@benjaminthomas/spec-driven-dev 0.0.0-stage → 1.4.1

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 (29) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +557 -2
  3. package/bin/cli.mjs +415 -0
  4. package/package.json +12 -4
  5. package/skills/generative-seo/SKILL.md +255 -0
  6. package/skills/generative-seo/assets/citation-log-template.md +17 -0
  7. package/skills/generative-seo/assets/competitor-analysis-template.md +20 -0
  8. package/skills/generative-seo/assets/content-plan-template.md +15 -0
  9. package/skills/generative-seo/assets/geo-config-template.md +33 -0
  10. package/skills/generative-seo/assets/geo-playbook-template.md +22 -0
  11. package/skills/generative-seo/assets/social-queue-template.md +8 -0
  12. package/skills/generative-seo/references/geo-methodology.md +143 -0
  13. package/skills/spec-checkpoint/SKILL.md +106 -0
  14. package/skills/spec-create/SKILL.md +149 -0
  15. package/skills/spec-create/references/action-required-template.md +53 -0
  16. package/skills/spec-create/references/readme-template.md +53 -0
  17. package/skills/spec-create/references/requirements-template.md +54 -0
  18. package/skills/spec-create/references/task-template.md +86 -0
  19. package/skills/spec-implement/SKILL.md +236 -0
  20. package/skills/spec-implement/references/coder-prompt-template.md +48 -0
  21. package/skills/spec-implement/references/fix-prompt-template.md +38 -0
  22. package/skills/spec-implement/references/review-prompt-template.md +50 -0
  23. package/skills/spec-investigate/SKILL.md +323 -0
  24. package/skills/spec-investigate/references/report-template.md +53 -0
  25. package/skills/spec-setup/SKILL.md +105 -0
  26. package/skills/spec-setup/references/config-template.yml +45 -0
  27. package/skills/spec-ship/SKILL.md +218 -0
  28. package/skills/spec-verify/SKILL.md +153 -0
  29. package/skills/spec-verify/references/report-template.md +57 -0
package/bin/cli.mjs ADDED
@@ -0,0 +1,415 @@
1
+ #!/usr/bin/env node
2
+ // spec-driven-dev installer (npm i -g @benjaminthomas/spec-driven-dev): detects your AI coding tools, copies the skills into each one, and
3
+ // runs project discovery (layout, tracker, branches, commands) to write .spec-driven.yml.
4
+ //
5
+ // spec-driven-dev install + project setup (interactive)
6
+ // spec-driven-dev setup project setup only
7
+ // spec-driven-dev doctor check .spec-driven.yml against the real repos (exit 1 on problems)
8
+ // spec-driven-dev status list specs with unfinished tasks (used by the session-start hook)
9
+ // flags: --yes --global --tools=claude,cursor --skills=spec-create,... --no-setup --dir=<path>
10
+ // --layout=single|monorepo|multi-repo --repos=api,../web,/abs/path --packages=apps/web,packages/ui
11
+ import fs from "node:fs";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import { execFileSync } from "node:child_process";
15
+ import { createInterface } from "node:readline/promises";
16
+ import { fileURLToPath } from "node:url";
17
+
18
+ const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
19
+ const SKILLS_DIR = path.join(ROOT, "skills");
20
+ const HOME = process.env.SPEC_DRIVEN_HOME || os.homedir(); // overridable for tests
21
+ const ALL_SKILLS = fs.readdirSync(SKILLS_DIR).filter((d) => fs.existsSync(path.join(SKILLS_DIR, d, "SKILL.md")));
22
+
23
+ const TOOLS = {
24
+ claude: { label: "Claude Code", home: ".claude", bin: "claude", project: ".claude/skills", global: ".claude/skills" },
25
+ cursor: { label: "Cursor", home: ".cursor", bin: "cursor", project: ".cursor/skills", global: ".cursor/skills" },
26
+ codex: { label: "Codex CLI", home: ".codex", bin: "codex", project: ".agents/skills", global: ".agents/skills" }, // Codex reads $HOME/.agents/skills
27
+ antigravity: { label: "Antigravity", home: ".gemini", bin: "antigravity", project: ".agents/skills", global: ".gemini/config/skills" },
28
+ };
29
+
30
+ // ---------- args & prompts ----------
31
+ const args = process.argv.slice(2);
32
+ const flag = (n) => args.includes(`--${n}`);
33
+ const opt = (n) => args.find((a) => a.startsWith(`--${n}=`))?.split("=")[1];
34
+ const command = args.find((a) => !a.startsWith("-")) || "install";
35
+ const YES = flag("yes") || flag("y") || !process.stdin.isTTY || ["doctor", "status"].includes(command);
36
+ const DIR = path.resolve(opt("dir") || process.cwd());
37
+
38
+ if (flag("help") || flag("h")) {
39
+ console.log(fs.readFileSync(fileURLToPath(import.meta.url), "utf8").split("\n").slice(1, 10).join("\n").replace(/^\/\/ ?/gm, ""));
40
+ process.exit(0);
41
+ }
42
+
43
+ const rl = YES ? null : createInterface({ input: process.stdin, output: process.stdout });
44
+ async function ask(q, def = "") {
45
+ if (YES) return def;
46
+ const a = (await rl.question(`${q}${def ? ` [${def}]` : ""}: `)).trim();
47
+ return a || def;
48
+ }
49
+ async function pick(q, items, defaults) {
50
+ items.forEach((it, i) => console.log(` ${i + 1}. ${it.label}`));
51
+ const defIdx = items.map((it, i) => (defaults.includes(it.value) ? i + 1 : null)).filter(Boolean).join(",");
52
+ const a = await ask(`${q} (comma-separated numbers, "all", or "none")`, defIdx || "none");
53
+ if (a === "all") return items.map((it) => it.value);
54
+ if (a === "none") return [];
55
+ return a.split(",").map((n) => items[Number(n.trim()) - 1]?.value).filter(Boolean);
56
+ }
57
+
58
+ const run = (cmd, argv, cwd) => {
59
+ try { return execFileSync(cmd, argv, { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim(); } catch { return ""; }
60
+ };
61
+ const onPath = (bin) => !!run(process.platform === "win32" ? "where" : "which", [bin]);
62
+
63
+ // ---------- install ----------
64
+ async function install() {
65
+ const detected = Object.keys(TOOLS).filter((t) => fs.existsSync(path.join(HOME, TOOLS[t].home)) || onPath(TOOLS[t].bin));
66
+ let tools = opt("tools")?.split(",");
67
+ if (!tools) {
68
+ console.log("\nWhich AI coding tools should get the skills? (detected ones are pre-selected)");
69
+ tools = await pick("Tools", Object.entries(TOOLS).map(([value, t]) => ({ value, label: t.label + (detected.includes(value) ? " ✓ detected" : "") })), detected);
70
+ }
71
+ const bad = tools.filter((t) => !TOOLS[t]);
72
+ if (bad.length) throw new Error(`Unknown tool(s): ${bad.join(", ")} — expected ${Object.keys(TOOLS).join(", ")}`);
73
+
74
+ // Claude Code users who already have the plugin would get every skill twice.
75
+ const plugins = path.join(HOME, ".claude/plugins/installed_plugins.json");
76
+ if (tools.includes("claude") && !opt("tools") && fs.existsSync(plugins) && fs.readFileSync(plugins, "utf8").includes('"spec-driven-dev@')) {
77
+ console.log("• Claude Code already has the spec-driven-dev plugin, so it's skipped here (update it with /plugin).");
78
+ tools = tools.filter((t) => t !== "claude");
79
+ }
80
+
81
+ const global = flag("global") || (!YES && (await ask("Install for this project only, or globally for all projects? (project/global)", "project")) === "global");
82
+ const skills = opt("skills")?.split(",") || ALL_SKILLS;
83
+ const unknown = skills.filter((s) => !ALL_SKILLS.includes(s));
84
+ if (unknown.length) throw new Error(`Unknown skill(s): ${unknown.join(", ")} — expected ${ALL_SKILLS.join(", ")}`);
85
+
86
+ const dests = [...new Set(tools.map((t) => (global ? path.join(HOME, TOOLS[t].global) : path.join(DIR, TOOLS[t].project))))];
87
+ for (const dest of dests) {
88
+ for (const s of skills) {
89
+ const to = path.join(dest, s);
90
+ fs.rmSync(to, { recursive: true, force: true });
91
+ fs.cpSync(path.join(SKILLS_DIR, s), to, { recursive: true, dereference: true, filter: (f) => !f.endsWith(".DS_Store") });
92
+ }
93
+ console.log(`✓ ${skills.length} skills → ${dest}`);
94
+ }
95
+ if (!dests.length) console.log("• No tools selected — nothing installed.");
96
+ }
97
+
98
+ // ---------- discovery ----------
99
+ const MARKERS = ["pnpm-workspace.yaml", "turbo.json", "nx.json", "lerna.json", "go.work"];
100
+ const PKG_DIRS = ["apps", "packages", "services", "libs"];
101
+ const MANIFESTS = ["package.json", "go.mod", "pyproject.toml", "Cargo.toml", "composer.json", "pom.xml"];
102
+ const isRepo = (d) => run("git", ["rev-parse", "--is-inside-work-tree"], d) === "true";
103
+ const readJson = (f) => { try { return JSON.parse(fs.readFileSync(f, "utf8")); } catch { return null; } };
104
+ const subdirs = (d) => { try { return fs.readdirSync(d, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name); } catch { return []; } };
105
+
106
+ function detectLayout(dir) {
107
+ if (isRepo(dir)) {
108
+ const top = run("git", ["rev-parse", "--show-toplevel"], dir);
109
+ const pkg = readJson(path.join(top, "package.json"));
110
+ const cargo = fs.existsSync(path.join(top, "Cargo.toml")) && fs.readFileSync(path.join(top, "Cargo.toml"), "utf8").includes("[workspace]");
111
+ const hasManifest = (rel) => MANIFESTS.some((m) => fs.existsSync(path.join(top, rel, m)));
112
+ // Prefer the workspace list the repo declares; fall back to conventional folders.
113
+ // shortcut: only "dir/*", "dir/**" and exact paths are expanded — enough for typical workspace globs.
114
+ const pnpm = fs.existsSync(path.join(top, "pnpm-workspace.yaml")) ? [...fs.readFileSync(path.join(top, "pnpm-workspace.yaml"), "utf8").matchAll(/^\s*-\s*['"]?([^'"\n#]+?)['"]?\s*$/gm)].map((m) => m[1]) : [];
115
+ const declared = [...(Array.isArray(pkg?.workspaces) ? pkg.workspaces : pkg?.workspaces?.packages || []), ...pnpm].filter((g) => !g.startsWith("!"));
116
+ const globs = declared.length ? declared : PKG_DIRS.map((p) => `${p}/*`);
117
+ const packages = [...new Set(globs.flatMap((g) => {
118
+ const base = g.replace(/\/\*\*?$/, "");
119
+ return base === g ? [g.replace(/\/$/, "")] : subdirs(path.join(top, base)).map((n) => `${base}/${n}`);
120
+ }))].filter(hasManifest);
121
+ const mono = MARKERS.some((m) => fs.existsSync(path.join(top, m))) || pkg?.workspaces || cargo || packages.length > 1;
122
+ return { layout: mono ? "monorepo" : "single", root: top, repos: mono ? packages : [] };
123
+ }
124
+ // a git worktree has a .git *file*; it belongs to its main repo, so skip it
125
+ const repos = subdirs(dir).filter((n) => { try { return fs.statSync(path.join(dir, n, ".git")).isDirectory(); } catch { return false; } });
126
+ return repos.length ? { layout: "multi-repo", root: dir, repos } : { layout: "single", root: dir, repos: [] };
127
+ }
128
+
129
+ function commandsFor(dir) {
130
+ const pkg = readJson(path.join(dir, "package.json"));
131
+ const out = { install: "", lint: "", typecheck: "", test: "", build: "", dev: "" };
132
+ if (!pkg) return out;
133
+ const pm = fs.existsSync(path.join(dir, "pnpm-lock.yaml")) ? "pnpm" : fs.existsSync(path.join(dir, "yarn.lock")) ? "yarn" : fs.existsSync(path.join(dir, "bun.lockb")) || fs.existsSync(path.join(dir, "bun.lock")) ? "bun" : "npm";
134
+ const s = pkg.scripts || {};
135
+ const name = (...ks) => ks.find((k) => s[k]);
136
+ const cmd = (k) => (k ? (pm === "npm" && k === "test" ? "npm test" : `${pm} run ${k}`) : "");
137
+ return { install: `${pm} install`, lint: cmd(name("lint")), typecheck: cmd(name("typecheck", "type-check", "tsc", "check-types")), test: cmd(name("test")), build: cmd(name("build")), dev: cmd(name("dev", "start")) };
138
+ }
139
+
140
+ function gitFacts(dir) {
141
+ if (!isRepo(dir)) return null;
142
+ const def = run("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], dir).replace(/^origin\//, "") || run("git", ["branch", "--show-current"], dir);
143
+ const branches = run("git", ["for-each-ref", "--sort=-committerdate", "--count=40", "--format=%(refname:short)", "refs/remotes", "refs/heads"], dir).split("\n").filter(Boolean).map((b) => b.replace(/^origin\//, ""));
144
+ const subjects = run("git", ["log", "-40", "--format=%s"], dir).split("\n").filter(Boolean);
145
+ const owner = (run("git", ["remote", "get-url", "origin"], dir).match(/[:/]([^/:]+)\/[^/]+?(\.git)?$/) || [])[1] || "";
146
+ return { def, branches, subjects, owner, integration: ["staging", "develop", "development"].find((b) => branches.includes(b)) || "" };
147
+ }
148
+
149
+ function infer(facts) {
150
+ const branches = facts.flatMap((f) => f.branches);
151
+ const subjects = facts.flatMap((f) => f.subjects);
152
+ const keys = {};
153
+ for (const s of [...branches, ...subjects]) for (const m of s.matchAll(/\b([A-Z][A-Z0-9]{1,9})-\d+\b/g)) keys[m[1]] = (keys[m[1]] || 0) + 1;
154
+ const key = Object.entries(keys).sort((a, b) => b[1] - a[1])[0]?.[0] || "";
155
+ const ghRefs = subjects.filter((s) => /#\d+/.test(s)).length;
156
+ const bare = branches.filter((b) => /^[A-Z][A-Z0-9]+-\d+$/.test(b)).length;
157
+ const work = branches.filter((b) => !["main", "master", "HEAD", "staging", "develop", "development", "pre-prod", "origin"].includes(b)).length || 1;
158
+ const conv = subjects.filter((s) => /^(feat|fix|chore|docs|refactor|test|perf|build|ci|style)(\(.+\))?!?:/.test(s)).length;
159
+ const tick = subjects.filter((s) => /^\[?[A-Z][A-Z0-9]+-\d+/.test(s)).length;
160
+ const n = subjects.length || 1;
161
+ return {
162
+ trackerType: key ? "jira" : ghRefs ? "github" : "none",
163
+ key,
164
+ branchName: key && bare / work >= 0.3 ? "{ticket}" : key ? "{type}/{ticket}-{slug}" : "{type}/{slug}",
165
+ commitStyle: conv / n >= 0.5 ? "conventional" : tick / n >= 0.5 ? "ticket" : "freeform",
166
+ base: facts.filter((f) => f.integration === "staging").length > facts.length / 2 ? "staging" : facts.find((f) => f.integration)?.integration || "default",
167
+ };
168
+ }
169
+
170
+ const y = (v) => JSON.stringify(v ?? ""); // JSON strings are valid YAML scalars
171
+ function toYaml(c) {
172
+ const cmds = (o, pad) => Object.entries(o).map(([k, v]) => `${pad}${k}: ${y(v)}`).join("\n");
173
+ let out = `# .spec-driven.yml — written by spec-driven-dev setup. Edit freely, or re-run setup.\n# Every spec-* skill reads this first. Field reference: skills/spec-setup/references/config-template.yml\nversion: 1\nlayout: ${c.layout}\n`;
174
+ if (c.repos.length) {
175
+ out += "repos:\n" + c.repos.map((r) => {
176
+ let e = ` - path: ${y(r.path)}\n role: ${y(r.role)}\n`;
177
+ if (r.base && r.base !== c.git.base_branch) e += ` git:\n base_branch: ${y(r.base)}\n pr_target: ${y(r.base)}\n`;
178
+ if (r.commands && Object.values(r.commands).some(Boolean)) e += ` commands:\n${cmds(r.commands, " ")}\n`;
179
+ return e;
180
+ }).join("");
181
+ }
182
+ out += `tracker:\n type: ${c.tracker.type}\n key: ${y(c.tracker.key)}\n url: ${y(c.tracker.url)}\n`;
183
+ out += `git:\n${cmds(c.git, " ")}\n`;
184
+ out += `commands:\n${cmds(c.commands, " ")}\n dev_url: ${y(c.devUrl)}\n`;
185
+ return out + `specs_dir: ${y(c.specsDir)}\n`;
186
+ }
187
+
188
+ const LAYOUTS = ["single", "monorepo", "multi-repo"];
189
+ const splitList = (v) => (v || "").split(",").map((x) => x.trim()).filter(Boolean);
190
+ // Paths are stored relative to the config's folder when inside it, absolute otherwise.
191
+ const relOrAbs = (root, p) => { const r = path.relative(root, p); return r.startsWith("..") || path.isAbsolute(r) ? p : r || "."; };
192
+
193
+ async function chooseLayout(detected) {
194
+ const forced = opt("layout");
195
+ if (forced && !LAYOUTS.includes(forced)) throw new Error(`--layout must be one of ${LAYOUTS.join(", ")}`);
196
+ if (forced) return forced;
197
+ for (;;) {
198
+ const a = await ask(`Layout — single, monorepo, or multi-repo (detected: ${detected})`, detected);
199
+ if (LAYOUTS.includes(a)) return a;
200
+ console.log(` Please type one of: ${LAYOUTS.join(", ")}`);
201
+ }
202
+ }
203
+
204
+ // multi-repo: pick from repos found in the workspace folder, then add any others by path.
205
+ async function chooseRepos(d) {
206
+ // Inside one repo but multi-repo chosen → the workspace is the folder that holds this repo.
207
+ const insideRepo = d.layout !== "multi-repo";
208
+ const defRoot = insideRepo ? path.dirname(d.root) : d.root;
209
+ const root = path.resolve(DIR, await ask("Workspace folder (where .spec-driven.yml goes)", defRoot));
210
+ const found = detectLayout(root).layout === "multi-repo" ? detectLayout(root).repos : [];
211
+ let picked = [];
212
+ const fromFlag = opt("repos");
213
+ if (fromFlag) {
214
+ picked = splitList(fromFlag).map((p) => path.resolve(root, p));
215
+ } else {
216
+ if (found.length) {
217
+ const owners = {};
218
+ for (const r of found) (owners[gitFacts(path.join(root, r))?.owner || "?"] ||= []).push(r);
219
+ const biggest = Object.values(owners).sort((a, b) => b.length - a.length)[0] || [];
220
+ const here = insideRepo ? [path.relative(root, d.root)] : [];
221
+ console.log(`Found ${found.length} git repos in ${root}. Which ones change together as one workspace?`);
222
+ picked = (await pick("Repos", found.map((r) => ({ value: r, label: `${r} (${Object.keys(owners).find((o) => owners[o].includes(r))})` })), [...new Set([...here, ...biggest])]))
223
+ .map((r) => path.join(root, r));
224
+ } else if (insideRepo) picked = [d.root];
225
+ const extra = await ask("Add repos from other folders? Paths, comma-separated (absolute, ~/…, or relative to the workspace folder); empty for none", "");
226
+ picked.push(...splitList(extra).map((p) => path.resolve(root, p.replace(/^~(?=$|\/)/, HOME))));
227
+ }
228
+ const repos = [];
229
+ for (const p of [...new Set(picked)]) {
230
+ if (!fs.existsSync(p)) console.log(` ✗ ${p}: folder not found — skipped`);
231
+ else if (!isRepo(p)) console.log(` ✗ ${p}: not a git repo — skipped`);
232
+ else repos.push(relOrAbs(root, p));
233
+ }
234
+ if (!repos.length) throw new Error("A multi-repo workspace needs at least one git repo. Re-run setup and pick or add repos (or use --repos=a,b).");
235
+ console.log(` Workspace repos: ${repos.join(", ")}`);
236
+ return { root, repos };
237
+ }
238
+
239
+ // monorepo: confirm or edit the package folders (relative to the repo root).
240
+ async function choosePackages(d) {
241
+ const root = d.root;
242
+ const fromFlag = opt("packages");
243
+ const answer = fromFlag || (await ask("Package folders, comma-separated, relative to the repo root (empty if none yet)", d.repos.join(",")));
244
+ const pkgs = [];
245
+ for (const p of splitList(answer)) {
246
+ if (fs.existsSync(path.resolve(root, p))) pkgs.push(relOrAbs(root, path.resolve(root, p)));
247
+ else console.log(` ✗ ${p}: folder not found in ${root} — skipped`);
248
+ }
249
+ return { root, repos: pkgs };
250
+ }
251
+
252
+ async function setup() {
253
+ console.log("\nProject setup — detecting how this project works…");
254
+ const d = detectLayout(DIR);
255
+ const layout = await chooseLayout(d.layout);
256
+ const { root, repos } = layout === "multi-repo" ? await chooseRepos(d) : layout === "monorepo" ? await choosePackages(d) : { root: d.layout === "multi-repo" ? DIR : d.root, repos: [] };
257
+ if (layout === "single" && !isRepo(root)) console.log(` • ${root} isn't a git repo — run setup inside the repo, or choose multi-repo.`);
258
+ const file = path.join(root, ".spec-driven.yml");
259
+ if (fs.existsSync(file) && (await ask(`${file} exists — overwrite? (y/n)`, "n")) !== "y") {
260
+ console.log("• Kept the existing config. Run the spec-setup skill in your AI tool to review it.");
261
+ return;
262
+ }
263
+
264
+ const repoDirs = layout === "multi-repo" ? repos.map((r) => path.resolve(root, r)) : [root];
265
+ const facts = repoDirs.map(gitFacts).filter(Boolean);
266
+ const g = infer(facts);
267
+ if (facts.length) console.log(`Detected: tracker ${g.trackerType}${g.key ? ` (${g.key}-…)` : ""}, base ${g.base}, branches "${g.branchName}", commits ${g.commitStyle}`);
268
+
269
+ const trackerType = await ask("Issue tracker (jira/github/linear/none)", g.trackerType);
270
+ const key = trackerType === "jira" || trackerType === "linear" ? await ask("Ticket key prefix (e.g. STP)", g.key) : "";
271
+ const url = trackerType === "none" ? "" : await ask("Tracker URL (optional)", "");
272
+ const base = await ask('Base branch work is cut from ("default" = repo default branch)', g.base);
273
+ const pr = await ask("Branch PRs target", base);
274
+ const branchName = await ask("Branch name pattern ({ticket} {slug} {type})", g.branchName);
275
+ const commitStyle = await ask("Commit style (conventional/ticket/freeform)", g.commitStyle);
276
+ const specsDir = await ask("Where specs/<feature>/ folders go", "specs");
277
+
278
+ const config = {
279
+ layout, tracker: { type: trackerType, key, url }, specsDir, devUrl: "",
280
+ git: { remote: "origin", base_branch: base, pr_target: pr, branch_name: branchName, commit_style: commitStyle },
281
+ commands: layout === "single" ? commandsFor(repoDirs[0]) : commandsFor(root),
282
+ repos: repos.map((r) => {
283
+ const f = layout === "multi-repo" ? gitFacts(path.resolve(root, r)) : null;
284
+ // a repo without an integration branch must say so, or it inherits the top-level base (e.g. staging) it doesn't have
285
+ return { path: r, role: "", base: f ? f.integration || "default" : "", commands: commandsFor(path.resolve(root, r)) };
286
+ }),
287
+ };
288
+ fs.writeFileSync(file, toYaml(config));
289
+ console.log(`✓ Wrote ${file}`);
290
+ console.log(isRepo(root) ? " Commit it so your team shares the same rules." : " This folder isn't a git repo, so the config is personal to this machine.");
291
+ console.log(' Fill in repo roles, dev_url, and non-Node commands by asking your AI tool to "run spec-setup".');
292
+ }
293
+
294
+ // ---------- reading the config ----------
295
+ // shortcut: parses the YAML subset setup writes (nested maps, lists of maps, scalars); a full YAML parser if users start hand-writing anchors/flow style.
296
+ function parseYaml(text) {
297
+ const root = {};
298
+ const stack = [{ indent: -1, node: root }];
299
+ const scalar = (v) => {
300
+ v = v.replace(/\s+#.*$/, "").trim();
301
+ if (/^".*"$/.test(v)) return JSON.parse(v);
302
+ if (/^'.*'$/.test(v)) return v.slice(1, -1);
303
+ return v;
304
+ };
305
+ for (const raw of text.split("\n")) {
306
+ if (!raw.trim() || raw.trim().startsWith("#")) continue;
307
+ const indent = raw.search(/\S/);
308
+ let line = raw.trim();
309
+ while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop();
310
+ let parent = stack[stack.length - 1].node;
311
+ if (line.startsWith("- ")) {
312
+ const item = {};
313
+ parent.push(item);
314
+ stack.push({ indent, node: item });
315
+ parent = item;
316
+ line = line.slice(2);
317
+ }
318
+ const m = line.match(/^([\w-]+):\s*(.*)$/);
319
+ if (!m) continue;
320
+ const [, key, val] = m;
321
+ if (val === "") {
322
+ const next = text.split("\n").slice(text.split("\n").indexOf(raw) + 1).find((l) => l.trim() && !l.trim().startsWith("#"));
323
+ parent[key] = next && next.trim().startsWith("- ") ? [] : {};
324
+ stack.push({ indent: line === raw.trim() ? indent : indent + 2, node: parent[key] });
325
+ } else parent[key] = scalar(val);
326
+ }
327
+ return root;
328
+ }
329
+
330
+ function findConfig(dir) {
331
+ for (let d = dir; ; d = path.dirname(d)) {
332
+ const f = path.join(d, ".spec-driven.yml");
333
+ if (fs.existsSync(f)) return f;
334
+ if (d === path.dirname(d) || d === HOME) return null;
335
+ }
336
+ }
337
+
338
+ function doctor() {
339
+ const file = findConfig(DIR);
340
+ if (!file) throw new Error(`No .spec-driven.yml in ${DIR} or above. Run: spec-driven-dev setup (or npx @benjaminthomas/spec-driven-dev setup)`);
341
+ const root = path.dirname(file);
342
+ let c;
343
+ try { c = parseYaml(fs.readFileSync(file, "utf8")); } catch (e) { throw new Error(`${file} could not be read: ${e.message}`); }
344
+ const problems = [], notes = [];
345
+ if (!["single", "monorepo", "multi-repo"].includes(c.layout)) problems.push(`layout "${c.layout}" must be single, monorepo, or multi-repo`);
346
+ if (c.tracker && !["jira", "github", "linear", "none"].includes(c.tracker.type)) problems.push(`tracker.type "${c.tracker.type}" must be jira, github, linear, or none`);
347
+ if (c.git?.commit_style && !["conventional", "ticket", "freeform"].includes(c.git.commit_style)) problems.push(`git.commit_style "${c.git.commit_style}" must be conventional, ticket, or freeform`);
348
+ if (c.git?.branch_name && !/\{(ticket|slug)\}/.test(c.git.branch_name)) problems.push(`git.branch_name "${c.git.branch_name}" needs {ticket} or {slug}`);
349
+ const repos = c.layout === "single" ? [{ path: "." }] : Array.isArray(c.repos) ? c.repos : [];
350
+ if (c.layout !== "single" && !repos.length) problems.push(`layout is ${c.layout} but repos: is empty`);
351
+ for (const r of repos) {
352
+ const dir = path.resolve(root, r.path || ".");
353
+ const label = r.path === "." ? "repo" : r.path;
354
+ if (!fs.existsSync(dir)) { problems.push(`${label}: folder not found`); continue; }
355
+ const gitDir = c.layout === "monorepo" ? root : dir;
356
+ if (c.layout !== "monorepo" && !isRepo(gitDir)) { problems.push(`${label}: not a git repo`); continue; }
357
+ if (c.layout === "multi-repo" && !r.role) notes.push(`${label}: no role — investigations search better with one`);
358
+ if (/\(role inferred, verify\)/.test(r.role || "")) notes.push(`${label}: role is still marked "inferred, verify"`);
359
+ for (const k of ["base_branch", "pr_target"]) {
360
+ const b = r.git?.[k] || c.git?.[k];
361
+ if (b && b !== "default" && isRepo(gitDir) && !run("git", ["rev-parse", "--verify", "--quiet", `refs/remotes/${c.git?.remote || "origin"}/${b}`], gitDir) && !run("git", ["rev-parse", "--verify", "--quiet", `refs/heads/${b}`], gitDir))
362
+ problems.push(`${label}: git.${k} "${b}" doesn't exist (locally or on ${c.git?.remote || "origin"})`);
363
+ }
364
+ const pkg = readJson(path.join(dir, "package.json"));
365
+ const cmds = { ...(c.layout === "single" ? c.commands : {}), ...(r.commands || {}) };
366
+ for (const [k, v] of Object.entries(cmds)) {
367
+ const script = typeof v === "string" && v.match(/^(?:npm|pnpm|yarn|bun) run ([\w:.-]+)$/)?.[1];
368
+ if (script && pkg && !pkg.scripts?.[script]) problems.push(`${label}: commands.${k} runs "${script}", which isn't in package.json scripts`);
369
+ }
370
+ }
371
+ const specs = path.join(root, c.specs_dir || "specs");
372
+ if (!fs.existsSync(specs)) notes.push(`${c.specs_dir || "specs"}/ doesn't exist yet — created on first spec`);
373
+ console.log(`Checked ${file}`);
374
+ for (const n of notes) console.log(` • ${n}`);
375
+ for (const p of problems) console.log(` ✗ ${p}`);
376
+ if (problems.length) { process.exitCode = 1; console.log(`${problems.length} problem(s). Fix the file, or ask your AI tool to "run spec-setup".`); }
377
+ else console.log("✓ Config matches the repos.");
378
+ }
379
+
380
+ // Silent unless something is unfinished, so it costs no context in a session start hook.
381
+ function status() {
382
+ const file = findConfig(DIR);
383
+ const root = file ? path.dirname(file) : DIR;
384
+ const specsName = (file && parseYaml(fs.readFileSync(file, "utf8")).specs_dir) || "specs";
385
+ const specs = path.join(root, specsName);
386
+ const lines = [];
387
+ for (const name of subdirs(specs)) {
388
+ const readme = path.join(specs, name, "README.md");
389
+ if (!fs.existsSync(readme)) {
390
+ if (fs.existsSync(path.join(specs, name, "investigation.md"))) lines.push(`- ${specsName}/${name}: investigated, no spec yet → next: spec-create`);
391
+ continue;
392
+ }
393
+ const t = fs.readFileSync(readme, "utf8");
394
+ const done = (t.match(/^\s*- \[x\]/gim) || []).length, todo = (t.match(/^\s*- \[ \]/gm) || []).length;
395
+ if (todo) lines.push(`- ${specsName}/${name}: ${done}/${done + todo} tasks done → next: spec-implement`);
396
+ else if (done && !fs.existsSync(path.join(specs, name, "verification-report.md"))) lines.push(`- ${specsName}/${name}: all tasks done, not verified → next: spec-verify`);
397
+ }
398
+ if (lines.length) console.log(`spec-driven-dev: unfinished work in ${root}\n${lines.join("\n")}`);
399
+ }
400
+
401
+ try {
402
+ if (command === "doctor") doctor();
403
+ else if (command === "status") status();
404
+ else if (command === "install") {
405
+ await install();
406
+ if (!flag("no-setup") && (YES || (await ask("\nSet up this project now? (y/n)", "y")) === "y")) await setup();
407
+ } else if (command === "setup") await setup();
408
+ else throw new Error(`Unknown command "${command}" — use install (default), setup, doctor, or status`);
409
+ if (["install", "setup"].includes(command)) console.log('\nNext: in your AI tool, say "investigate <ticket>" or "create a spec".');
410
+ } catch (e) {
411
+ console.error(`✗ ${e.message}`);
412
+ process.exitCode = 1;
413
+ } finally {
414
+ rl?.close();
415
+ }
package/package.json CHANGED
@@ -1,6 +1,14 @@
1
1
  {
2
2
  "name": "@benjaminthomas/spec-driven-dev",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.4.1",
4
+ "description": "Spec-driven AI development workflow (investigate → spec → implement → verify → ship) as Agent Skills for Claude Code, Cursor, Codex CLI and Antigravity.",
5
+ "type": "module",
6
+ "bin": { "spec-driven-dev": "bin/cli.mjs" },
7
+ "files": ["bin", "skills"],
8
+ "scripts": { "test": "python3 scripts/check.py && node scripts/test-cli.mjs" },
9
+ "engines": { "node": ">=18" },
10
+ "keywords": ["agent-skills", "claude-code", "cursor", "codex", "antigravity", "spec-driven-development", "ai-agents"],
11
+ "repository": { "type": "git", "url": "git+https://github.com/benjaminthomas/spec-driven-dev.git" },
12
+ "license": "MIT",
13
+ "publishConfig": { "access": "public" }
14
+ }