doctrina-cli 0.1.1 → 0.2.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 -5
- package/package.json +1 -1
- package/src/commands/analyze.js +5 -5
- package/src/commands/change.js +113 -16
- package/src/commands/clarify.js +81 -18
- package/src/commands/context.js +129 -0
- package/src/commands/decision.js +91 -8
- package/src/commands/hooks.js +3 -3
- package/src/commands/index-rebuild.js +109 -0
- package/src/commands/init.js +58 -7
- package/src/commands/intake.js +166 -0
- package/src/commands/metrics.js +190 -0
- package/src/commands/next.js +108 -0
- package/src/commands/search.js +93 -0
- package/src/commands/skill.js +58 -5
- package/src/commands/spec.js +49 -7
- package/src/commands/templates.js +103 -4
- package/src/commands/validate.js +76 -9
- package/src/commands/work.js +215 -0
- package/src/index.js +26 -3
- package/src/lib/diff.js +103 -0
- package/src/lib/ears.js +108 -0
- package/src/lib/fs-ops.js +12 -0
- package/src/lib/scan.js +185 -0
- package/src/lib/templates.js +2 -2
- package/templates/AGENTS.md.template +15 -0
- package/templates/README.md +23 -2
- package/templates/doctrina/index.json.template +2 -1
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import process from "node:process";
|
|
3
|
-
import { exists, isDir, isFile, lineCount, read, walk } from "../lib/fs-ops.js";
|
|
3
|
+
import { exists, isDir, isFile, lineCount, read, relPath, walk, write } from "../lib/fs-ops.js";
|
|
4
4
|
import { locateTemplatesDir } from "../lib/templates.js";
|
|
5
|
+
import { flagBool } from "../lib/args.js";
|
|
5
6
|
import { c } from "../lib/colors.js";
|
|
6
7
|
import { suggest } from "../lib/suggest.js";
|
|
7
8
|
|
|
8
|
-
const SUBCOMMANDS = ["list", "check"];
|
|
9
|
+
const SUBCOMMANDS = ["list", "check", "update"];
|
|
9
10
|
|
|
10
11
|
// Recommended sections per file kind. Adopters whose files lack these
|
|
11
12
|
// headings get a warning from `templates check`; they are recommendations,
|
|
@@ -26,7 +27,7 @@ const PRODUCT_SECTIONS = [
|
|
|
26
27
|
];
|
|
27
28
|
const INDEX_FIELDS = ["$schema_version", "project", "artifacts"];
|
|
28
29
|
|
|
29
|
-
export async function run(positional,
|
|
30
|
+
export async function run(positional, flags) {
|
|
30
31
|
const sub = positional[0];
|
|
31
32
|
if (!SUBCOMMANDS.includes(sub)) {
|
|
32
33
|
console.error(c.red("error:") + ` unknown templates subcommand "${sub ?? ""}"`);
|
|
@@ -37,9 +38,102 @@ export async function run(positional, _flags) {
|
|
|
37
38
|
return 2;
|
|
38
39
|
}
|
|
39
40
|
if (sub === "list") return listTemplates();
|
|
41
|
+
if (sub === "update") return updateTemplates(flags);
|
|
40
42
|
return checkTemplates();
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
// Additive-only updater: appends missing recommended sections and adds
|
|
46
|
+
// missing index.json fields. Never rewrites or removes user content.
|
|
47
|
+
// Preview is the default; --write applies. This is the opt-in semantic
|
|
48
|
+
// the deferred register required before shipping any template updater.
|
|
49
|
+
function updateTemplates(flags) {
|
|
50
|
+
const writeMode = flagBool(flags, "write", false);
|
|
51
|
+
const projectRoot = process.cwd();
|
|
52
|
+
if (!isDir(path.join(projectRoot, ".doctrina"))) {
|
|
53
|
+
console.error(c.red("error:") + " not a Doctrina project (no .doctrina/ in cwd)");
|
|
54
|
+
return 1;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const plan = [];
|
|
58
|
+
|
|
59
|
+
// Markdown files: append stub sections for missing recommended headings.
|
|
60
|
+
for (const [rel, sections] of [
|
|
61
|
+
["AGENTS.md", AGENTS_SECTIONS],
|
|
62
|
+
[path.join(".doctrina", "product.md"), PRODUCT_SECTIONS],
|
|
63
|
+
]) {
|
|
64
|
+
const filePath = path.join(projectRoot, rel);
|
|
65
|
+
if (!isFile(filePath)) continue;
|
|
66
|
+
const text = read(filePath);
|
|
67
|
+
const missing = sections.filter((h) => !hasHeading(text, h));
|
|
68
|
+
if (missing.length > 0) {
|
|
69
|
+
plan.push({
|
|
70
|
+
describe: missing.map((h) => `${rel}: append stub section "${h}"`),
|
|
71
|
+
apply() {
|
|
72
|
+
let updated = read(filePath);
|
|
73
|
+
if (!updated.endsWith("\n")) updated += "\n";
|
|
74
|
+
for (const h of missing) {
|
|
75
|
+
updated += `\n${h}\n\n<!-- added by doctrina templates update — fill in -->\n`;
|
|
76
|
+
}
|
|
77
|
+
write(filePath, updated, { force: true });
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// index.json: add missing schema fields and artifact categories.
|
|
84
|
+
const indexPath = path.join(projectRoot, ".doctrina", "index.json");
|
|
85
|
+
if (isFile(indexPath)) {
|
|
86
|
+
try {
|
|
87
|
+
const idx = JSON.parse(read(indexPath));
|
|
88
|
+
const describe = [];
|
|
89
|
+
if (idx.$schema_version === undefined) describe.push('index.json: add "$schema_version": "0.1.0"');
|
|
90
|
+
if (idx.project === undefined) describe.push(`index.json: add "project": "${path.basename(projectRoot)}"`);
|
|
91
|
+
if (idx.artifacts === undefined) describe.push('index.json: add empty "artifacts" object');
|
|
92
|
+
const categories = ["specs", "decisions", "changes", "changes_archive", "skills"];
|
|
93
|
+
for (const cat of categories) {
|
|
94
|
+
if (idx.artifacts && idx.artifacts[cat] === undefined) {
|
|
95
|
+
describe.push(`index.json: add empty artifact category "${cat}"`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (describe.length > 0) {
|
|
99
|
+
plan.push({
|
|
100
|
+
describe,
|
|
101
|
+
apply() {
|
|
102
|
+
const current = JSON.parse(read(indexPath));
|
|
103
|
+
current.$schema_version ??= "0.1.0";
|
|
104
|
+
current.project ??= path.basename(projectRoot);
|
|
105
|
+
current.artifacts ??= {};
|
|
106
|
+
for (const cat of categories) current.artifacts[cat] ??= [];
|
|
107
|
+
write(indexPath, JSON.stringify(current, null, 2) + "\n", { force: true });
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
console.error(c.red("error:") + " .doctrina/index.json failed to parse; fix it before updating");
|
|
113
|
+
return 1;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const steps = plan.flatMap((p) => p.describe);
|
|
118
|
+
if (steps.length === 0) {
|
|
119
|
+
console.log(c.green("ok") + " project already follows the current template shape");
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
for (const s of steps) {
|
|
124
|
+
console.log((writeMode ? c.green("update ") : c.yellow("would ")) + s);
|
|
125
|
+
}
|
|
126
|
+
console.log("");
|
|
127
|
+
if (!writeMode) {
|
|
128
|
+
console.log(`${steps.length} pending update${steps.length === 1 ? "" : "s"} (preview only — nothing written)`);
|
|
129
|
+
console.log(c.gray("hint: ") + "re-run with --write to apply");
|
|
130
|
+
return 1;
|
|
131
|
+
}
|
|
132
|
+
for (const p of plan) p.apply();
|
|
133
|
+
console.log(c.green("ok") + ` ${steps.length} update${steps.length === 1 ? "" : "s"} applied (additive only — review the stubs and fill them in)`);
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
43
137
|
function listTemplates() {
|
|
44
138
|
const dir = locateTemplatesDir();
|
|
45
139
|
const files = walk(dir).filter((f) => !f.endsWith(".gitkeep"));
|
|
@@ -52,7 +146,7 @@ function listTemplates() {
|
|
|
52
146
|
let maxLen = 0;
|
|
53
147
|
const rows = [];
|
|
54
148
|
for (const f of files) {
|
|
55
|
-
const rel =
|
|
149
|
+
const rel = relPath(dir, f);
|
|
56
150
|
const lines = lineCount(f);
|
|
57
151
|
rows.push({ rel, lines });
|
|
58
152
|
if (rel.length > maxLen) maxLen = rel.length;
|
|
@@ -155,6 +249,11 @@ Subcommands:
|
|
|
155
249
|
recommended section that is missing. Read-only; never
|
|
156
250
|
modifies any files. Exits 1 when recommendations exist,
|
|
157
251
|
0 when the project follows the current template shape.
|
|
252
|
+
update Additive-only fixer for what check reports: appends
|
|
253
|
+
missing recommended sections as stubs and adds missing
|
|
254
|
+
index.json fields. Preview by default (exits 1 while
|
|
255
|
+
updates are pending); pass --write to apply. Never
|
|
256
|
+
rewrites or removes existing content.
|
|
158
257
|
|
|
159
258
|
Distinct from \`doctrina validate\`: validate answers "is this a
|
|
160
259
|
well-formed Doctrina tree?"; templates check answers "does this
|
package/src/commands/validate.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import process from "node:process";
|
|
3
|
-
import {
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
|
+
import { exists, isDir, isFile, lineCount, read, relPath, walk } from "../lib/fs-ops.js";
|
|
4
5
|
import * as idx from "../lib/index-json.js";
|
|
5
6
|
import { c } from "../lib/colors.js";
|
|
6
7
|
import { parseFrontmatter } from "./skill.js";
|
|
8
|
+
import { checkEars, isEarsSpec } from "../lib/ears.js";
|
|
7
9
|
|
|
8
10
|
export async function run(_positional, _flags) {
|
|
9
11
|
const projectRoot = process.cwd();
|
|
@@ -20,6 +22,15 @@ export async function run(_positional, _flags) {
|
|
|
20
22
|
else if (lines > 150) warnings.push(`AGENTS.md is ${lines} lines (>150 soft limit)`);
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
// 1b. Nested AGENTS.md files ("nearest wins" hierarchy) obey the same
|
|
26
|
+
// size caps as the root file.
|
|
27
|
+
for (const nested of findNestedAgentsMd(projectRoot)) {
|
|
28
|
+
const rel = relPath(projectRoot, nested);
|
|
29
|
+
const lines = lineCount(nested);
|
|
30
|
+
if (lines > 200) errors.push(`${rel} is ${lines} lines (>200, hard limit)`);
|
|
31
|
+
else if (lines > 150) warnings.push(`${rel} is ${lines} lines (>150 soft limit)`);
|
|
32
|
+
}
|
|
33
|
+
|
|
23
34
|
// 2. product.md
|
|
24
35
|
const productMd = path.join(projectRoot, ".doctrina", "product.md");
|
|
25
36
|
if (!isFile(productMd)) errors.push(".doctrina/product.md missing");
|
|
@@ -50,6 +61,17 @@ export async function run(_positional, _flags) {
|
|
|
50
61
|
const full = path.join(projectRoot, rel);
|
|
51
62
|
if (!exists(full)) errors.push(`index.json references missing artifact: ${rel}`);
|
|
52
63
|
}
|
|
64
|
+
|
|
65
|
+
// 4b. Spec version drift — the Version: header inside each spec
|
|
66
|
+
// must match the version recorded in index.json.
|
|
67
|
+
for (const s of index.artifacts.specs ?? []) {
|
|
68
|
+
const full = path.join(projectRoot, s.path);
|
|
69
|
+
if (!isFile(full)) continue;
|
|
70
|
+
const m = read(full).match(/^\*\*Version:\*\*\s+(\S+)/m);
|
|
71
|
+
if (m && s.version && m[1] !== s.version) {
|
|
72
|
+
warnings.push(`${s.path} declares version ${m[1]} but index.json records ${s.version} (sync the index)`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
53
75
|
}
|
|
54
76
|
}
|
|
55
77
|
|
|
@@ -60,7 +82,7 @@ export async function run(_positional, _flags) {
|
|
|
60
82
|
if (!f.endsWith(".md")) continue;
|
|
61
83
|
const text = read(f);
|
|
62
84
|
if (!/^-\s+\*\*Status:\*\*\s+\S+/m.test(text)) {
|
|
63
|
-
errors.push(`${
|
|
85
|
+
errors.push(`${relPath(projectRoot, f)} missing or malformed Status: header`);
|
|
64
86
|
}
|
|
65
87
|
}
|
|
66
88
|
}
|
|
@@ -71,7 +93,7 @@ export async function run(_positional, _flags) {
|
|
|
71
93
|
for (const f of walk(adaptersDir)) {
|
|
72
94
|
if (!f.endsWith(".template")) continue;
|
|
73
95
|
const lines = lineCount(f);
|
|
74
|
-
if (lines > 30) errors.push(`${
|
|
96
|
+
if (lines > 30) errors.push(`${relPath(projectRoot, f)} is ${lines} lines (>30, adapter cap)`);
|
|
75
97
|
}
|
|
76
98
|
}
|
|
77
99
|
|
|
@@ -96,7 +118,15 @@ export async function run(_positional, _flags) {
|
|
|
96
118
|
const specPath = path.join(specsDir, cap, "spec.md");
|
|
97
119
|
if (isFile(specPath)) {
|
|
98
120
|
const lines = lineCount(specPath);
|
|
99
|
-
if (lines > 400) warnings.push(`${
|
|
121
|
+
if (lines > 400) warnings.push(`${relPath(projectRoot, specPath)} is ${lines} lines (>400 soft cap)`);
|
|
122
|
+
|
|
123
|
+
// 8b. EARS grammar shape — section-appropriate When/While/Where/shall.
|
|
124
|
+
const text = read(specPath);
|
|
125
|
+
if (isEarsSpec(text)) {
|
|
126
|
+
for (const f of checkEars(text)) {
|
|
127
|
+
warnings.push(`${relPath(projectRoot, specPath)}:${f.line} ${f.message}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
100
130
|
}
|
|
101
131
|
}
|
|
102
132
|
}
|
|
@@ -106,7 +136,7 @@ export async function run(_positional, _flags) {
|
|
|
106
136
|
for (const f of walk(adrDir)) {
|
|
107
137
|
if (!f.endsWith(".md")) continue;
|
|
108
138
|
const lines = lineCount(f);
|
|
109
|
-
if (lines > 300) warnings.push(`${
|
|
139
|
+
if (lines > 300) warnings.push(`${relPath(projectRoot, f)} is ${lines} lines (>300 soft cap)`);
|
|
110
140
|
}
|
|
111
141
|
}
|
|
112
142
|
|
|
@@ -126,7 +156,7 @@ export async function run(_positional, _flags) {
|
|
|
126
156
|
path.resolve(projectRoot, ref),
|
|
127
157
|
];
|
|
128
158
|
if (!candidates.some(exists)) {
|
|
129
|
-
warnings.push(`${
|
|
159
|
+
warnings.push(`${relPath(projectRoot, f)} references missing path \`${ref}\``);
|
|
130
160
|
}
|
|
131
161
|
}
|
|
132
162
|
}
|
|
@@ -140,7 +170,7 @@ export async function run(_positional, _flags) {
|
|
|
140
170
|
for (const cap of readdirSync(specsDir)) {
|
|
141
171
|
const specPath = path.join(specsDir, cap, "spec.md");
|
|
142
172
|
if (isFile(specPath) && !indexedSpecIds.has(cap)) {
|
|
143
|
-
warnings.push(`orphan spec ${
|
|
173
|
+
warnings.push(`orphan spec ${relPath(projectRoot, specPath)} (capability "${cap}" not in index.json)`);
|
|
144
174
|
}
|
|
145
175
|
}
|
|
146
176
|
}
|
|
@@ -150,7 +180,7 @@ export async function run(_positional, _flags) {
|
|
|
150
180
|
if (!f.endsWith(".md")) continue;
|
|
151
181
|
const m = path.basename(f).match(/^(\d{4})-/);
|
|
152
182
|
if (m && !indexedAdrIds.has(m[1])) {
|
|
153
|
-
warnings.push(`orphan ADR ${
|
|
183
|
+
warnings.push(`orphan ADR ${relPath(projectRoot, f)} (id "${m[1]}" not in index.json)`);
|
|
154
184
|
}
|
|
155
185
|
}
|
|
156
186
|
}
|
|
@@ -162,7 +192,7 @@ export async function run(_positional, _flags) {
|
|
|
162
192
|
for (const f of walk(skillsDir)) {
|
|
163
193
|
if (!f.endsWith(".md")) continue;
|
|
164
194
|
const text = read(f);
|
|
165
|
-
const rel =
|
|
195
|
+
const rel = relPath(projectRoot, f);
|
|
166
196
|
const baseName = path.basename(f, ".md");
|
|
167
197
|
const nameField = parseFrontmatter(text, "name");
|
|
168
198
|
const descField = parseFrontmatter(text, "description");
|
|
@@ -176,6 +206,15 @@ export async function run(_positional, _flags) {
|
|
|
176
206
|
const lines = lineCount(f);
|
|
177
207
|
if (lines > 200) warnings.push(`${rel} is ${lines} lines (>200 soft cap)`);
|
|
178
208
|
else if (lines > 150) warnings.push(`${rel} is ${lines} lines (>150 soft cap)`);
|
|
209
|
+
|
|
210
|
+
// 15. Skill description drift — index.json must mirror the
|
|
211
|
+
// frontmatter description (run `doctrina skill sync`).
|
|
212
|
+
if (descField && index?.artifacts?.skills) {
|
|
213
|
+
const entry = index.artifacts.skills.find((s) => s.id === baseName);
|
|
214
|
+
if (entry && entry.description !== descField) {
|
|
215
|
+
warnings.push(`${rel} description differs from index.json (run \`doctrina skill sync\`)`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
179
218
|
}
|
|
180
219
|
}
|
|
181
220
|
|
|
@@ -193,6 +232,34 @@ export async function run(_positional, _flags) {
|
|
|
193
232
|
return errors.length === 0 ? 0 : 1;
|
|
194
233
|
}
|
|
195
234
|
|
|
235
|
+
// Find AGENTS.md files below the root (the "nearest AGENTS.md wins"
|
|
236
|
+
// hierarchy). Bounded walk: dependency, build, and VCS directories are
|
|
237
|
+
// skipped so validate stays fast on large repositories.
|
|
238
|
+
const NESTED_SKIP_DIRS = new Set([
|
|
239
|
+
".git", "node_modules", ".doctrina", "vendor", "dist", "build", "out",
|
|
240
|
+
"target", ".venv", "venv", "__pycache__", ".next", "coverage",
|
|
241
|
+
]);
|
|
242
|
+
|
|
243
|
+
function findNestedAgentsMd(projectRoot) {
|
|
244
|
+
const found = [];
|
|
245
|
+
const stack = readdirSync(projectRoot)
|
|
246
|
+
.filter((e) => !NESTED_SKIP_DIRS.has(e) && !e.startsWith("."))
|
|
247
|
+
.map((e) => path.join(projectRoot, e))
|
|
248
|
+
.filter(isDir);
|
|
249
|
+
while (stack.length) {
|
|
250
|
+
const dir = stack.pop();
|
|
251
|
+
for (const entry of readdirSync(dir)) {
|
|
252
|
+
const full = path.join(dir, entry);
|
|
253
|
+
if (isDir(full)) {
|
|
254
|
+
if (!NESTED_SKIP_DIRS.has(entry) && !entry.startsWith(".")) stack.push(full);
|
|
255
|
+
} else if (entry === "AGENTS.md") {
|
|
256
|
+
found.push(full);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return found.sort();
|
|
261
|
+
}
|
|
262
|
+
|
|
196
263
|
// Extract candidate file path references from Markdown text. Only
|
|
197
264
|
// Markdown link targets `[text](path)` are checked — backtick spans
|
|
198
265
|
// are descriptive prose and produce too many false positives.
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { readdirSync } from "node:fs";
|
|
4
|
+
import { exists, isDir, isFile, read, relPath, write } from "../lib/fs-ops.js";
|
|
5
|
+
import { flagString } from "../lib/args.js";
|
|
6
|
+
import { c } from "../lib/colors.js";
|
|
7
|
+
import { changeNew } from "./change.js";
|
|
8
|
+
|
|
9
|
+
// `work` is the second half of the no-ceremony path (ADR 0005): a brief
|
|
10
|
+
// prompt ("add login") becomes a fully scaffolded change plus a playbook
|
|
11
|
+
// the host agent executes — context, spec deltas, tasks, implementation,
|
|
12
|
+
// then the analyze → apply → archive → validate close. The CLI's own
|
|
13
|
+
// language processing stops at slugging and term counting; everything
|
|
14
|
+
// semantic is the agent's job.
|
|
15
|
+
|
|
16
|
+
export async function run(positional, flags) {
|
|
17
|
+
const prompt = positional.join(" ").trim();
|
|
18
|
+
if (!prompt) {
|
|
19
|
+
console.error(c.red("error:") + " work requires a prompt (quote it if it contains spaces)");
|
|
20
|
+
console.error(c.gray("hint: ") + "example: doctrina work \"add login with email and password\"");
|
|
21
|
+
return 2;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const projectRoot = process.cwd();
|
|
25
|
+
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
26
|
+
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const pinned = flagString(flags, "capability");
|
|
30
|
+
if (pinned && !/^[a-z][a-z0-9-]*$/.test(pinned)) {
|
|
31
|
+
console.error(c.red("error:") + ` invalid capability "${pinned}" (lowercase letters, digits, hyphens)`);
|
|
32
|
+
return 2;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const id = flagString(flags, "id") ?? `${nextChangeNumber(projectRoot)}-${slugify(prompt)}`;
|
|
36
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(id)) {
|
|
37
|
+
console.error(c.red("error:") + ` invalid change id "${id}" (lowercase letters, digits, hyphens)`);
|
|
38
|
+
return 2;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const code = changeNew([id, prompt], flags);
|
|
42
|
+
if (code !== 0) return code;
|
|
43
|
+
|
|
44
|
+
// Record the prompt verbatim as the change's Why — the raw intent has
|
|
45
|
+
// one home, and it is the proposal, not the playbook output.
|
|
46
|
+
const proposalPath = path.join(projectRoot, ".doctrina", "changes", id, "proposal.md");
|
|
47
|
+
if (isFile(proposalPath)) {
|
|
48
|
+
const txt = read(proposalPath);
|
|
49
|
+
// CRLF-tolerant: templates may be checked out with \r\n on Windows.
|
|
50
|
+
let updated = txt.replace(/(## Why\r?\n\r?\n)<!--[\s\S]*?-->[ \t]*\r?\n?/, `$1${prompt}\n`);
|
|
51
|
+
if (updated === txt) updated = txt.replace(/(## Why\r?\n)/, `$1\n${prompt}\n`);
|
|
52
|
+
if (pinned) {
|
|
53
|
+
updated = updated.replace(/^(-\s+\*\*Affects specs:\*\*).*$/m, `$1 ${pinned}`);
|
|
54
|
+
}
|
|
55
|
+
if (updated !== txt) write(proposalPath, updated, { force: true });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const matches = pinned ? [] : rankCapabilities(projectRoot, prompt);
|
|
59
|
+
const capability = pinned ?? matches[0]?.id ?? null;
|
|
60
|
+
|
|
61
|
+
console.log("");
|
|
62
|
+
printPlaybook(projectRoot, { id, prompt, pinned, matches, capability });
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Next sequential NNNN across open changes and the archive, so work-driven
|
|
67
|
+
// ids stay ordered no matter how earlier changes were named.
|
|
68
|
+
function nextChangeNumber(projectRoot) {
|
|
69
|
+
const changesDir = path.join(projectRoot, ".doctrina", "changes");
|
|
70
|
+
let max = 0;
|
|
71
|
+
const harvest = (name) => {
|
|
72
|
+
const m = name.match(/^(\d{1,4})-/) ?? name.match(/^(\d{1,4})$/);
|
|
73
|
+
if (m) max = Math.max(max, Number(m[1]));
|
|
74
|
+
};
|
|
75
|
+
if (isDir(changesDir)) {
|
|
76
|
+
for (const entry of readdirSync(changesDir)) {
|
|
77
|
+
if (entry === "archive" || entry.startsWith(".")) continue;
|
|
78
|
+
if (isDir(path.join(changesDir, entry))) harvest(entry);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const archiveDir = path.join(changesDir, "archive");
|
|
82
|
+
if (isDir(archiveDir)) {
|
|
83
|
+
for (const entry of readdirSync(archiveDir)) {
|
|
84
|
+
if (!isDir(path.join(archiveDir, entry))) continue;
|
|
85
|
+
harvest(entry.replace(/^\d{4}-\d{2}-\d{2}-/, ""));
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return String(max + 1).padStart(4, "0");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ASCII-folded kebab-case of the prompt ("Faça o login" -> "faca-o-login").
|
|
92
|
+
export function slugify(text) {
|
|
93
|
+
let slug = text
|
|
94
|
+
.normalize("NFD")
|
|
95
|
+
.replace(/\p{M}+/gu, "")
|
|
96
|
+
.toLowerCase()
|
|
97
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
98
|
+
.replace(/^-+|-+$/g, "");
|
|
99
|
+
if (slug.length > 48) slug = slug.slice(0, 48).replace(/-+$/g, "");
|
|
100
|
+
return slug.length > 0 ? slug : "task";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Deterministic term overlap: fold prompt and spec text to ASCII lowercase,
|
|
104
|
+
// drop short/stop words, score name hits heavily and body hits lightly.
|
|
105
|
+
// This is a hint for the agent, never a decision (ADR 0005).
|
|
106
|
+
const STOPWORDS = new Set([
|
|
107
|
+
// en
|
|
108
|
+
"the", "and", "for", "with", "from", "that", "this", "into", "when",
|
|
109
|
+
"then", "shall", "should", "must", "can", "will", "make", "add", "new",
|
|
110
|
+
"use", "create", "implement", "feature", "system", "user", "users",
|
|
111
|
+
// pt (ASCII-folded)
|
|
112
|
+
"uma", "umas", "uns", "dos", "das", "nos", "nas", "por", "para", "com",
|
|
113
|
+
"que", "sem", "aos", "faca", "fazer", "criar", "crie", "novo", "nova",
|
|
114
|
+
"adicionar", "adicione", "implementar", "implemente", "funcionalidade",
|
|
115
|
+
"sistema", "usuario", "usuarios",
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
function fold(text) {
|
|
119
|
+
return text.normalize("NFD").replace(/\p{M}+/gu, "").toLowerCase();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function promptTerms(prompt) {
|
|
123
|
+
return [...new Set(
|
|
124
|
+
fold(prompt)
|
|
125
|
+
.split(/[^a-z0-9]+/)
|
|
126
|
+
.filter((t) => t.length >= 3 && !STOPWORDS.has(t)),
|
|
127
|
+
)];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function rankCapabilities(projectRoot, prompt) {
|
|
131
|
+
const specsDir = path.join(projectRoot, ".doctrina", "specs");
|
|
132
|
+
const terms = promptTerms(prompt);
|
|
133
|
+
if (terms.length === 0 || !isDir(specsDir)) return [];
|
|
134
|
+
|
|
135
|
+
const ranked = [];
|
|
136
|
+
for (const cap of readdirSync(specsDir).sort()) {
|
|
137
|
+
const specPath = path.join(specsDir, cap, "spec.md");
|
|
138
|
+
if (!isFile(specPath)) continue;
|
|
139
|
+
const body = fold(read(specPath));
|
|
140
|
+
const nameTokens = cap.split("-");
|
|
141
|
+
let score = 0;
|
|
142
|
+
for (const term of terms) {
|
|
143
|
+
if (nameTokens.includes(term)) score += 5;
|
|
144
|
+
const hits = body.split(term).length - 1;
|
|
145
|
+
score += Math.min(hits, 5);
|
|
146
|
+
}
|
|
147
|
+
if (score > 0) ranked.push({ id: cap, score, path: `.doctrina/specs/${cap}/spec.md` });
|
|
148
|
+
}
|
|
149
|
+
return ranked.sort((a, b) => b.score - a.score || a.id.localeCompare(b.id)).slice(0, 3);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function printPlaybook(projectRoot, { id, prompt, pinned, matches, capability }) {
|
|
153
|
+
const capToken = capability ?? "<capability>";
|
|
154
|
+
console.log(c.bold(`Work playbook — change ${id}`) + c.gray(" — agent-executed (ADR 0005)."));
|
|
155
|
+
console.log("");
|
|
156
|
+
console.log(`Prompt: "${prompt}"`);
|
|
157
|
+
|
|
158
|
+
if (pinned) {
|
|
159
|
+
const hasSpec = isFile(path.join(projectRoot, ".doctrina", "specs", pinned, "spec.md"));
|
|
160
|
+
console.log(`Capability (pinned): ${c.cyan(pinned)}` +
|
|
161
|
+
(hasSpec ? "" : c.yellow(" — no spec yet; create it in step 2")));
|
|
162
|
+
} else if (matches.length > 0) {
|
|
163
|
+
console.log("Likely capabilities (deterministic term match — a hint, not a decision):");
|
|
164
|
+
for (const m of matches) {
|
|
165
|
+
console.log(` ${c.cyan(m.id.padEnd(20))} score ${String(m.score).padStart(3)} ${c.gray(m.path)}`);
|
|
166
|
+
}
|
|
167
|
+
} else {
|
|
168
|
+
console.log(c.gray("No existing spec matches the prompt — likely a new capability."));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
console.log("");
|
|
172
|
+
console.log("Execute in order, in a single linear pass:");
|
|
173
|
+
console.log("");
|
|
174
|
+
console.log("1. Read the context pack and confirm (or correct) the capability:");
|
|
175
|
+
console.log(` ${c.cyan(`doctrina context ${capToken} --concat`)}`);
|
|
176
|
+
console.log("");
|
|
177
|
+
console.log("2. If the capability has no spec yet:");
|
|
178
|
+
console.log(` ${c.cyan("doctrina spec new <capability>")}`);
|
|
179
|
+
console.log("");
|
|
180
|
+
console.log("3. Write one delta per affected capability at");
|
|
181
|
+
console.log(` .doctrina/changes/${id}/specs/<capability>/delta.md:`);
|
|
182
|
+
console.log(c.gray(" # Spec Delta — capability: <capability>"));
|
|
183
|
+
console.log(c.gray(" **Operation:** ADDED | MODIFIED | REMOVED"));
|
|
184
|
+
console.log(c.gray(" **Target spec on apply:** `.doctrina/specs/<capability>/spec.md`"));
|
|
185
|
+
console.log(c.gray(" ---"));
|
|
186
|
+
console.log(c.gray(" <EARS requirements: full spec body for ADDED, fragment for MODIFIED>"));
|
|
187
|
+
console.log("");
|
|
188
|
+
console.log(`4. Replace the placeholder tasks in .doctrina/changes/${id}/tasks.md`);
|
|
189
|
+
console.log(" with small, checkable implementation tasks (a few hours each, max).");
|
|
190
|
+
console.log("");
|
|
191
|
+
console.log("5. Implement task by task, checking each box as it lands. If the");
|
|
192
|
+
console.log(" prompt is genuinely ambiguous, ask the user before assuming.");
|
|
193
|
+
console.log("");
|
|
194
|
+
console.log(`6. ${c.cyan(`doctrina analyze ${id}`)} — fix every ✗ before applying.`);
|
|
195
|
+
console.log(`7. ${c.cyan(`doctrina change apply ${id}`)} (MODIFIED deltas are merged by hand).`);
|
|
196
|
+
console.log(`8. ${c.cyan(`doctrina change archive ${id}`)}`);
|
|
197
|
+
console.log(`9. ${c.cyan("doctrina validate")}, then ${c.cyan("doctrina next")} for the follow-up.`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export const help = `
|
|
201
|
+
Usage: doctrina work "<prompt>" [--capability <cap>] [--id <id>] [--force]
|
|
202
|
+
|
|
203
|
+
Turn a brief prompt into a scaffolded change plus the work playbook the
|
|
204
|
+
host AI agent executes (see ADR 0005). The CLI derives a sequential
|
|
205
|
+
change id (NNNN-<slug>) from the prompt, opens the change folder,
|
|
206
|
+
records the prompt under the proposal's "## Why", ranks existing specs
|
|
207
|
+
by term overlap as a capability hint, and prints the ordered steps:
|
|
208
|
+
context → spec delta → tasks → implement → analyze → apply → archive →
|
|
209
|
+
validate. No natural-language interpretation happens in the CLI.
|
|
210
|
+
|
|
211
|
+
Options:
|
|
212
|
+
--capability <cap> Pin the capability instead of ranking matches
|
|
213
|
+
--id <id> Override the derived change id
|
|
214
|
+
--force Overwrite an existing change folder
|
|
215
|
+
`;
|
package/src/index.js
CHANGED
|
@@ -17,28 +17,51 @@ import * as analyze from "./commands/analyze.js";
|
|
|
17
17
|
import * as clarify from "./commands/clarify.js";
|
|
18
18
|
import * as templates from "./commands/templates.js";
|
|
19
19
|
import * as skill from "./commands/skill.js";
|
|
20
|
+
import * as indexCmd from "./commands/index-rebuild.js";
|
|
21
|
+
import * as next from "./commands/next.js";
|
|
22
|
+
import * as metrics from "./commands/metrics.js";
|
|
23
|
+
import * as context from "./commands/context.js";
|
|
24
|
+
import * as search from "./commands/search.js";
|
|
25
|
+
import * as intake from "./commands/intake.js";
|
|
26
|
+
import * as work from "./commands/work.js";
|
|
20
27
|
|
|
21
|
-
const COMMANDS = {
|
|
28
|
+
const COMMANDS = {
|
|
29
|
+
init, spec, change, decision, validate, hooks, analyze, clarify,
|
|
30
|
+
templates, skill, index: indexCmd, next, metrics, context, search,
|
|
31
|
+
intake, work,
|
|
32
|
+
};
|
|
22
33
|
|
|
23
34
|
const TOP_HELP = `
|
|
24
35
|
Usage: doctrina <command> [options]
|
|
25
36
|
|
|
26
37
|
Commands:
|
|
27
38
|
init Scaffold AGENTS.md and .doctrina/ in the current directory
|
|
39
|
+
intake Store the full project description; print the bootstrap playbook
|
|
40
|
+
work Brief prompt -> scaffolded change + guided work playbook
|
|
28
41
|
spec new Create a new capability spec (--bug for bug-shape)
|
|
42
|
+
spec list List specs with version, status, and size
|
|
29
43
|
change new Open a change proposal
|
|
30
44
|
change apply Apply spec deltas (ADDED/REMOVED auto, MODIFIED manual)
|
|
31
45
|
change archive Archive an applied change
|
|
46
|
+
change diff Preview spec deltas (line diff for MODIFIED)
|
|
32
47
|
decision new Create the next sequentially numbered ADR
|
|
48
|
+
decision accept Flip a proposed ADR to accepted
|
|
33
49
|
decision supersede Create a new ADR that supersedes an existing one
|
|
50
|
+
decision list List ADRs with status, date, and title
|
|
34
51
|
skill new Scaffold an on-demand procedural memory skill
|
|
35
52
|
skill list List skills with their descriptions
|
|
53
|
+
skill sync Mirror skill frontmatter descriptions into index.json
|
|
36
54
|
analyze Inspect a change folder before applying
|
|
37
|
-
clarify Smell-test a Markdown file for ambiguity
|
|
55
|
+
clarify Smell-test a Markdown file for ambiguity (--all for the tree)
|
|
56
|
+
context Print the context pack for a task in read order
|
|
57
|
+
search Search the artifact tree, grouped by category
|
|
38
58
|
validate Run schema and structural checks
|
|
39
59
|
templates list List the templates shipped by the installed CLI
|
|
40
60
|
templates check Compare the project against the recommended template shape
|
|
41
61
|
hooks install Install the pre-commit hook
|
|
62
|
+
index rebuild Regenerate index.json from the artifacts on disk
|
|
63
|
+
next Print the recommended next workflow actions
|
|
64
|
+
metrics Local git-derived adoption metrics (no network)
|
|
42
65
|
|
|
43
66
|
Global flags:
|
|
44
67
|
--help, -h Show this message (or per-command help if after a command)
|
|
@@ -47,7 +70,7 @@ Global flags:
|
|
|
47
70
|
|
|
48
71
|
async function main(argv) {
|
|
49
72
|
const { positional, flags } = parseArgs(argv, {
|
|
50
|
-
boolean: ["help", "h", "version", "v", "force", "non-interactive"],
|
|
73
|
+
boolean: ["help", "h", "version", "v", "force", "non-interactive", "check", "save", "bug", "write", "all", "concat", "archive"],
|
|
51
74
|
});
|
|
52
75
|
|
|
53
76
|
if (flags.get("version") || flags.get("v")) {
|