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.
Files changed (57) hide show
  1. package/README.md +27 -5
  2. package/package.json +4 -2
  3. package/src/commands/analyze.js +5 -5
  4. package/src/commands/change.js +113 -16
  5. package/src/commands/clarify.js +81 -18
  6. package/src/commands/context.js +129 -0
  7. package/src/commands/decision.js +91 -8
  8. package/src/commands/hooks.js +3 -3
  9. package/src/commands/index-rebuild.js +109 -0
  10. package/src/commands/init.js +58 -7
  11. package/src/commands/intake.js +166 -0
  12. package/src/commands/metrics.js +190 -0
  13. package/src/commands/next.js +108 -0
  14. package/src/commands/search.js +93 -0
  15. package/src/commands/skill.js +58 -5
  16. package/src/commands/spec.js +49 -7
  17. package/src/commands/templates.js +103 -4
  18. package/src/commands/validate.js +76 -9
  19. package/src/commands/work.js +215 -0
  20. package/src/index.js +26 -3
  21. package/src/lib/diff.js +103 -0
  22. package/src/lib/ears.js +108 -0
  23. package/src/lib/fs-ops.js +12 -0
  24. package/src/lib/scan.js +185 -0
  25. package/src/lib/templates.js +2 -2
  26. package/templates/AGENTS.md.template +83 -0
  27. package/templates/README.md +85 -0
  28. package/templates/adapters/aider/CONVENTIONS.md.template +11 -0
  29. package/templates/adapters/amp/README.md +13 -0
  30. package/templates/adapters/claude/CLAUDE.md.template +11 -0
  31. package/templates/adapters/codex/README.md +12 -0
  32. package/templates/adapters/continue/.continue/rules/00-doctrina.md.template +11 -0
  33. package/templates/adapters/copilot/.github/copilot-instructions.md.template +11 -0
  34. package/templates/adapters/cursor/.cursor/rules/00-doctrina.mdc.template +16 -0
  35. package/templates/adapters/devin/README.md +14 -0
  36. package/templates/adapters/factory/README.md +13 -0
  37. package/templates/adapters/gemini/GEMINI.md.template +11 -0
  38. package/templates/adapters/jules/README.md +13 -0
  39. package/templates/adapters/windsurf/.windsurfrules.template +11 -0
  40. package/templates/change/design.md.template +24 -0
  41. package/templates/change/proposal.md.template +26 -0
  42. package/templates/change/spec-delta.md.template +20 -0
  43. package/templates/change/tasks.md.template +17 -0
  44. package/templates/decision.md.template +56 -0
  45. package/templates/doctrina/changes/.gitkeep +0 -0
  46. package/templates/doctrina/changes/archive/.gitkeep +0 -0
  47. package/templates/doctrina/decisions/.gitkeep +0 -0
  48. package/templates/doctrina/index.json.template +19 -0
  49. package/templates/doctrina/product.md.template +31 -0
  50. package/templates/doctrina/skills/.gitkeep +0 -0
  51. package/templates/doctrina/specs/.gitkeep +0 -0
  52. package/templates/doctrina/templates/.gitkeep +0 -0
  53. package/templates/hooks/pre-commit.sample +7 -0
  54. package/templates/hooks/watch.sample +21 -0
  55. package/templates/skill.md.template +49 -0
  56. package/templates/spec-bug.md.template +40 -0
  57. package/templates/spec.md.template +52 -0
@@ -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:") + ` ${path.relative(projectRoot, targetPath)} already exists`);
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") + ` ${path.relative(projectRoot, targetPath)}`);
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 ${path.relative(projectRoot, adrDir)}`);
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 ${path.relative(projectRoot, oldFile)} has no Status: header`);
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") + ` ${path.relative(projectRoot, newPath)}`);
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") + ` ${path.relative(projectRoot, oldFile)} status -> superseded by ${next}`);
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
  `;
@@ -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:") + ` ${path.relative(projectRoot, target)} already exists (pass --force to overwrite)`);
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") + ` ${path.relative(projectRoot, target)}`);
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
+ `;
@@ -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") + ` ${path.relative(projectRoot, agentsMdPath)}` + (fromAgentsContent ? " (with --from base)" : ""));
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") + ` ${path.relative(projectRoot, dest)} (with --from base)`);
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") + ` ${path.relative(projectRoot, written)}`);
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
- console.log(`Next: edit ${c.cyan("AGENTS.md")} and ${c.cyan(".doctrina/product.md")} for your project.`);
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}]`) + ` ${path.relative(projectRoot, written)}`);
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(path.sep).length - 1;
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
+ `;