@benjaminthomas/spec-driven-dev 0.0.0-stage → 1.4.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 (29) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +483 -2
  3. package/bin/cli.mjs +357 -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 +99 -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,357 @@
1
+ #!/usr/bin/env node
2
+ // spec-driven-dev installer: 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
+ // npx @benjaminthomas/spec-driven-dev install + project setup (interactive)
6
+ // npx @benjaminthomas/spec-driven-dev setup project setup only
7
+ // npx @benjaminthomas/spec-driven-dev doctor check .spec-driven.yml against the real repos (exit 1 on problems)
8
+ // npx @benjaminthomas/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
+ import fs from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+ import { execFileSync } from "node:child_process";
14
+ import { createInterface } from "node:readline/promises";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
18
+ const SKILLS_DIR = path.join(ROOT, "skills");
19
+ const HOME = process.env.SPEC_DRIVEN_HOME || os.homedir(); // overridable for tests
20
+ const ALL_SKILLS = fs.readdirSync(SKILLS_DIR).filter((d) => fs.existsSync(path.join(SKILLS_DIR, d, "SKILL.md")));
21
+
22
+ const TOOLS = {
23
+ claude: { label: "Claude Code", home: ".claude", bin: "claude", project: ".claude/skills", global: ".claude/skills" },
24
+ cursor: { label: "Cursor", home: ".cursor", bin: "cursor", project: ".cursor/skills", global: ".cursor/skills" },
25
+ codex: { label: "Codex CLI", home: ".codex", bin: "codex", project: ".agents/skills", global: ".agents/skills" }, // Codex reads $HOME/.agents/skills
26
+ antigravity: { label: "Antigravity", home: ".gemini", bin: "antigravity", project: ".agents/skills", global: ".gemini/config/skills" },
27
+ };
28
+
29
+ // ---------- args & prompts ----------
30
+ const args = process.argv.slice(2);
31
+ const flag = (n) => args.includes(`--${n}`);
32
+ const opt = (n) => args.find((a) => a.startsWith(`--${n}=`))?.split("=")[1];
33
+ const command = args.find((a) => !a.startsWith("-")) || "install";
34
+ const YES = flag("yes") || flag("y") || !process.stdin.isTTY || ["doctor", "status"].includes(command);
35
+ const DIR = path.resolve(opt("dir") || process.cwd());
36
+
37
+ if (flag("help") || flag("h")) {
38
+ console.log(fs.readFileSync(fileURLToPath(import.meta.url), "utf8").split("\n").slice(1, 9).join("\n").replace(/^\/\/ ?/gm, ""));
39
+ process.exit(0);
40
+ }
41
+
42
+ const rl = YES ? null : createInterface({ input: process.stdin, output: process.stdout });
43
+ async function ask(q, def = "") {
44
+ if (YES) return def;
45
+ const a = (await rl.question(`${q}${def ? ` [${def}]` : ""}: `)).trim();
46
+ return a || def;
47
+ }
48
+ async function pick(q, items, defaults) {
49
+ items.forEach((it, i) => console.log(` ${i + 1}. ${it.label}`));
50
+ const defIdx = items.map((it, i) => (defaults.includes(it.value) ? i + 1 : null)).filter(Boolean).join(",");
51
+ const a = await ask(`${q} (comma-separated numbers, "all", or "none")`, defIdx || "none");
52
+ if (a === "all") return items.map((it) => it.value);
53
+ if (a === "none") return [];
54
+ return a.split(",").map((n) => items[Number(n.trim()) - 1]?.value).filter(Boolean);
55
+ }
56
+
57
+ const run = (cmd, argv, cwd) => {
58
+ try { return execFileSync(cmd, argv, { cwd, stdio: ["ignore", "pipe", "ignore"] }).toString().trim(); } catch { return ""; }
59
+ };
60
+ const onPath = (bin) => !!run(process.platform === "win32" ? "where" : "which", [bin]);
61
+
62
+ // ---------- install ----------
63
+ async function install() {
64
+ const detected = Object.keys(TOOLS).filter((t) => fs.existsSync(path.join(HOME, TOOLS[t].home)) || onPath(TOOLS[t].bin));
65
+ let tools = opt("tools")?.split(",");
66
+ if (!tools) {
67
+ console.log("\nWhich AI coding tools should get the skills? (detected ones are pre-selected)");
68
+ tools = await pick("Tools", Object.entries(TOOLS).map(([value, t]) => ({ value, label: t.label + (detected.includes(value) ? " ✓ detected" : "") })), detected);
69
+ }
70
+ const bad = tools.filter((t) => !TOOLS[t]);
71
+ if (bad.length) throw new Error(`Unknown tool(s): ${bad.join(", ")} — expected ${Object.keys(TOOLS).join(", ")}`);
72
+
73
+ // Claude Code users who already have the plugin would get every skill twice.
74
+ const plugins = path.join(HOME, ".claude/plugins/installed_plugins.json");
75
+ if (tools.includes("claude") && !opt("tools") && fs.existsSync(plugins) && fs.readFileSync(plugins, "utf8").includes('"spec-driven-dev@')) {
76
+ console.log("• Claude Code already has the spec-driven-dev plugin, so it's skipped here (update it with /plugin).");
77
+ tools = tools.filter((t) => t !== "claude");
78
+ }
79
+
80
+ const global = flag("global") || (!YES && (await ask("Install for this project only, or globally for all projects? (project/global)", "project")) === "global");
81
+ const skills = opt("skills")?.split(",") || ALL_SKILLS;
82
+ const unknown = skills.filter((s) => !ALL_SKILLS.includes(s));
83
+ if (unknown.length) throw new Error(`Unknown skill(s): ${unknown.join(", ")} — expected ${ALL_SKILLS.join(", ")}`);
84
+
85
+ const dests = [...new Set(tools.map((t) => (global ? path.join(HOME, TOOLS[t].global) : path.join(DIR, TOOLS[t].project))))];
86
+ for (const dest of dests) {
87
+ for (const s of skills) {
88
+ const to = path.join(dest, s);
89
+ fs.rmSync(to, { recursive: true, force: true });
90
+ fs.cpSync(path.join(SKILLS_DIR, s), to, { recursive: true, dereference: true, filter: (f) => !f.endsWith(".DS_Store") });
91
+ }
92
+ console.log(`✓ ${skills.length} skills → ${dest}`);
93
+ }
94
+ if (!dests.length) console.log("• No tools selected — nothing installed.");
95
+ }
96
+
97
+ // ---------- discovery ----------
98
+ const MARKERS = ["pnpm-workspace.yaml", "turbo.json", "nx.json", "lerna.json", "go.work"];
99
+ const PKG_DIRS = ["apps", "packages", "services", "libs"];
100
+ const MANIFESTS = ["package.json", "go.mod", "pyproject.toml", "Cargo.toml", "composer.json", "pom.xml"];
101
+ const isRepo = (d) => run("git", ["rev-parse", "--is-inside-work-tree"], d) === "true";
102
+ const readJson = (f) => { try { return JSON.parse(fs.readFileSync(f, "utf8")); } catch { return null; } };
103
+ const subdirs = (d) => { try { return fs.readdirSync(d, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name); } catch { return []; } };
104
+
105
+ function detectLayout(dir) {
106
+ if (isRepo(dir)) {
107
+ const top = run("git", ["rev-parse", "--show-toplevel"], dir);
108
+ const pkg = readJson(path.join(top, "package.json"));
109
+ const cargo = fs.existsSync(path.join(top, "Cargo.toml")) && fs.readFileSync(path.join(top, "Cargo.toml"), "utf8").includes("[workspace]");
110
+ const hasManifest = (rel) => MANIFESTS.some((m) => fs.existsSync(path.join(top, rel, m)));
111
+ // Prefer the workspace list the repo declares; fall back to conventional folders.
112
+ // shortcut: only "dir/*", "dir/**" and exact paths are expanded — enough for typical workspace globs.
113
+ 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]) : [];
114
+ const declared = [...(Array.isArray(pkg?.workspaces) ? pkg.workspaces : pkg?.workspaces?.packages || []), ...pnpm].filter((g) => !g.startsWith("!"));
115
+ const globs = declared.length ? declared : PKG_DIRS.map((p) => `${p}/*`);
116
+ const packages = [...new Set(globs.flatMap((g) => {
117
+ const base = g.replace(/\/\*\*?$/, "");
118
+ return base === g ? [g.replace(/\/$/, "")] : subdirs(path.join(top, base)).map((n) => `${base}/${n}`);
119
+ }))].filter(hasManifest);
120
+ const mono = MARKERS.some((m) => fs.existsSync(path.join(top, m))) || pkg?.workspaces || cargo || packages.length > 1;
121
+ return { layout: mono ? "monorepo" : "single", root: top, repos: mono ? packages : [] };
122
+ }
123
+ // a git worktree has a .git *file*; it belongs to its main repo, so skip it
124
+ const repos = subdirs(dir).filter((n) => { try { return fs.statSync(path.join(dir, n, ".git")).isDirectory(); } catch { return false; } });
125
+ return repos.length ? { layout: "multi-repo", root: dir, repos } : { layout: "single", root: dir, repos: [] };
126
+ }
127
+
128
+ function commandsFor(dir) {
129
+ const pkg = readJson(path.join(dir, "package.json"));
130
+ const out = { install: "", lint: "", typecheck: "", test: "", build: "", dev: "" };
131
+ if (!pkg) return out;
132
+ 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";
133
+ const s = pkg.scripts || {};
134
+ const name = (...ks) => ks.find((k) => s[k]);
135
+ const cmd = (k) => (k ? (pm === "npm" && k === "test" ? "npm test" : `${pm} run ${k}`) : "");
136
+ 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")) };
137
+ }
138
+
139
+ function gitFacts(dir) {
140
+ if (!isRepo(dir)) return null;
141
+ const def = run("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], dir).replace(/^origin\//, "") || run("git", ["branch", "--show-current"], dir);
142
+ 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\//, ""));
143
+ const subjects = run("git", ["log", "-40", "--format=%s"], dir).split("\n").filter(Boolean);
144
+ const owner = (run("git", ["remote", "get-url", "origin"], dir).match(/[:/]([^/:]+)\/[^/]+?(\.git)?$/) || [])[1] || "";
145
+ return { def, branches, subjects, owner, integration: ["staging", "develop", "development"].find((b) => branches.includes(b)) || "" };
146
+ }
147
+
148
+ function infer(facts) {
149
+ const branches = facts.flatMap((f) => f.branches);
150
+ const subjects = facts.flatMap((f) => f.subjects);
151
+ const keys = {};
152
+ 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;
153
+ const key = Object.entries(keys).sort((a, b) => b[1] - a[1])[0]?.[0] || "";
154
+ const ghRefs = subjects.filter((s) => /#\d+/.test(s)).length;
155
+ const bare = branches.filter((b) => /^[A-Z][A-Z0-9]+-\d+$/.test(b)).length;
156
+ const work = branches.filter((b) => !["main", "master", "HEAD", "staging", "develop", "development", "pre-prod", "origin"].includes(b)).length || 1;
157
+ const conv = subjects.filter((s) => /^(feat|fix|chore|docs|refactor|test|perf|build|ci|style)(\(.+\))?!?:/.test(s)).length;
158
+ const tick = subjects.filter((s) => /^\[?[A-Z][A-Z0-9]+-\d+/.test(s)).length;
159
+ const n = subjects.length || 1;
160
+ return {
161
+ trackerType: key ? "jira" : ghRefs ? "github" : "none",
162
+ key,
163
+ branchName: key && bare / work >= 0.3 ? "{ticket}" : key ? "{type}/{ticket}-{slug}" : "{type}/{slug}",
164
+ commitStyle: conv / n >= 0.5 ? "conventional" : tick / n >= 0.5 ? "ticket" : "freeform",
165
+ base: facts.filter((f) => f.integration === "staging").length > facts.length / 2 ? "staging" : facts.find((f) => f.integration)?.integration || "default",
166
+ };
167
+ }
168
+
169
+ const y = (v) => JSON.stringify(v ?? ""); // JSON strings are valid YAML scalars
170
+ function toYaml(c) {
171
+ const cmds = (o, pad) => Object.entries(o).map(([k, v]) => `${pad}${k}: ${y(v)}`).join("\n");
172
+ 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`;
173
+ if (c.repos.length) {
174
+ out += "repos:\n" + c.repos.map((r) => {
175
+ let e = ` - path: ${y(r.path)}\n role: ${y(r.role)}\n`;
176
+ if (r.base && r.base !== c.git.base_branch) e += ` git:\n base_branch: ${y(r.base)}\n pr_target: ${y(r.base)}\n`;
177
+ if (r.commands && Object.values(r.commands).some(Boolean)) e += ` commands:\n${cmds(r.commands, " ")}\n`;
178
+ return e;
179
+ }).join("");
180
+ }
181
+ out += `tracker:\n type: ${c.tracker.type}\n key: ${y(c.tracker.key)}\n url: ${y(c.tracker.url)}\n`;
182
+ out += `git:\n${cmds(c.git, " ")}\n`;
183
+ out += `commands:\n${cmds(c.commands, " ")}\n dev_url: ${y(c.devUrl)}\n`;
184
+ return out + `specs_dir: ${y(c.specsDir)}\n`;
185
+ }
186
+
187
+ async function setup() {
188
+ console.log("\nProject setup — detecting how this project works…");
189
+ const d = detectLayout(DIR);
190
+ let repos = d.repos;
191
+ if (d.layout === "multi-repo") {
192
+ const owners = {};
193
+ for (const r of repos) (owners[gitFacts(path.join(d.root, r))?.owner || "?"] ||= []).push(r);
194
+ const biggest = Object.values(owners).sort((a, b) => b.length - a.length)[0] || [];
195
+ console.log(`Found ${repos.length} git repos in ${d.root}. Which ones change together as one workspace?`);
196
+ repos = await pick("Repos", repos.map((r) => ({ value: r, label: `${r} (${Object.keys(owners).find((o) => owners[o].includes(r))})` })), biggest);
197
+ }
198
+ const layout = await ask(`Layout — single, monorepo, or multi-repo (detected: ${d.layout})`, d.layout);
199
+ const root = d.root;
200
+ const file = path.join(root, ".spec-driven.yml");
201
+ if (fs.existsSync(file) && (await ask(`${file} exists — overwrite? (y/n)`, "n")) !== "y") {
202
+ console.log("• Kept the existing config. Run the spec-setup skill in your AI tool to review it.");
203
+ return;
204
+ }
205
+
206
+ const repoDirs = layout === "multi-repo" ? repos.map((r) => path.join(root, r)) : [isRepo(root) ? root : DIR];
207
+ const facts = repoDirs.map(gitFacts).filter(Boolean);
208
+ const g = infer(facts);
209
+ if (facts.length) console.log(`Detected: tracker ${g.trackerType}${g.key ? ` (${g.key}-…)` : ""}, base ${g.base}, branches "${g.branchName}", commits ${g.commitStyle}`);
210
+
211
+ const trackerType = await ask("Issue tracker (jira/github/linear/none)", g.trackerType);
212
+ const key = trackerType === "jira" || trackerType === "linear" ? await ask("Ticket key prefix (e.g. STP)", g.key) : "";
213
+ const url = trackerType === "none" ? "" : await ask("Tracker URL (optional)", "");
214
+ const base = await ask('Base branch work is cut from ("default" = repo default branch)', g.base);
215
+ const pr = await ask("Branch PRs target", base);
216
+ const branchName = await ask("Branch name pattern ({ticket} {slug} {type})", g.branchName);
217
+ const commitStyle = await ask("Commit style (conventional/ticket/freeform)", g.commitStyle);
218
+ const specsDir = await ask("Where specs/<feature>/ folders go", "specs");
219
+
220
+ const config = {
221
+ layout, tracker: { type: trackerType, key, url }, specsDir, devUrl: "",
222
+ git: { remote: "origin", base_branch: base, pr_target: pr, branch_name: branchName, commit_style: commitStyle },
223
+ commands: layout === "single" ? commandsFor(repoDirs[0]) : commandsFor(root),
224
+ repos: layout === "single" ? [] : (layout === "multi-repo" ? repos : d.repos).map((r) => {
225
+ const f = layout === "multi-repo" ? gitFacts(path.join(root, r)) : null;
226
+ // a repo without an integration branch must say so, or it inherits the top-level base (e.g. staging) it doesn't have
227
+ return { path: r, role: "", base: f ? f.integration || "default" : "", commands: commandsFor(path.join(root, r)) };
228
+ }),
229
+ };
230
+ fs.writeFileSync(file, toYaml(config));
231
+ console.log(`✓ Wrote ${file}`);
232
+ 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.");
233
+ console.log(' Fill in repo roles, dev_url, and non-Node commands by asking your AI tool to "run spec-setup".');
234
+ }
235
+
236
+ // ---------- reading the config ----------
237
+ // 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.
238
+ function parseYaml(text) {
239
+ const root = {};
240
+ const stack = [{ indent: -1, node: root }];
241
+ const scalar = (v) => {
242
+ v = v.replace(/\s+#.*$/, "").trim();
243
+ if (/^".*"$/.test(v)) return JSON.parse(v);
244
+ if (/^'.*'$/.test(v)) return v.slice(1, -1);
245
+ return v;
246
+ };
247
+ for (const raw of text.split("\n")) {
248
+ if (!raw.trim() || raw.trim().startsWith("#")) continue;
249
+ const indent = raw.search(/\S/);
250
+ let line = raw.trim();
251
+ while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop();
252
+ let parent = stack[stack.length - 1].node;
253
+ if (line.startsWith("- ")) {
254
+ const item = {};
255
+ parent.push(item);
256
+ stack.push({ indent, node: item });
257
+ parent = item;
258
+ line = line.slice(2);
259
+ }
260
+ const m = line.match(/^([\w-]+):\s*(.*)$/);
261
+ if (!m) continue;
262
+ const [, key, val] = m;
263
+ if (val === "") {
264
+ const next = text.split("\n").slice(text.split("\n").indexOf(raw) + 1).find((l) => l.trim() && !l.trim().startsWith("#"));
265
+ parent[key] = next && next.trim().startsWith("- ") ? [] : {};
266
+ stack.push({ indent: line === raw.trim() ? indent : indent + 2, node: parent[key] });
267
+ } else parent[key] = scalar(val);
268
+ }
269
+ return root;
270
+ }
271
+
272
+ function findConfig(dir) {
273
+ for (let d = dir; ; d = path.dirname(d)) {
274
+ const f = path.join(d, ".spec-driven.yml");
275
+ if (fs.existsSync(f)) return f;
276
+ if (d === path.dirname(d) || d === HOME) return null;
277
+ }
278
+ }
279
+
280
+ function doctor() {
281
+ const file = findConfig(DIR);
282
+ if (!file) throw new Error(`No .spec-driven.yml in ${DIR} or above. Run: npx @benjaminthomas/spec-driven-dev setup`);
283
+ const root = path.dirname(file);
284
+ let c;
285
+ try { c = parseYaml(fs.readFileSync(file, "utf8")); } catch (e) { throw new Error(`${file} could not be read: ${e.message}`); }
286
+ const problems = [], notes = [];
287
+ if (!["single", "monorepo", "multi-repo"].includes(c.layout)) problems.push(`layout "${c.layout}" must be single, monorepo, or multi-repo`);
288
+ 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`);
289
+ 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`);
290
+ 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}`);
291
+ const repos = c.layout === "single" ? [{ path: "." }] : Array.isArray(c.repos) ? c.repos : [];
292
+ if (c.layout !== "single" && !repos.length) problems.push(`layout is ${c.layout} but repos: is empty`);
293
+ for (const r of repos) {
294
+ const dir = path.join(root, r.path || "");
295
+ const label = r.path === "." ? "repo" : r.path;
296
+ if (!fs.existsSync(dir)) { problems.push(`${label}: folder not found`); continue; }
297
+ const gitDir = c.layout === "monorepo" ? root : dir;
298
+ if (c.layout !== "monorepo" && !isRepo(gitDir)) { problems.push(`${label}: not a git repo`); continue; }
299
+ if (c.layout === "multi-repo" && !r.role) notes.push(`${label}: no role — investigations search better with one`);
300
+ if (/\(role inferred, verify\)/.test(r.role || "")) notes.push(`${label}: role is still marked "inferred, verify"`);
301
+ for (const k of ["base_branch", "pr_target"]) {
302
+ const b = r.git?.[k] || c.git?.[k];
303
+ 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))
304
+ problems.push(`${label}: git.${k} "${b}" doesn't exist (locally or on ${c.git?.remote || "origin"})`);
305
+ }
306
+ const pkg = readJson(path.join(dir, "package.json"));
307
+ const cmds = { ...(c.layout === "single" ? c.commands : {}), ...(r.commands || {}) };
308
+ for (const [k, v] of Object.entries(cmds)) {
309
+ const script = typeof v === "string" && v.match(/^(?:npm|pnpm|yarn|bun) run ([\w:.-]+)$/)?.[1];
310
+ if (script && pkg && !pkg.scripts?.[script]) problems.push(`${label}: commands.${k} runs "${script}", which isn't in package.json scripts`);
311
+ }
312
+ }
313
+ const specs = path.join(root, c.specs_dir || "specs");
314
+ if (!fs.existsSync(specs)) notes.push(`${c.specs_dir || "specs"}/ doesn't exist yet — created on first spec`);
315
+ console.log(`Checked ${file}`);
316
+ for (const n of notes) console.log(` • ${n}`);
317
+ for (const p of problems) console.log(` ✗ ${p}`);
318
+ if (problems.length) { process.exitCode = 1; console.log(`${problems.length} problem(s). Fix the file, or ask your AI tool to "run spec-setup".`); }
319
+ else console.log("✓ Config matches the repos.");
320
+ }
321
+
322
+ // Silent unless something is unfinished, so it costs no context in a session start hook.
323
+ function status() {
324
+ const file = findConfig(DIR);
325
+ const root = file ? path.dirname(file) : DIR;
326
+ const specsName = (file && parseYaml(fs.readFileSync(file, "utf8")).specs_dir) || "specs";
327
+ const specs = path.join(root, specsName);
328
+ const lines = [];
329
+ for (const name of subdirs(specs)) {
330
+ const readme = path.join(specs, name, "README.md");
331
+ if (!fs.existsSync(readme)) {
332
+ if (fs.existsSync(path.join(specs, name, "investigation.md"))) lines.push(`- ${specsName}/${name}: investigated, no spec yet → next: spec-create`);
333
+ continue;
334
+ }
335
+ const t = fs.readFileSync(readme, "utf8");
336
+ const done = (t.match(/^\s*- \[x\]/gim) || []).length, todo = (t.match(/^\s*- \[ \]/gm) || []).length;
337
+ if (todo) lines.push(`- ${specsName}/${name}: ${done}/${done + todo} tasks done → next: spec-implement`);
338
+ else if (done && !fs.existsSync(path.join(specs, name, "verification-report.md"))) lines.push(`- ${specsName}/${name}: all tasks done, not verified → next: spec-verify`);
339
+ }
340
+ if (lines.length) console.log(`spec-driven-dev: unfinished work in ${root}\n${lines.join("\n")}`);
341
+ }
342
+
343
+ try {
344
+ if (command === "doctor") doctor();
345
+ else if (command === "status") status();
346
+ else if (command === "install") {
347
+ await install();
348
+ if (!flag("no-setup") && (YES || (await ask("\nSet up this project now? (y/n)", "y")) === "y")) await setup();
349
+ } else if (command === "setup") await setup();
350
+ else throw new Error(`Unknown command "${command}" — use install (default), setup, doctor, or status`);
351
+ if (["install", "setup"].includes(command)) console.log('\nNext: in your AI tool, say "investigate <ticket>" or "create a spec".');
352
+ } catch (e) {
353
+ console.error(`✗ ${e.message}`);
354
+ process.exitCode = 1;
355
+ } finally {
356
+ rl?.close();
357
+ }
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.0",
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
+ }
@@ -0,0 +1,255 @@
1
+ ---
2
+ name: generative-seo
3
+ description: Run an evidence-based SEO + GEO (Generative Engine Optimization) program for ANY website or product codebase — auditing technical SEO, writing or retrofitting content so it gets cited by ChatGPT/Perplexity/AI Overviews, tracking AI-citation visibility, refreshing competitor research, and drafting distribution posts. Use whenever the user asks to audit SEO, improve AI-search/AI-citation visibility, write or optimize marketing/blog content for search or GEO, check whether a product shows up in ChatGPT/Perplexity answers, do competitor/keyword research, or wants a monthly SEO performance report — regardless of framework (Next.js, Astro, Remix, plain HTML, WordPress, etc.) or what the product is. Works on any repo; it interviews you once to learn the project's specifics (brand, domain, framework, docs location) and remembers the answers.
4
+ license: MIT
5
+ ---
6
+
7
+ # Generative SEO (GEO) Operations
8
+
9
+ Runs an SEO + GEO program the same way a sharp in-house SEO operator would: technical
10
+ audits, answer-first content built to be quoted by AI engines, an AI-citation tracker,
11
+ competitor watch, and (draft-only) distribution. It is deliberately generic — first use
12
+ in a repo spends a few minutes learning that project's facts, then behaves like a
13
+ project-specific skill from then on.
14
+
15
+ **Mode selection:** the argument names the mode (`content`, `audit`, `optimize`,
16
+ `competitors`, `citations`, `report`, `social`). No argument → read the project's docs
17
+ (below), summarize current state, and recommend the highest-impact next action.
18
+
19
+ For the full evidence behind every rule below (which studies, what they found, why
20
+ each technique works) and the complete technical checklist, see
21
+ `references/geo-methodology.md`. Read it before an `audit` or before writing a
22
+ first piece of `content` in a new project; skip re-reading it on later runs in the
23
+ same project.
24
+
25
+ ---
26
+
27
+ ## First run in a project: learn the project
28
+
29
+ Look for a config file at `docs/seo/geo-config.md` (or wherever the user says their
30
+ SEO docs live — some repos keep them in `content/`, `marketing/`, or a wiki instead).
31
+ If it exists, read it and skip to **Read these first**.
32
+
33
+ If it does not exist, this is the first run — ask the user (batch these, don't
34
+ interview one question at a time):
35
+
36
+ 1. **Product/brand name(s)** — and critically, if the product is a sub-brand of a
37
+ parent company or a different name than the company that builds it, get that
38
+ distinction explicit now (e.g. "Acme Corp is the company; Acme Flow is the
39
+ product we're marketing" or "same name, no distinction needed"). Getting this
40
+ wrong (writing "is Acme Corp free?" when the product is Acme Flow) is a
41
+ real, recurring mistake — nail it once here instead of guessing per-page later.
42
+ 2. **Primary domain** the content lives on (affects sitemap/robots checks and
43
+ internal-link audits).
44
+ 3. **Framework** — Next.js, Astro, Remix, SvelteKit, plain static HTML, a CMS
45
+ (WordPress/Webflow/etc.), or something else. This determines where
46
+ sitemap/robots/metadata/JSON-LD live (see `references/geo-methodology.md` for
47
+ the per-framework map) — if unsure, look at the repo yourself first
48
+ (`package.json`, top-level config files) before asking.
49
+ 4. **Where marketing/content pages live** in the repo (a directory, a headless
50
+ CMS, or "not in this repo" if content is managed elsewhere).
51
+ 5. **Existing design system doc**, if any (a `DESIGN.md`, a Storybook, a Figma
52
+ library) — new/edited pages should match it rather than invent a new look.
53
+ 6. **How to source real statistics** for content — product analytics the user can
54
+ query, a public dataset, or "external sources only." Never invent numbers
55
+ regardless of the answer.
56
+ 7. **Docs folder** for this skill's own working files (default: `docs/seo/` at
57
+ repo root — offer this default rather than making the user think of one).
58
+
59
+ Write the answers to `<docs_dir>/geo-config.md` using
60
+ `assets/geo-config-template.md` as the shape. Then create the four working docs
61
+ from their templates in `assets/` (only the ones missing — never overwrite an
62
+ existing one):
63
+
64
+ | File | Template | Purpose |
65
+ |---|---|---|
66
+ | `<docs_dir>/geo-playbook.md` | `assets/geo-playbook-template.md` | This project's evidence-based rules + house style, filled in from the config answers |
67
+ | `<docs_dir>/content-plan.md` | `assets/content-plan-template.md` | The execution backlog with statuses |
68
+ | `<docs_dir>/competitor-analysis.md` | `assets/competitor-analysis-template.md` | Competitor landscape + keyword gaps |
69
+ | `<docs_dir>/citation-log.md` | `assets/citation-log-template.md` | Dated AI-citation audit history |
70
+
71
+ For `citations` mode specifically, also derive a **fixed prompt set**: 10–15
72
+ realistic queries someone would type into ChatGPT/Perplexity/Google when looking
73
+ for a product like this one (category term, "best X for Y" roundups, problem-first
74
+ questions, a couple of named-competitor-alternative queries). Save them into
75
+ `<docs_dir>/citation-log.md`'s header. Keep this list stable run-over-run — append
76
+ new queries, don't rewrite the set, or trend comparison breaks.
77
+
78
+ ## Read these first (source of truth — keep them updated every run)
79
+
80
+ - `<docs_dir>/geo-config.md` — project facts (brand, domain, framework, paths)
81
+ - `<docs_dir>/geo-playbook.md` — this project's rules, filled in from the methodology reference
82
+ - `<docs_dir>/content-plan.md` — phased content queue with statuses
83
+ - `<docs_dir>/competitor-analysis.md` — competitor landscape + keyword gap map
84
+ - `<docs_dir>/citation-log.md` — AI-citation audit history + the fixed prompt set
85
+
86
+ ---
87
+
88
+ ## Non-negotiable content rules (every page you write or edit)
89
+
90
+ Full evidence (Princeton GEO paper, Ahrefs citation studies) in
91
+ `references/geo-methodology.md`. Summary:
92
+
93
+ 1. **Answer-first**: the complete, direct answer to the target query lands in the
94
+ first ~200 words. Most AI citations pull from early-page content.
95
+ 2. **≥3 statistics, ≥1 quotable expert-voice line, cited sources** per substantive
96
+ page — measurably lifts AI-citation odds. Stats must be real and sourced (see
97
+ the project's config for where "real" comes from) — never invented.
98
+ 3. **Extractable structure**: H2/H3s phrased as the questions people actually ask;
99
+ Q&A blocks; comparison tables; a TL;DR/summary box; definition-style openings
100
+ ("X is …").
101
+ 4. **Self-contained sections**: every H2 section should make sense quoted in
102
+ isolation — AI retrieval is passage-level, not whole-page.
103
+ 5. **Author + published date + last-updated date** visible on-page. Refresh
104
+ anything older than ~6 months.
105
+ 6. **FAQ block + `FAQPage` JSON-LD** on pricing, feature, and comparison pages.
106
+ 7. Tone: practitioner-credible, specific, first-hand. No filler, never keyword-stuff.
107
+ 8. Word count follows the query's actual answer depth — never pad, never ship a
108
+ page so thin it can't stand on its own (a rough floor: ~300 words).
109
+ 9. Follow the naming/brand distinction captured in `geo-config.md` on every page —
110
+ re-check it before publishing if the page discusses "who makes this."
111
+ 10. Match whatever design system this repo already has (`geo-config.md` points at
112
+ it) — a GEO-optimized page that looks out of place hurts more than it helps.
113
+
114
+ ---
115
+
116
+ ## Mode: `audit` — technical SEO + GEO audit (add `--fix` to also apply fixes)
117
+
118
+ Audit BOTH the codebase and the live site (fetch the live URLs — deployed state
119
+ drifts from the repo). Report every failure with file:line. With `--fix`, apply
120
+ code-level fixes and run the project's own check commands (lint/typecheck —
121
+ inspect `package.json` scripts or the repo's build tooling; skip anything that
122
+ doesn't exist rather than assuming `npm run check`).
123
+
124
+ Full checklist (sitemap/robots by framework, JSON-LD schema types, AI-crawler
125
+ allowlist, `llms.txt`, heading hygiene, internal linking, Core Web Vitals sanity)
126
+ is in `references/geo-methodology.md` — pull it up for this mode.
127
+
128
+ **Output:** severity-ordered findings table (blocker / high / med / low), each
129
+ with file:line and the fix. With `--fix`: apply, verify with the project's checks,
130
+ summarize the diff.
131
+
132
+ ## Mode: `content` — write the next content piece
133
+
134
+ 1. Read `<docs_dir>/content-plan.md`; pick the first `todo` item in the current
135
+ phase (or the item the user names). Flag any unresolved blockers first.
136
+ 2. Check the target query's live SERP (web search) — confirm intent, note what
137
+ ranks, find a differentiating angle grounded in what this specific product
138
+ actually does differently (pull that from `geo-config.md` / the codebase, not
139
+ assumption).
140
+ 3. Write the piece following the non-negotiable rules above. Comparison pages
141
+ must be honest about competitors — credibility is the citation currency.
142
+ 4. Implement it wherever this repo's content actually lives (per `geo-config.md`
143
+ — a route file, a CMS entry, a markdown collection) with full metadata,
144
+ JSON-LD, and design-system compliance.
145
+ 5. Add the URL to the project's sitemap mechanism. Update the item's status in
146
+ `content-plan.md` to `shipped (YYYY-MM-DD)`.
147
+ 6. Run the project's lint/typecheck/build if it has them. Present the draft for
148
+ review — content is outward-facing; the user approves before it goes live.
149
+ 7. AFTER the page is deployed and live, if the project has an IndexNow/Bing-ping
150
+ mechanism, use it (shortens AI-engine discovery from days to hours). If it
151
+ doesn't, mention manual IndexNow submission (bing.com/indexnow) as an option
152
+ rather than inventing tooling. Never ping before deploy.
153
+
154
+ ## Mode: `optimize` — GEO-retrofit an existing page
155
+
156
+ Given a URL/path: score it against the non-negotiable rules (answer-first? stats?
157
+ extractable structure? dates? FAQ? schema?), report a before/after checklist, then
158
+ apply improvements preserving the page's voice and design.
159
+
160
+ ## Mode: `competitors` — refresh competitor & keyword research
161
+
162
+ 1. Re-run research (web search) covering the players already in
163
+ `competitor-analysis.md`, plus new AI-native entrants in the same category.
164
+ 2. Check whether the product's category term has a new canonical answer in
165
+ search; check who's cited in relevant "best X" roundups.
166
+ 3. Diff against the existing doc: new entrants, keyword-gap changes, threats.
167
+ Append a dated changelog entry — don't remove existing queue items. Suggest
168
+ content-plan additions.
169
+ Cadence: quarterly, or whenever the user senses a new competitor.
170
+
171
+ ## Mode: `citations` — AI-visibility audit
172
+
173
+ 1. Run the fixed prompt set from `citation-log.md` through web search (captures
174
+ Google/AI-Overview signals) and check Bing presence for the same queries.
175
+ Direct ChatGPT/Perplexity querying usually isn't possible from here — log
176
+ what's checkable and list the 5-minute manual checks for the user with exact
177
+ prompts to paste.
178
+ 2. For each query: record who ranks / is cited, whether this product appears
179
+ (distinguish **mention** vs **citation**), and which of the project's own
180
+ pages should be winning it.
181
+ 3. Append a dated entry to `citation-log.md`. Report the trend vs. the previous
182
+ entry.
183
+
184
+ ## Mode: `report` — periodic performance summary
185
+
186
+ Compile from: citation-log trend, content-plan ship rate, live-site spot checks,
187
+ and (if the user provides Search Console/Bing/GA exports — ask, don't assume
188
+ access) impressions/clicks on target queries. Output: what shipped, what moved,
189
+ top 3 recommended actions for next period. Set realistic expectations: Google
190
+ traction is typically months out from a content ship; Bing/Perplexity move faster
191
+ (weeks).
192
+
193
+ ## Mode: `social` — draft distribution posts for shipped content (drafts ONLY)
194
+
195
+ This mode never posts anywhere — automated posting risks ToS violations on
196
+ LinkedIn/Reddit/etc. and the account behind it. Output goes to
197
+ `<docs_dir>/social-queue.md` (create from `assets/social-queue-template.md` on
198
+ first run); the user reviews, pastes, and marks items posted.
199
+
200
+ 1. Read `social-queue.md` and `content-plan.md`. Pick shipped pages with no queue
201
+ entry yet — or the page the user names. Newest first, max 3 pages per run.
202
+ 2. For each page draft, in the founder/team's actual voice (first person,
203
+ practitioner tone, one concrete stat or claim pulled from the page, minimal
204
+ hashtags, ends with a question or the link — never reads like a press
205
+ release):
206
+ - A personal-profile post for whatever the primary channel is (ask if
207
+ unclear — LinkedIn and X are common defaults).
208
+ - A shorter, product-forward version for a company/brand page, if one exists.
209
+ - If the piece is worth republishing elsewhere (e.g. Medium), a checklist
210
+ entry noting the platform's canonical-URL mechanism must be used — never
211
+ copy-paste in a way that loses canonical attribution.
212
+ - Community threads (Reddit-style forums, Hacker News, niche Slack/Discord
213
+ communities): only where the skill has search budget to find 1–3 live
214
+ threads the page genuinely answers; draft a value-first reply that stands
215
+ on its own without the link, flagged for the user to post manually, in
216
+ their own voice. Never suggest posting the identical comment in 2 places.
217
+ 3. Append drafts under a dated heading, one entry per page, with a status marker
218
+ the user flips (`[ ] queued` → `[x] posted YYYY-MM-DD <URL>` or `[-]
219
+ skipped`). Never overwrite prior entries.
220
+ 4. Report what was drafted and what's still unposted from previous runs — an
221
+ aging queue means distribution, not content, is the bottleneck.
222
+
223
+ ---
224
+
225
+ ## Automation (scheduled agents)
226
+
227
+ If the harness supports scheduled/cron-style agent runs, these are reasonable
228
+ cadences — set them up only if the user asks, never silently:
229
+
230
+ | Routine | Cadence | Action |
231
+ |---|---|---|
232
+ | Content | Weekly | Pick the next queue item, open a PR (never push straight to main) |
233
+ | Citation audit | Monthly | Update the citation log on a branch |
234
+ | Technical audit | Monthly | File findings as a report; PR fixes only for blockers |
235
+ | Competitor refresh | Quarterly | Refresh competitor-analysis.md |
236
+ | Social drafts | Weekly | Draft posts for newly-shipped pages |
237
+
238
+ Scheduled runs always work on a branch + PR — content is outward-facing and needs
239
+ human review before going live. If a run hits an unresolved blocker, report
240
+ rather than plow ahead. Never invent statistics, in scheduled or interactive runs.
241
+
242
+ ## Standing cautions
243
+
244
+ - A subdomain is a separate site for authority purposes — don't split content
245
+ onto a subdomain without a deliberate reason; it usually just dilutes signal.
246
+ - Programmatic/templated pages need real data depth per page — thin
247
+ variable-injection pages risk scaled-content-abuse penalties for the whole
248
+ domain.
249
+ - `llms.txt` and schema markup are hygiene, not strategy — don't over-invest
250
+ expecting them alone to move AI-citation odds. The actual lever is answer-first
251
+ content + real stats + third-party mentions (roundups, directories, forums,
252
+ YouTube) + fast indexing (Bing/IndexNow).
253
+ - Anything gated behind a login the skill can't access (Search Console, Bing
254
+ Webmaster Tools, review-site dashboards, social platforms) → give the user a
255
+ precise action list; don't fake having checked it.
@@ -0,0 +1,17 @@
1
+ # AI Citation Log
2
+
3
+ ## Fixed prompt set
4
+
5
+ <!--
6
+ 10-15 queries a real prospect would type into ChatGPT / Perplexity / Google
7
+ when looking for a product like this one. Derived once on first `citations`
8
+ run, kept stable so trend comparisons mean something — append, don't rewrite.
9
+ -->
10
+
11
+ -
12
+
13
+ ## Audit history
14
+
15
+ | Date | Prompt | Engine | Our status (mention/citation/absent) | Who wins | Note |
16
+ |---|---|---|---|---|---|
17
+ | | | | | | |