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.
@@ -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, _flags) {
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 = path.relative(dir, f).split(path.sep).join("/");
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
@@ -1,9 +1,11 @@
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 { 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(`${path.relative(projectRoot, f)} missing or malformed Status: header`);
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(`${path.relative(projectRoot, f)} is ${lines} lines (>30, adapter cap)`);
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(`${path.relative(projectRoot, specPath)} is ${lines} lines (>400 soft cap)`);
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(`${path.relative(projectRoot, f)} is ${lines} lines (>300 soft cap)`);
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(`${path.relative(projectRoot, f)} references missing path \`${ref}\``);
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 ${path.relative(projectRoot, specPath)} (capability "${cap}" not in index.json)`);
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 ${path.relative(projectRoot, f)} (id "${m[1]}" not in index.json)`);
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 = path.relative(projectRoot, f);
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 = { init, spec, change, decision, validate, hooks, analyze, clarify, templates, skill };
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")) {