doctrina-cli 0.1.0 → 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 +4 -2
- 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 +83 -0
- package/templates/README.md +85 -0
- package/templates/adapters/aider/CONVENTIONS.md.template +11 -0
- package/templates/adapters/amp/README.md +13 -0
- package/templates/adapters/claude/CLAUDE.md.template +11 -0
- package/templates/adapters/codex/README.md +12 -0
- package/templates/adapters/continue/.continue/rules/00-doctrina.md.template +11 -0
- package/templates/adapters/copilot/.github/copilot-instructions.md.template +11 -0
- package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +16 -0
- package/templates/adapters/devin/README.md +14 -0
- package/templates/adapters/factory/README.md +13 -0
- package/templates/adapters/gemini/GEMINI.md.template +11 -0
- package/templates/adapters/jules/README.md +13 -0
- package/templates/adapters/windsurf/.windsurfrules.template +11 -0
- package/templates/change/design.md.template +24 -0
- package/templates/change/proposal.md.template +26 -0
- package/templates/change/spec-delta.md.template +20 -0
- package/templates/change/tasks.md.template +17 -0
- package/templates/decision.md.template +56 -0
- package/templates/doctrina/changes/.gitkeep +0 -0
- package/templates/doctrina/changes/archive/.gitkeep +0 -0
- package/templates/doctrina/decisions/.gitkeep +0 -0
- package/templates/doctrina/index.json.template +19 -0
- package/templates/doctrina/product.md.template +31 -0
- package/templates/doctrina/skills/.gitkeep +0 -0
- package/templates/doctrina/specs/.gitkeep +0 -0
- package/templates/doctrina/templates/.gitkeep +0 -0
- package/templates/hooks/pre-commit.sample +7 -0
- package/templates/hooks/watch.sample +21 -0
- package/templates/skill.md.template +49 -0
- package/templates/spec-bug.md.template +40 -0
- package/templates/spec.md.template +52 -0
package/src/commands/decision.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import process from "node:process";
|
|
3
|
-
import { exists, read, write } from "../lib/fs-ops.js";
|
|
3
|
+
import { exists, read, relPath, write } from "../lib/fs-ops.js";
|
|
4
4
|
import { walk } from "../lib/fs-ops.js";
|
|
5
5
|
import { locateTemplatesDir, substitute } from "../lib/templates.js";
|
|
6
6
|
import * as idx from "../lib/index-json.js";
|
|
@@ -8,7 +8,7 @@ import { today, slugify, padNumber } from "../lib/dates.js";
|
|
|
8
8
|
import { c } from "../lib/colors.js";
|
|
9
9
|
import { suggest } from "../lib/suggest.js";
|
|
10
10
|
|
|
11
|
-
const SUBCOMMANDS = ["new", "supersede"];
|
|
11
|
+
const SUBCOMMANDS = ["new", "supersede", "accept", "list"];
|
|
12
12
|
|
|
13
13
|
export async function run(positional, _flags) {
|
|
14
14
|
const sub = positional[0];
|
|
@@ -17,6 +17,10 @@ export async function run(positional, _flags) {
|
|
|
17
17
|
return decisionNew(positional.slice(1));
|
|
18
18
|
case "supersede":
|
|
19
19
|
return decisionSupersede(positional.slice(1));
|
|
20
|
+
case "accept":
|
|
21
|
+
return decisionAccept(positional.slice(1));
|
|
22
|
+
case "list":
|
|
23
|
+
return decisionList();
|
|
20
24
|
default:
|
|
21
25
|
console.error(c.red("error:") + ` unknown decision subcommand "${sub ?? ""}"`);
|
|
22
26
|
const guess = suggest(sub, SUBCOMMANDS);
|
|
@@ -42,7 +46,7 @@ function decisionNew(args) {
|
|
|
42
46
|
const filename = `${next}-${slug}.md`;
|
|
43
47
|
const targetPath = path.join(projectRoot, ".doctrina", "decisions", filename);
|
|
44
48
|
if (exists(targetPath)) {
|
|
45
|
-
console.error(c.red("error:") + ` ${
|
|
49
|
+
console.error(c.red("error:") + ` ${relPath(projectRoot, targetPath)} already exists`);
|
|
46
50
|
return 1;
|
|
47
51
|
}
|
|
48
52
|
|
|
@@ -55,7 +59,7 @@ function decisionNew(args) {
|
|
|
55
59
|
DATE: date,
|
|
56
60
|
});
|
|
57
61
|
write(targetPath, body, { force: false });
|
|
58
|
-
console.log(c.green("created") + ` ${
|
|
62
|
+
console.log(c.green("created") + ` ${relPath(projectRoot, targetPath)}`);
|
|
59
63
|
|
|
60
64
|
const index = idx.load(projectRoot);
|
|
61
65
|
idx.addDecision(index, {
|
|
@@ -85,13 +89,13 @@ function decisionSupersede(args) {
|
|
|
85
89
|
const files = walk(adrDir);
|
|
86
90
|
const oldFile = files.find((f) => path.basename(f).startsWith(`${padded}-`));
|
|
87
91
|
if (!oldFile) {
|
|
88
|
-
console.error(c.red("error:") + ` no ADR found with number ${padded} in ${
|
|
92
|
+
console.error(c.red("error:") + ` no ADR found with number ${padded} in ${relPath(projectRoot, adrDir)}`);
|
|
89
93
|
return 1;
|
|
90
94
|
}
|
|
91
95
|
const oldText = read(oldFile);
|
|
92
96
|
const oldStatusMatch = oldText.match(/^-\s+\*\*Status:\*\*\s+(.+)$/m);
|
|
93
97
|
if (!oldStatusMatch) {
|
|
94
|
-
console.error(c.red("error:") + ` ADR at ${
|
|
98
|
+
console.error(c.red("error:") + ` ADR at ${relPath(projectRoot, oldFile)} has no Status: header`);
|
|
95
99
|
return 1;
|
|
96
100
|
}
|
|
97
101
|
const oldStatus = oldStatusMatch[1].trim();
|
|
@@ -124,14 +128,14 @@ function decisionSupersede(args) {
|
|
|
124
128
|
// Inject Supersedes link
|
|
125
129
|
body = body.replace(/(\*\*Supersedes:\*\*)\s+—/, `$1 ${padded}`);
|
|
126
130
|
write(newPath, body, { force: false });
|
|
127
|
-
console.log(c.green("created") + ` ${
|
|
131
|
+
console.log(c.green("created") + ` ${relPath(projectRoot, newPath)}`);
|
|
128
132
|
|
|
129
133
|
// Mutate ONLY the Status: and Superseded by: headers of the old ADR
|
|
130
134
|
const updated = oldText
|
|
131
135
|
.replace(/^(-\s+\*\*Status:\*\*)\s+.+$/m, `$1 superseded by ${next}`)
|
|
132
136
|
.replace(/^(-\s+\*\*Superseded by:\*\*)\s+.+$/m, `$1 ${next}`);
|
|
133
137
|
write(oldFile, updated, { force: true });
|
|
134
|
-
console.log(c.green("updated") + ` ${
|
|
138
|
+
console.log(c.green("updated") + ` ${relPath(projectRoot, oldFile)} status -> superseded by ${next}`);
|
|
135
139
|
|
|
136
140
|
// Update index
|
|
137
141
|
const index = idx.load(projectRoot);
|
|
@@ -153,6 +157,81 @@ function decisionSupersede(args) {
|
|
|
153
157
|
return 0;
|
|
154
158
|
}
|
|
155
159
|
|
|
160
|
+
function decisionAccept(args) {
|
|
161
|
+
const target = args[0];
|
|
162
|
+
if (!target) {
|
|
163
|
+
console.error(c.red("error:") + " decision accept requires the target ADR number (e.g. 0003)");
|
|
164
|
+
return 2;
|
|
165
|
+
}
|
|
166
|
+
const projectRoot = process.cwd();
|
|
167
|
+
ensureDoctrinaProject(projectRoot);
|
|
168
|
+
|
|
169
|
+
const padded = padNumber(parseInt(target, 10));
|
|
170
|
+
const adrDir = path.join(projectRoot, ".doctrina", "decisions");
|
|
171
|
+
const file = walk(adrDir).find((f) => path.basename(f).startsWith(`${padded}-`));
|
|
172
|
+
if (!file) {
|
|
173
|
+
console.error(c.red("error:") + ` no ADR found with number ${padded} in ${relPath(projectRoot, adrDir)}`);
|
|
174
|
+
return 1;
|
|
175
|
+
}
|
|
176
|
+
const text = read(file);
|
|
177
|
+
const statusMatch = text.match(/^-\s+\*\*Status:\*\*\s+(.+)$/m);
|
|
178
|
+
if (!statusMatch) {
|
|
179
|
+
console.error(c.red("error:") + ` ADR at ${relPath(projectRoot, file)} has no Status: header`);
|
|
180
|
+
return 1;
|
|
181
|
+
}
|
|
182
|
+
const status = statusMatch[1].trim().toLowerCase();
|
|
183
|
+
if (status !== "proposed") {
|
|
184
|
+
console.error(c.red("error:") + ` ADR ${padded} is "${statusMatch[1].trim()}", not "proposed" — nothing to accept`);
|
|
185
|
+
return 1;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Mutate ONLY the Status: header; the body stays immutable.
|
|
189
|
+
write(file, text.replace(/^(-\s+\*\*Status:\*\*)\s+.+$/m, "$1 accepted"), { force: true });
|
|
190
|
+
console.log(c.green("accepted") + ` ${relPath(projectRoot, file)}`);
|
|
191
|
+
|
|
192
|
+
const date = today();
|
|
193
|
+
const index = idx.load(projectRoot);
|
|
194
|
+
idx.updateDecision(index, padded, () => ({ status: "accepted" }));
|
|
195
|
+
idx.touch(index, date);
|
|
196
|
+
idx.save(projectRoot, index);
|
|
197
|
+
console.log(c.green("indexed") + ` decision ${padded} -> accepted`);
|
|
198
|
+
return 0;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function decisionList() {
|
|
202
|
+
const projectRoot = process.cwd();
|
|
203
|
+
ensureDoctrinaProject(projectRoot);
|
|
204
|
+
|
|
205
|
+
const adrDir = path.join(projectRoot, ".doctrina", "decisions");
|
|
206
|
+
const rows = [];
|
|
207
|
+
for (const f of walk(adrDir)) {
|
|
208
|
+
const m = path.basename(f).match(/^(\d{4})-.*\.md$/);
|
|
209
|
+
if (!m) continue;
|
|
210
|
+
const text = read(f);
|
|
211
|
+
const titleMatch = text.match(/^#\s+ADR\s+\d{4}\s*[—-]\s*(.+)$/m);
|
|
212
|
+
const statusMatch = text.match(/^-\s+\*\*Status:\*\*\s+(.+)$/m);
|
|
213
|
+
const dateMatch = text.match(/^-\s+\*\*Date:\*\*\s+(\S+)/m);
|
|
214
|
+
rows.push({
|
|
215
|
+
id: m[1],
|
|
216
|
+
status: statusMatch ? statusMatch[1].trim() : "?",
|
|
217
|
+
date: dateMatch ? dateMatch[1] : "—",
|
|
218
|
+
title: titleMatch ? titleMatch[1].trim() : path.basename(f),
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
if (rows.length === 0) {
|
|
222
|
+
console.log(c.gray("no ADRs found in .doctrina/decisions/"));
|
|
223
|
+
return 0;
|
|
224
|
+
}
|
|
225
|
+
console.log(c.bold("Decisions:"));
|
|
226
|
+
console.log("");
|
|
227
|
+
for (const r of rows) {
|
|
228
|
+
console.log(` ${c.cyan(r.id)} ${r.status.padEnd(20)} ${r.date} ${r.title}`);
|
|
229
|
+
}
|
|
230
|
+
console.log("");
|
|
231
|
+
console.log(c.gray(`${rows.length} ADR${rows.length === 1 ? "" : "s"}`));
|
|
232
|
+
return 0;
|
|
233
|
+
}
|
|
234
|
+
|
|
156
235
|
function nextDecisionNumber(projectRoot) {
|
|
157
236
|
const adrDir = path.join(projectRoot, ".doctrina", "decisions");
|
|
158
237
|
const files = walk(adrDir);
|
|
@@ -178,6 +257,10 @@ Usage: doctrina decision <subcommand> [args]
|
|
|
178
257
|
|
|
179
258
|
Subcommands:
|
|
180
259
|
new "<title>" Create the next sequentially numbered ADR
|
|
260
|
+
accept <number> Flip a proposed ADR to accepted (rewrites
|
|
261
|
+
only the Status: header) and update the index
|
|
181
262
|
supersede <number> "<new title>" Create a new ADR that supersedes the target
|
|
182
263
|
and rewrite the target's Status: header
|
|
264
|
+
list One line per ADR: number, status, date,
|
|
265
|
+
title. Read-only.
|
|
183
266
|
`;
|
package/src/commands/hooks.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import process from "node:process";
|
|
3
3
|
import { chmodSync } from "node:fs";
|
|
4
|
-
import { exists, mkdirp, read, write } from "../lib/fs-ops.js";
|
|
4
|
+
import { exists, mkdirp, read, relPath, write } from "../lib/fs-ops.js";
|
|
5
5
|
import { locateTemplatesDir } from "../lib/templates.js";
|
|
6
6
|
import { flagBool } from "../lib/args.js";
|
|
7
7
|
import { c } from "../lib/colors.js";
|
|
@@ -33,7 +33,7 @@ export async function run(positional, flags) {
|
|
|
33
33
|
|
|
34
34
|
const target = path.join(hooksDir, "pre-commit");
|
|
35
35
|
if (exists(target) && !force) {
|
|
36
|
-
console.error(c.red("error:") + ` ${
|
|
36
|
+
console.error(c.red("error:") + ` ${relPath(projectRoot, target)} already exists (pass --force to overwrite)`);
|
|
37
37
|
return 1;
|
|
38
38
|
}
|
|
39
39
|
|
|
@@ -42,7 +42,7 @@ export async function run(positional, flags) {
|
|
|
42
42
|
const body = read(tplPath);
|
|
43
43
|
write(target, body, { force: true });
|
|
44
44
|
chmodSync(target, 0o755);
|
|
45
|
-
console.log(c.green("installed") + ` ${
|
|
45
|
+
console.log(c.green("installed") + ` ${relPath(projectRoot, target)}`);
|
|
46
46
|
console.log(`The hook runs ${c.cyan("doctrina validate")} before each commit.`);
|
|
47
47
|
return 0;
|
|
48
48
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { exists } from "../lib/fs-ops.js";
|
|
4
|
+
import * as idx from "../lib/index-json.js";
|
|
5
|
+
import { deriveIndex, indexesMatch, stableStringify } from "../lib/scan.js";
|
|
6
|
+
import { today } from "../lib/dates.js";
|
|
7
|
+
import { flagBool } from "../lib/args.js";
|
|
8
|
+
import { c } from "../lib/colors.js";
|
|
9
|
+
import { suggest } from "../lib/suggest.js";
|
|
10
|
+
|
|
11
|
+
const SUBCOMMANDS = ["rebuild"];
|
|
12
|
+
|
|
13
|
+
export async function run(positional, flags) {
|
|
14
|
+
const sub = positional[0];
|
|
15
|
+
if (sub !== "rebuild") {
|
|
16
|
+
console.error(c.red("error:") + ` unknown index subcommand "${sub ?? ""}"`);
|
|
17
|
+
const guess = suggest(sub, SUBCOMMANDS);
|
|
18
|
+
console.error(c.gray("hint: ") + (guess
|
|
19
|
+
? `did you mean \`doctrina index ${guess}\`?`
|
|
20
|
+
: `available: ${SUBCOMMANDS.join(", ")}`));
|
|
21
|
+
return 2;
|
|
22
|
+
}
|
|
23
|
+
return rebuild(flags);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function rebuild(flags) {
|
|
27
|
+
const check = flagBool(flags, "check", false);
|
|
28
|
+
const projectRoot = process.cwd();
|
|
29
|
+
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
30
|
+
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let current = null;
|
|
34
|
+
try {
|
|
35
|
+
current = idx.load(projectRoot);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
if (check) {
|
|
38
|
+
console.error(c.red("error:") + ` ${err.message}`);
|
|
39
|
+
return 1;
|
|
40
|
+
}
|
|
41
|
+
console.log(c.yellow("warn:") + ` ${err.message} — rebuilding from scratch`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const derived = deriveIndex(projectRoot, current);
|
|
45
|
+
|
|
46
|
+
if (indexesMatch(derived, current)) {
|
|
47
|
+
console.log(c.green("ok") + " index.json matches the tree (nothing to do)");
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const drift = describeDrift(current, derived);
|
|
52
|
+
for (const line of drift) console.log((check ? c.yellow("drift: ") : c.gray("sync: ")) + line);
|
|
53
|
+
|
|
54
|
+
if (check) {
|
|
55
|
+
console.log("");
|
|
56
|
+
console.log(c.red("fail") + ` index.json has drifted from the tree (${drift.length} difference${drift.length === 1 ? "" : "s"})`);
|
|
57
|
+
console.log(c.gray("hint: ") + "run `doctrina index rebuild` to regenerate it");
|
|
58
|
+
return 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
derived.last_updated = today();
|
|
62
|
+
idx.save(projectRoot, derived);
|
|
63
|
+
const a = derived.artifacts;
|
|
64
|
+
console.log("");
|
|
65
|
+
console.log(c.green("rebuilt") + ` .doctrina/index.json — ${a.specs.length} specs, ${a.decisions.length} decisions, ` +
|
|
66
|
+
`${a.changes.length} open changes, ${a.changes_archive.length} archived, ${a.skills.length} skills`);
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Human-readable category-level drift between the on-disk index and the
|
|
71
|
+
// derived one: added / removed / changed entry ids.
|
|
72
|
+
function describeDrift(current, derived) {
|
|
73
|
+
const lines = [];
|
|
74
|
+
if (!current) return ["index.json missing or unreadable"];
|
|
75
|
+
const categories = ["specs", "decisions", "changes", "changes_archive", "skills"];
|
|
76
|
+
for (const cat of categories) {
|
|
77
|
+
const cur = new Map((current.artifacts?.[cat] ?? []).map((e) => [e.id, e]));
|
|
78
|
+
const der = new Map((derived.artifacts?.[cat] ?? []).map((e) => [e.id, e]));
|
|
79
|
+
for (const id of der.keys()) {
|
|
80
|
+
if (!cur.has(id)) lines.push(`${cat}: "${id}" on disk but not in index`);
|
|
81
|
+
else if (stableStringify(cur.get(id)) !== stableStringify(der.get(id))) {
|
|
82
|
+
lines.push(`${cat}: "${id}" metadata differs from the files`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
for (const id of cur.keys()) {
|
|
86
|
+
if (!der.has(id)) lines.push(`${cat}: "${id}" in index but not on disk`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (stableStringify(current.artifacts?.product ?? null) !== stableStringify(derived.artifacts.product)) {
|
|
90
|
+
lines.push("product: metadata differs");
|
|
91
|
+
}
|
|
92
|
+
if (lines.length === 0) lines.push("structural difference (key order or missing category)");
|
|
93
|
+
return lines;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const help = `
|
|
97
|
+
Usage: doctrina index <subcommand>
|
|
98
|
+
|
|
99
|
+
Subcommands:
|
|
100
|
+
rebuild Regenerate .doctrina/index.json from the artifacts on
|
|
101
|
+
disk (spec headers, ADR headers, change proposals,
|
|
102
|
+
archive folders, skill frontmatter). The files are the
|
|
103
|
+
source of truth; the index is derived.
|
|
104
|
+
rebuild --check Write nothing; exit 1 with a drift summary when the
|
|
105
|
+
index no longer matches the tree. CI-friendly.
|
|
106
|
+
|
|
107
|
+
Fields with no on-disk source (project name, framework_version, product
|
|
108
|
+
metadata) are carried over from the existing index.
|
|
109
|
+
`;
|
package/src/commands/init.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import process from "node:process";
|
|
3
|
-
import { exists, isDir, isFile, read, write } from "../lib/fs-ops.js";
|
|
3
|
+
import { exists, isDir, isFile, read, relPath, write } from "../lib/fs-ops.js";
|
|
4
4
|
import { locateTemplatesDir, loadTemplateTree, materialiseEntry, substitute } from "../lib/templates.js";
|
|
5
5
|
import { today } from "../lib/dates.js";
|
|
6
6
|
import { flagBool, flagString } from "../lib/args.js";
|
|
7
7
|
import { c } from "../lib/colors.js";
|
|
8
8
|
import { ask } from "../lib/prompt.js";
|
|
9
|
+
import { writeIntakeFile, printBootstrapPlaybook } from "./intake.js";
|
|
9
10
|
|
|
10
11
|
const SUPPORTED_AGENTS = [
|
|
11
12
|
"claude",
|
|
@@ -29,7 +30,27 @@ export async function run(_positional, flags) {
|
|
|
29
30
|
const projectName = flagString(flags, "project-name") ?? path.basename(projectRoot);
|
|
30
31
|
const date = flagString(flags, "date") ?? today();
|
|
31
32
|
|
|
33
|
+
// --intake hands over the FULL description up front (ADR 0005); the
|
|
34
|
+
// one-line summary is then derived instead of prompted for.
|
|
35
|
+
const intakeFile = flagString(flags, "intake");
|
|
36
|
+
let intakeBody = null;
|
|
37
|
+
if (intakeFile) {
|
|
38
|
+
const intakeAbs = path.resolve(projectRoot, intakeFile);
|
|
39
|
+
if (!isFile(intakeAbs)) {
|
|
40
|
+
console.error(c.red("error:") + ` --intake file not found: ${intakeFile}`);
|
|
41
|
+
return 1;
|
|
42
|
+
}
|
|
43
|
+
intakeBody = read(intakeAbs);
|
|
44
|
+
if (intakeBody.trim().length === 0) {
|
|
45
|
+
console.error(c.red("error:") + ` --intake file is empty: ${intakeFile}`);
|
|
46
|
+
return 1;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
32
50
|
let description = flagString(flags, "project-description") ?? "";
|
|
51
|
+
if (!description && intakeBody) {
|
|
52
|
+
description = firstLineSummary(intakeBody);
|
|
53
|
+
}
|
|
33
54
|
if (!description && !nonInteractive) {
|
|
34
55
|
description = await ask("One-sentence project description:");
|
|
35
56
|
}
|
|
@@ -79,7 +100,7 @@ export async function run(_positional, flags) {
|
|
|
79
100
|
? fromAgentsContent.replace(/\n+$/, "") + "\n\n---\n\n## Project-specific\n\n" + projectAgents
|
|
80
101
|
: projectAgents;
|
|
81
102
|
write(agentsMdPath, finalAgents, { force });
|
|
82
|
-
console.log(c.green("created") + ` ${
|
|
103
|
+
console.log(c.green("created") + ` ${relPath(projectRoot, agentsMdPath)}` + (fromAgentsContent ? " (with --from base)" : ""));
|
|
83
104
|
const finalLines = finalAgents.split("\n").length;
|
|
84
105
|
if (finalLines > 150) {
|
|
85
106
|
console.log(c.yellow("warn:") + ` AGENTS.md is ${finalLines} lines (>150 soft cap); consider trimming the base`);
|
|
@@ -96,11 +117,11 @@ export async function run(_positional, flags) {
|
|
|
96
117
|
const merged = `# ${projectName} — Product\n\n## Conventions base (from ${fromPath})\n\n${visionBlock}\n\n---\n\n${projectBody.replace(/^#[^\n]*\n+/, "")}`;
|
|
97
118
|
const dest = path.join(doctrinaDir, "product.md");
|
|
98
119
|
write(dest, merged, { force });
|
|
99
|
-
console.log(c.green("created") + ` ${
|
|
120
|
+
console.log(c.green("created") + ` ${relPath(projectRoot, dest)} (with --from base)`);
|
|
100
121
|
continue;
|
|
101
122
|
}
|
|
102
123
|
const written = materialiseEntry(entry, doctrinaDir, tokens, { force });
|
|
103
|
-
console.log(c.green("created") + ` ${
|
|
124
|
+
console.log(c.green("created") + ` ${relPath(projectRoot, written)}`);
|
|
104
125
|
}
|
|
105
126
|
|
|
106
127
|
// Adapters
|
|
@@ -115,12 +136,39 @@ export async function run(_positional, flags) {
|
|
|
115
136
|
}
|
|
116
137
|
}
|
|
117
138
|
|
|
139
|
+
if (intakeBody) {
|
|
140
|
+
const intakePath = writeIntakeFile(projectRoot, {
|
|
141
|
+
body: intakeBody,
|
|
142
|
+
source: relPath(projectRoot, path.resolve(projectRoot, intakeFile)),
|
|
143
|
+
projectName,
|
|
144
|
+
date,
|
|
145
|
+
force,
|
|
146
|
+
});
|
|
147
|
+
console.log(c.green("created") + ` ${relPath(projectRoot, intakePath)}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
118
150
|
console.log("");
|
|
119
151
|
console.log(c.bold("Doctrina initialised."));
|
|
120
|
-
|
|
152
|
+
if (intakeBody) {
|
|
153
|
+
console.log(`Next: execute the bootstrap playbook below — it converts the intake into`);
|
|
154
|
+
console.log(`${c.cyan(".doctrina/product.md")} and capability specs. Any AGENTS.md-aware agent`);
|
|
155
|
+
console.log(`runs it on its own; reprint anytime with ${c.cyan("doctrina intake")}.`);
|
|
156
|
+
console.log("");
|
|
157
|
+
printBootstrapPlaybook(projectRoot);
|
|
158
|
+
} else {
|
|
159
|
+
console.log(`Next: edit ${c.cyan("AGENTS.md")} and ${c.cyan(".doctrina/product.md")} for your project.`);
|
|
160
|
+
}
|
|
121
161
|
return 0;
|
|
122
162
|
}
|
|
123
163
|
|
|
164
|
+
// First non-empty line of the intake, stripped of Markdown heading marks
|
|
165
|
+
// and clipped, as the one-line description token for the templates.
|
|
166
|
+
function firstLineSummary(text) {
|
|
167
|
+
const line = text.split(/\r?\n/).find((l) => l.trim().length > 0) ?? "";
|
|
168
|
+
const cleaned = line.replace(/^#+\s*/, "").trim();
|
|
169
|
+
return cleaned.length > 120 ? cleaned.slice(0, 117).trimEnd() + "..." : cleaned;
|
|
170
|
+
}
|
|
171
|
+
|
|
124
172
|
function installAdapter(templatesDir, projectRoot, agent, tokens, opts) {
|
|
125
173
|
const tree = loadTemplateTree(templatesDir, path.join("adapters", agent));
|
|
126
174
|
for (const entry of tree) {
|
|
@@ -130,13 +178,13 @@ function installAdapter(templatesDir, projectRoot, agent, tokens, opts) {
|
|
|
130
178
|
AGENTS_MD_PATH: adapterAgentsMdRelativePath(entry.relativePath),
|
|
131
179
|
};
|
|
132
180
|
const written = materialiseEntry(entry, projectRoot, adapterTokens, opts);
|
|
133
|
-
console.log(c.green(`adapter[${agent}]`) + ` ${
|
|
181
|
+
console.log(c.green(`adapter[${agent}]`) + ` ${relPath(projectRoot, written)}`);
|
|
134
182
|
}
|
|
135
183
|
}
|
|
136
184
|
|
|
137
185
|
function adapterAgentsMdRelativePath(relativePath) {
|
|
138
186
|
const cleaned = relativePath.replace(/\.template$/, "");
|
|
139
|
-
const depth = cleaned.split(
|
|
187
|
+
const depth = cleaned.split("/").length - 1;
|
|
140
188
|
return depth === 0 ? "AGENTS.md" : "../".repeat(depth) + "AGENTS.md";
|
|
141
189
|
}
|
|
142
190
|
|
|
@@ -150,6 +198,9 @@ Options:
|
|
|
150
198
|
--project-description <text> One-sentence description
|
|
151
199
|
--agent <name> Install adapter for one agent
|
|
152
200
|
(claude|codex|cursor|copilot|gemini|aider|windsurf|continue|amp|devin|factory|jules|all)
|
|
201
|
+
--intake <file> Full project description; stored verbatim at .doctrina/intake.md
|
|
202
|
+
and used to derive the one-line description when absent.
|
|
203
|
+
Follow up with \`doctrina intake\` for the bootstrap playbook
|
|
153
204
|
--from <path> Local conventions directory; if it contains AGENTS.md and/or
|
|
154
205
|
.doctrina/product.md, the content is folded into the new project
|
|
155
206
|
--date <YYYY-MM-DD> Override the system date
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import process from "node:process";
|
|
3
|
+
import { exists, isFile, read, relPath, write } from "../lib/fs-ops.js";
|
|
4
|
+
import { listHeader } from "../lib/scan.js";
|
|
5
|
+
import * as idx from "../lib/index-json.js";
|
|
6
|
+
import { today } from "../lib/dates.js";
|
|
7
|
+
import { flagBool, flagString } from "../lib/args.js";
|
|
8
|
+
import { c } from "../lib/colors.js";
|
|
9
|
+
|
|
10
|
+
// `intake` is the first half of the no-ceremony path (ADR 0005): store the
|
|
11
|
+
// user's FULL project description verbatim, then print the bootstrap
|
|
12
|
+
// playbook the host agent executes to convert it into product.md content
|
|
13
|
+
// and capability specs. The CLI does no interpretation — it scaffolds and
|
|
14
|
+
// instructs; the agent thinks.
|
|
15
|
+
|
|
16
|
+
export async function run(positional, flags) {
|
|
17
|
+
const projectRoot = process.cwd();
|
|
18
|
+
if (!exists(path.join(projectRoot, ".doctrina"))) {
|
|
19
|
+
throw new Error("not a Doctrina project (no .doctrina/ in cwd). Run `doctrina init` first.");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const force = flagBool(flags, "force", false);
|
|
23
|
+
const inlineText = flagString(flags, "text");
|
|
24
|
+
const sourceFile = positional[0];
|
|
25
|
+
const intakePath = path.join(projectRoot, ".doctrina", "intake.md");
|
|
26
|
+
|
|
27
|
+
// No source given: reprint the playbook for an existing intake.
|
|
28
|
+
if (!sourceFile && !inlineText) {
|
|
29
|
+
if (!isFile(intakePath)) {
|
|
30
|
+
console.error(c.red("error:") + " no intake found and no description given");
|
|
31
|
+
console.error(c.gray("hint: ") + "run `doctrina intake <file>` or `doctrina intake --text \"<description>\"`");
|
|
32
|
+
return 1;
|
|
33
|
+
}
|
|
34
|
+
const status = (listHeader(read(intakePath), "Status") ?? "pending").toLowerCase();
|
|
35
|
+
if (status === "converted") {
|
|
36
|
+
console.log(c.green("ok") + " intake already converted — specs are the source of truth.");
|
|
37
|
+
console.log(c.gray("hint: ") + "start implementation with `doctrina work \"<brief prompt>\"`");
|
|
38
|
+
return 0;
|
|
39
|
+
}
|
|
40
|
+
printBootstrapPlaybook(projectRoot);
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let body;
|
|
45
|
+
let source;
|
|
46
|
+
if (inlineText) {
|
|
47
|
+
body = inlineText;
|
|
48
|
+
source = "inline (--text)";
|
|
49
|
+
} else {
|
|
50
|
+
const abs = path.resolve(projectRoot, sourceFile);
|
|
51
|
+
if (!isFile(abs)) {
|
|
52
|
+
console.error(c.red("error:") + ` description file not found: ${sourceFile}`);
|
|
53
|
+
return 1;
|
|
54
|
+
}
|
|
55
|
+
body = read(abs);
|
|
56
|
+
source = relPath(projectRoot, abs);
|
|
57
|
+
}
|
|
58
|
+
if (body.trim().length === 0) {
|
|
59
|
+
console.error(c.red("error:") + " the description is empty");
|
|
60
|
+
return 1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (exists(intakePath) && !force) {
|
|
64
|
+
console.error(c.red("error:") + ` ${relPath(projectRoot, intakePath)} already exists (pass --force to overwrite)`);
|
|
65
|
+
return 1;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const projectName = idx.load(projectRoot).project ?? path.basename(projectRoot);
|
|
69
|
+
writeIntakeFile(projectRoot, { body, source, projectName, date: today(), force });
|
|
70
|
+
console.log(c.green("created") + ` ${relPath(projectRoot, intakePath)}`);
|
|
71
|
+
console.log("");
|
|
72
|
+
printBootstrapPlaybook(projectRoot);
|
|
73
|
+
return 0;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Shared with `init --intake`. Writes the intake verbatim under a small
|
|
77
|
+
// status header; the agent flips Status to converted at the end of the
|
|
78
|
+
// playbook, which is how `next` knows the bootstrap is done.
|
|
79
|
+
export function writeIntakeFile(projectRoot, { body, source, projectName, date, force = false }) {
|
|
80
|
+
const intakePath = path.join(projectRoot, ".doctrina", "intake.md");
|
|
81
|
+
const content = [
|
|
82
|
+
`# Intake — ${projectName}`,
|
|
83
|
+
"",
|
|
84
|
+
"- **Status:** pending",
|
|
85
|
+
`- **Date:** ${date}`,
|
|
86
|
+
`- **Source:** ${source}`,
|
|
87
|
+
"",
|
|
88
|
+
"<!-- Raw project intent, stored verbatim. The bootstrap playbook",
|
|
89
|
+
" (doctrina intake) converts it into product.md and capability",
|
|
90
|
+
" specs, then flips Status to converted. After conversion the",
|
|
91
|
+
" specs are the only source of truth; never edit this file to",
|
|
92
|
+
" change requirements. -->",
|
|
93
|
+
"",
|
|
94
|
+
"---",
|
|
95
|
+
"",
|
|
96
|
+
body.replace(/\r\n/g, "\n").replace(/\n*$/, "\n"),
|
|
97
|
+
].join("\n");
|
|
98
|
+
write(intakePath, content, { force });
|
|
99
|
+
return intakePath;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Exported so `init --intake` can print the same playbook inline instead
|
|
103
|
+
// of bouncing the user to a second command.
|
|
104
|
+
export function printBootstrapPlaybook(projectRoot) {
|
|
105
|
+
const hasSpecs = exists(path.join(projectRoot, ".doctrina", "specs"));
|
|
106
|
+
console.log(c.bold("Bootstrap playbook") + c.gray(" — agent-executed (ADR 0005); the CLI does no interpretation."));
|
|
107
|
+
console.log("");
|
|
108
|
+
console.log("Execute in order, in a single linear pass:");
|
|
109
|
+
console.log("");
|
|
110
|
+
console.log(`1. Read ${c.cyan(".doctrina/intake.md")} (raw intent) and ${c.cyan(".doctrina/product.md")}.`);
|
|
111
|
+
console.log("");
|
|
112
|
+
console.log("2. Fill every product.md section from the intake: Vision, Problem,");
|
|
113
|
+
console.log(" Target users, Scope (in/out), Non-goals, Success criteria.");
|
|
114
|
+
console.log(" One fact, one home — product.md holds vision, never requirements.");
|
|
115
|
+
console.log("");
|
|
116
|
+
console.log("3. Derive the capability list: kebab-case, one per area of behaviour");
|
|
117
|
+
console.log(" (e.g. auth, billing, reports). For each capability:");
|
|
118
|
+
console.log(` ${c.cyan("doctrina spec new <capability>")}`);
|
|
119
|
+
console.log(" then replace the template placeholders in");
|
|
120
|
+
console.log(" .doctrina/specs/<capability>/spec.md with EARS requirements derived");
|
|
121
|
+
console.log(" from the intake (Ubiquitous / Event-driven / State-driven /");
|
|
122
|
+
console.log(" Unwanted-behavior / Optional). Remove section stubs you do not use,");
|
|
123
|
+
console.log(" and write concrete Acceptance criteria.");
|
|
124
|
+
console.log("");
|
|
125
|
+
console.log("4. Record any architectural decision the intake forces:");
|
|
126
|
+
console.log(` ${c.cyan("doctrina decision new \"<title>\"")} → edit → ${c.cyan("doctrina decision accept <num>")}`);
|
|
127
|
+
console.log("");
|
|
128
|
+
console.log("5. Quality gates — fix everything they report before moving on:");
|
|
129
|
+
console.log(` ${c.cyan("doctrina clarify --all")}`);
|
|
130
|
+
console.log(` ${c.cyan("doctrina validate")}`);
|
|
131
|
+
console.log(" If something in the intake is genuinely ambiguous, ask the user");
|
|
132
|
+
console.log(" before assuming.");
|
|
133
|
+
console.log("");
|
|
134
|
+
console.log("6. Mark the intake consumed: in .doctrina/intake.md flip");
|
|
135
|
+
console.log(" \"- **Status:** pending\" to \"- **Status:** converted\".");
|
|
136
|
+
console.log("");
|
|
137
|
+
console.log(`7. ${c.cyan("doctrina next")} — follow the recommendation. Implementation then`);
|
|
138
|
+
console.log(` flows through ${c.cyan("doctrina work \"<brief prompt>\"")}, one change at a time.`);
|
|
139
|
+
if (!hasSpecs) {
|
|
140
|
+
console.log("");
|
|
141
|
+
console.log(c.yellow("warn:") + " .doctrina/specs/ is missing — re-run `doctrina init` first.");
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const help = `
|
|
146
|
+
Usage: doctrina intake [<file>] [--text "<description>"] [--force]
|
|
147
|
+
|
|
148
|
+
Store the full project description verbatim at .doctrina/intake.md and
|
|
149
|
+
print the bootstrap playbook — the ordered instruction sequence the host
|
|
150
|
+
AI agent executes to convert the intake into product.md content and
|
|
151
|
+
capability specs (see ADR 0005). The CLI itself does no natural-language
|
|
152
|
+
interpretation.
|
|
153
|
+
|
|
154
|
+
Forms:
|
|
155
|
+
doctrina intake <file> Ingest a description file
|
|
156
|
+
doctrina intake --text "<text>" Ingest an inline description
|
|
157
|
+
doctrina intake Reprint the playbook for the
|
|
158
|
+
existing pending intake
|
|
159
|
+
|
|
160
|
+
Options:
|
|
161
|
+
--text "<description>" Inline description instead of a file
|
|
162
|
+
--force Overwrite an existing .doctrina/intake.md
|
|
163
|
+
|
|
164
|
+
After conversion the agent flips the intake header to
|
|
165
|
+
"- **Status:** converted"; specs become the only source of truth.
|
|
166
|
+
`;
|