@benjaminthomas/spec-driven-dev 1.5.1 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -93,6 +93,23 @@ words in quotes.
93
93
  /spec-ship "ship it"
94
94
  ```
95
95
 
96
+ **The right model for each task.** `spec-create` rates every task **light**, **standard**, or
97
+ **deep**, based on how much it touches and how risky it is (data, security, concurrency, public
98
+ contracts, unclear requirements). `spec-implement` then runs each task on the model for its tier:
99
+
100
+ | Tier | Typical task | Claude Code default |
101
+ |---|---|---|
102
+ | light | copy change, config value, small UI tweak following an existing pattern | `haiku` |
103
+ | standard | a normal feature slice: route + service + tests | `sonnet` |
104
+ | deep | migrations with backfills, auth/permissions, concurrency, cross-repo contracts | `opus` |
105
+
106
+ - **Reviews:** each batch's review runs on at least `standard`, and on the batch's highest tier.
107
+ - **Escalation:** a task that still fails after a fix round is retried one tier up.
108
+ - **Tools that run one model per session** (Cursor, Codex) don't switch silently. Before a heavier
109
+ batch, they pause and tell you which model to switch to.
110
+ - **Changing the models:** edit the `models:` section of `.spec-driven.yml`, for example to use a
111
+ newer model for `deep`.
112
+
96
113
  **Fewer stops:** after reviewing the spec, say **"approved, run it through"**. The workflow then
97
114
  implements, verifies, and commits in one go. It stops at the first failure, and it always asks
98
115
  before pushing or opening a PR. To make this the default after approval, choose `run-through` during
@@ -116,8 +133,12 @@ say **"run spec-setup"**: that does a deeper version that also reads `CLAUDE.md`
116
133
  config, and recent PRs, and fills in what the installer can't (such as each repo's role, or commands
117
134
  for stacks other than Node). If a skill finds no config, it runs this setup first.
118
135
 
119
- **What `spec-driven-dev` asks, in order** (every answer comes pre-filled with what it detected, so you
120
- can usually just press Enter):
136
+ **What `spec-driven-dev` asks, in order.** Each question is a menu: move with **↑/↓**, tick with
137
+ **Space**, and confirm with **Enter**. What it detected is pre-selected and marked *(detected)*, so you
138
+ can usually just press Enter. Repo paths are added one at a time: paste a path (quotes, `~`, and
139
+ Windows paths like `C:\work\api` all work). A path that doesn't exist or isn't a git repo is flagged
140
+ straight away, so you can fix it before moving on. Press Enter on an empty line when you're done.
141
+ **Ctrl+C** cancels without changing anything.
121
142
 
122
143
  | Question | Detected from | Example answer |
123
144
  |---|---|---|
@@ -135,6 +156,7 @@ can usually just press Enter):
135
156
  | Lint / typecheck / test / build / dev commands | `package.json` scripts and lockfile (per repo or package) | `pnpm run lint` |
136
157
  | Where specs go | (you choose) | `specs/` |
137
158
  | After you approve a spec | (you choose) | `stop` (wait at each stage) or `run-through` |
159
+ | Model per task tier | written with defaults: Claude Code `haiku`/`sonnet`/`opus`, others `default` | edit `models:` later |
138
160
 
139
161
  **Where the file lives, by layout:**
140
162
 
@@ -148,12 +170,16 @@ can usually just press Enter):
148
170
  example, from inside `/Projects/frontend`:
149
171
 
150
172
  ```
151
- Layout — single, monorepo, or multi-repo (detected: single) [single]: multi-repo
152
- Found 1 git repos under /Projects. Which ones belong to this workspace?
153
- 1. frontend
154
- Repos [1]:
155
- Other repo paths, comma-separated (empty for none): ~/Work/backend, /srv/database
156
- Where should this workspace's .spec-driven.yml live [~/.spec-driven/frontend-workspace]:
173
+ ✔ How is this project organised? › Multiple repos
174
+ ✔ Which of these repos (found under /Projects) belong to this workspace? › frontend
175
+ ? Add a repo from another folder — paste its path, or press Enter to finish (1 selected) › ~/Work/nope
176
+ › Folder not found: /Users/you/Work/nope
177
+ ? Add a repo from another folder — paste its path, or press Enter to finish (1 selected) › ~/Work/backend
178
+ ✓ added /Users/you/Work/backend
179
+ ? Add a repo from another folder — paste its path, or press Enter to finish (2 selected) › /srv/database
180
+ ✓ added /srv/database
181
+ ? Add a repo from another folder — paste its path, or press Enter to finish (3 selected) ›
182
+ ✔ Folder for this workspace's settings file (.spec-driven.yml) › ~/.spec-driven/frontend-workspace
157
183
  ```
158
184
 
159
185
  - **Where the config goes:** the repos' shared parent folder, or `~/.spec-driven/<name>/` when that
@@ -408,7 +434,8 @@ touched by an update.
408
434
  Releases are published by this repo's GitHub Actions workflow, not from anyone's laptop, through
409
435
  npm [trusted publishing](https://docs.npmjs.com/trusted-publishers). Each version's npm page shows
410
436
  a provenance badge linking it to the exact commit and workflow run that built it. The package has
411
- no dependencies, and it contains only `bin/cli.mjs` and the `skills/` folder.
437
+ one small dependency (`prompts`, for the setup menus), and it contains only `bin/` and the `skills/`
438
+ folder.
412
439
 
413
440
  **Something isn't working. Where do I start?**
414
441
  Run `spec-driven-dev doctor` in your project. It checks the config against your
@@ -462,8 +489,9 @@ can otherwise reuse an older copy it cached. What changed in each version is in
462
489
  `npx github:benjaminthomas/spec-driven-dev` installs straight from GitHub instead, for example to
463
490
  try an unreleased commit on `main`.
464
491
 
465
- **Requirements:** Node.js 14 or newer and git. The installer has no dependencies, and CI tests it on
466
- Node 14, 16, 18, 20, 22, and 24.
492
+ **Requirements:** Node.js 14 or newer and git, on macOS, Linux, or Windows (PowerShell or cmd). The
493
+ installer has one small dependency, [`prompts`](https://www.npmjs.com/package/prompts), for its menus.
494
+ CI tests it on Node 14, 16, 18, 20, 22, and 24, and on Windows and macOS.
467
495
 
468
496
  ### Claude Code (plugin, with automatic updates)
469
497
 
@@ -532,15 +560,15 @@ skips that step rather than failing.
532
560
 
533
561
  | Skill | Stage | SKILL.md lines | Words | Supporting files |
534
562
  |---|---|---:|---:|---:|
535
- | `spec-setup` | 0 · configure | 114 | 1,022 | 1 |
536
- | `spec-investigate` | 1 · understand | 325 | 2,741 | 1 |
537
- | `spec-create` | 2 · plan | 157 | 1,415 | 4 |
538
- | `spec-implement` | 3 · build | 273 | 2,109 | 3 |
563
+ | `spec-setup` | 0 · configure | 117 | 1,068 | 1 |
564
+ | `spec-investigate` | 1 · understand | 330 | 2,794 | 1 |
565
+ | `spec-create` | 2 · plan | 166 | 1,503 | 5 |
566
+ | `spec-implement` | 3 · build | 298 | 2,375 | 3 |
539
567
  | `spec-verify` | 4 · prove | 155 | 1,492 | 1 |
540
568
  | `spec-checkpoint` | 5 · commit | 108 | 795 | 0 |
541
569
  | `spec-ship` | 6 · PR | 220 | 1,268 | 0 |
542
570
  | `generative-seo` | standalone | 255 | 2,209 | 7 |
543
- | **Total** | | **1,607** | **13,051** | **17** |
571
+ | **Total** | | **1,649** | **13,504** | **18** |
544
572
 
545
573
  - **8** skills: **1** setup + **6** workflow stages + **1** standalone
546
574
  - **4** tools supported natively (Claude Code, Codex CLI, Cursor, Antigravity), plus an MCP fallback
@@ -559,7 +587,8 @@ skills/<name>/SKILL.md the skill itself (frontmatter: name, description,
559
587
  skills/<name>/references/ templates and prompts the skill loads when needed
560
588
  .claude-plugin/ Claude Code plugin + marketplace manifests
561
589
  .agents/skills/ symlinks so Codex/Antigravity pick the skills up inside this repo
562
- bin/cli.mjs the `spec-driven-dev` CLI: installer, setup, doctor, status (no dependencies)
590
+ bin/spec-driven-dev.cjs launcher (runs on any Node, explains if it's too old)
591
+ bin/cli.mjs the `spec-driven-dev` CLI: installer, setup menus, doctor, status
563
592
  package.json npm package (ships bin/ and skills/)
564
593
  mcp-server/ MCP fallback server
565
594
  hooks/hooks.json Claude Code session-start hook (runs `cli.mjs status`)
package/bin/cli.mjs CHANGED
@@ -13,7 +13,7 @@ import fs from "node:fs";
13
13
  import os from "node:os";
14
14
  import path from "node:path";
15
15
  import { execFileSync } from "node:child_process";
16
- import { createInterface } from "node:readline"; // callback API: readline/promises needs Node 17+
16
+ import prompts from "prompts"; // arrow-key menus that work in PowerShell, cmd, macOS and Linux terminals
17
17
  import { fileURLToPath } from "node:url";
18
18
 
19
19
  const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
@@ -61,19 +61,25 @@ if (flag("help") || flag("h")) {
61
61
  process.exit(0);
62
62
  }
63
63
 
64
- const rl = YES ? null : createInterface({ input: process.stdin, output: process.stdout });
65
- async function ask(q, def = "") {
66
- if (YES) return def;
67
- const a = (await new Promise((resolve) => rl.question(`${q}${def ? ` [${def}]` : ""}: `, resolve))).trim();
68
- return a || def;
64
+ // Every question goes through these helpers. With --yes (or answers given as flags) they return the default.
65
+ const cancelled = () => { console.log("\n✗ Setup cancelled. Nothing was changed."); process.exit(1); };
66
+ async function ask(q) { const r = await prompts({ name: "v", ...q }, { onCancel: cancelled }); return r.v; }
67
+ async function askText(message, initial = "", validate) {
68
+ if (YES) return initial;
69
+ return String((await ask({ type: "text", message, initial, validate })) ?? "").trim();
69
70
  }
70
- async function pick(q, items, defaults) {
71
- items.forEach((it, i) => console.log(` ${i + 1}. ${it.label}`));
72
- const defIdx = items.map((it, i) => (defaults.includes(it.value) ? i + 1 : null)).filter(Boolean).join(",");
73
- const a = await ask(`${q} (comma-separated numbers, "all", or "none")`, defIdx || "none");
74
- if (a === "all") return items.map((it) => it.value);
75
- if (a === "none") return [];
76
- return a.split(",").map((n) => items[Number(n.trim()) - 1]?.value).filter(Boolean);
71
+ async function askSelect(message, choices, initialValue) {
72
+ if (YES) return initialValue;
73
+ const initial = Math.max(0, choices.findIndex((c) => c.value === initialValue));
74
+ return ask({ type: "select", message, choices, initial, hint: "↑/↓ then Enter" });
75
+ }
76
+ async function askMulti(message, choices) {
77
+ if (YES) return choices.filter((c) => c.selected).map((c) => c.value);
78
+ return ask({ type: "multiselect", message, choices, instructions: false, hint: "↑/↓ move · Space select · A all · Enter confirm" });
79
+ }
80
+ async function askConfirm(message, initial = true) {
81
+ if (YES) return initial;
82
+ return ask({ type: "toggle", message, initial, active: "yes", inactive: "no" });
77
83
  }
78
84
 
79
85
  const run = (cmd, argv, cwd) => {
@@ -96,10 +102,8 @@ function copyDir(from, to) {
96
102
  async function install() {
97
103
  const detected = Object.keys(TOOLS).filter((t) => fs.existsSync(path.join(HOME, TOOLS[t].home)) || onPath(TOOLS[t].bin));
98
104
  let tools = opt("tools")?.split(",");
99
- if (!tools) {
100
- console.log("\nWhich AI coding tools should get the skills? (detected ones are pre-selected)");
101
- tools = await pick("Tools", Object.entries(TOOLS).map(([value, t]) => ({ value, label: t.label + (detected.includes(value) ? " ✓ detected" : "") })), detected);
102
- }
105
+ if (!tools) tools = await askMulti("Which AI coding tools should get the skills?",
106
+ Object.entries(TOOLS).map(([value, t]) => ({ value, title: t.label + (detected.includes(value) ? " (installed)" : ""), selected: detected.includes(value) })));
103
107
  const bad = tools.filter((t) => !TOOLS[t]);
104
108
  if (bad.length) throw new Error(`Unknown tool(s): ${bad.join(", ")} — expected ${Object.keys(TOOLS).join(", ")}`);
105
109
 
@@ -110,7 +114,10 @@ async function install() {
110
114
  tools = tools.filter((t) => t !== "claude");
111
115
  }
112
116
 
113
- const global = flag("global") || (!YES && (await ask("Install for this project only, or globally for all projects? (project/global)", "project")) === "global");
117
+ const global = flag("global") || (await askSelect("Install the skills for…", [
118
+ { title: "This project only", value: false, description: "skills go in this folder, e.g. .cursor/skills/" },
119
+ { title: "All my projects", value: true, description: "skills go in your home folder, e.g. ~/.cursor/skills/" },
120
+ ], false));
114
121
  const skills = opt("skills")?.split(",") || ALL_SKILLS;
115
122
  const unknown = skills.filter((s) => !ALL_SKILLS.includes(s));
116
123
  if (unknown.length) throw new Error(`Unknown skill(s): ${unknown.join(", ")} — expected ${ALL_SKILLS.join(", ")}`);
@@ -152,7 +159,7 @@ function findRepos(dir, depth = 2, prefix = "") {
152
159
 
153
160
  function detectLayout(dir) {
154
161
  if (isRepo(dir)) {
155
- const top = run("git", ["rev-parse", "--show-toplevel"], dir);
162
+ const top = path.resolve(run("git", ["rev-parse", "--show-toplevel"], dir)); // C:/x → C:\x on Windows
156
163
  const pkg = readJson(path.join(top, "package.json"));
157
164
  const cargo = fs.existsSync(path.join(top, "Cargo.toml")) && fs.readFileSync(path.join(top, "Cargo.toml"), "utf8").includes("[workspace]");
158
165
  const hasManifest = (rel) => MANIFESTS.some((m) => fs.existsSync(path.join(top, rel, m)));
@@ -228,11 +235,22 @@ function toYaml(c) {
228
235
  out += `tracker:\n type: ${c.tracker.type}\n key: ${y(c.tracker.key)}\n url: ${y(c.tracker.url)}\n`;
229
236
  out += `git:\n${cmds(c.git, " ")}\n`;
230
237
  out += `commands:\n${cmds(c.commands, " ")}\n dev_url: ${y(c.devUrl)}\n`;
238
+ out += "models: # model per task tier (light / standard / deep); \"default\" = the session's model\n" + Object.entries(MODEL_DEFAULTS)
239
+ .map(([tool, m]) => ` ${tool}:\n` + TIERS.map((t) => ` ${t}: ${m[t]}\n`).join("")).join("");
231
240
  out += `pipeline:\n after_approval: ${c.afterApproval || "stop"} # stop | run-through (implement → verify → checkpoint; ship always asks)\n`;
232
241
  return out + `specs_dir: ${y(c.specsDir)}\n`;
233
242
  }
234
243
 
235
244
  const LAYOUTS = ["single", "monorepo", "multi-repo"];
245
+ const TIERS = ["light", "standard", "deep"];
246
+ // Aliases, not dated model IDs, so they keep pointing at the current models. Other tools default to
247
+ // the session's own model; spec-implement then tells you when to switch.
248
+ const MODEL_DEFAULTS = {
249
+ claude: { light: "haiku", standard: "sonnet", deep: "opus" },
250
+ cursor: { light: "default", standard: "default", deep: "default" },
251
+ codex: { light: "default", standard: "default", deep: "default" },
252
+ antigravity: { light: "default", standard: "default", deep: "default" },
253
+ };
236
254
  const splitList = (v) => (v || "").split(",").map((x) => x.trim()).filter(Boolean);
237
255
  // Paths are stored relative to the config's folder when inside it, absolute otherwise.
238
256
  const real = (p) => { try { return fs.realpathSync(p); } catch { return p; } }; // /var vs /private/var etc.
@@ -242,14 +260,25 @@ async function chooseLayout(detected) {
242
260
  const forced = opt("layout");
243
261
  if (forced && !LAYOUTS.includes(forced)) throw new Error(`--layout must be one of ${LAYOUTS.join(", ")}`);
244
262
  if (forced) return forced;
245
- for (;;) {
246
- const a = await ask(`Layout — single, monorepo, or multi-repo (detected: ${detected})`, detected);
247
- if (LAYOUTS.includes(a)) return a;
248
- console.log(` Please type one of: ${LAYOUTS.join(", ")}`);
249
- }
263
+ const tag = (v) => (v === detected ? " (detected)" : "");
264
+ return askSelect("How is this project organised?", [
265
+ { title: `Single repo${tag("single")}`, value: "single", description: "one git repo, one app" },
266
+ { title: `Monorepo${tag("monorepo")}`, value: "monorepo", description: "one git repo with several apps or packages" },
267
+ { title: `Multiple repos${tag("multi-repo")}`, value: "multi-repo", description: "several git repos that change together, in any folders" },
268
+ ], detected);
250
269
  }
251
270
 
252
- const expandHome = (p) => p.replace(/^~(?=$|\/)/, HOME);
271
+ const expandHome = (p) => p.replace(/^~(?=$|[\\/])/, HOME);
272
+ // Accept what people paste: surrounding quotes, ~, relative paths, Windows or POSIX separators.
273
+ const cleanPath = (v) => path.resolve(DIR, expandHome(String(v).trim().replace(/^(["'])(.*)\1$/, "$2").trim()));
274
+ const gitTop = (p) => { const t = run("git", ["rev-parse", "--show-toplevel"], p); return t ? path.resolve(t) : p; }; // git prints C:/x on Windows
275
+ function repoPathProblem(v) {
276
+ if (!String(v || "").trim()) return null;
277
+ const p = cleanPath(v);
278
+ if (!fs.existsSync(p)) return `Folder not found: ${p}`;
279
+ if (!isRepo(p)) return `Not a git repo: ${p}`;
280
+ return null;
281
+ }
253
282
 
254
283
  function commonParent(paths) {
255
284
  if (!paths.length) return "";
@@ -266,7 +295,7 @@ async function chooseRepos(d) {
266
295
  let picked = [];
267
296
  const fromFlag = opt("repos");
268
297
  if (fromFlag) {
269
- picked = splitList(fromFlag).map((p) => path.resolve(DIR, expandHome(p)));
298
+ picked = splitList(fromFlag).map(cleanPath);
270
299
  } else {
271
300
  const found = findRepos(scanRoot);
272
301
  if (found.length) {
@@ -274,19 +303,25 @@ async function chooseRepos(d) {
274
303
  for (const r of found) { const o = gitFacts(path.join(scanRoot, r))?.owner || "?"; (owners[o] = owners[o] || []).push(r); }
275
304
  const biggest = Object.values(owners).sort((a, b) => b.length - a.length)[0] || [];
276
305
  const here = insideRepo ? [path.relative(scanRoot, d.root)] : [];
277
- console.log(`Found ${found.length} git repos under ${scanRoot}. Which ones belong to this workspace?`);
278
- picked = (await pick("Repos", found.map((r) => ({ value: r, label: `${r} (${Object.keys(owners).find((o) => owners[o].includes(r))})` })), [...new Set([...here, ...(insideRepo ? [] : biggest)])]))
306
+ const pre = new Set([...here, ...(insideRepo ? [] : biggest)]);
307
+ picked = (await askMulti(`Which of these repos (found under ${scanRoot}) belong to this workspace?`,
308
+ found.map((r) => { const o = Object.keys(owners).find((k) => owners[k].includes(r)); return { value: r, title: r + (o && o !== "?" ? ` (${o})` : ""), selected: pre.has(r) }; })))
279
309
  .map((r) => path.join(scanRoot, r));
280
310
  } else if (insideRepo) picked = [d.root];
281
- console.log("Repos in other places can be part of the same workspace (e.g. ~/Work/backend, /srv/database).");
282
- const extra = await ask("Other repo paths, comma-separated (empty for none)", "");
283
- picked.push(...splitList(extra).map((p) => path.resolve(DIR, expandHome(p))));
311
+ // Repos elsewhere: one path at a time, checked as you type, so a typo can be fixed straight away.
312
+ if (!YES) for (;;) {
313
+ const v = await askText(`Add a repo from another folder — paste its path, or press Enter to finish${picked.length ? ` (${picked.length} selected)` : ""}`, "",
314
+ (x) => repoPathProblem(x) || true);
315
+ if (!v) break;
316
+ picked.push(cleanPath(v));
317
+ console.log(` ✓ added ${gitTop(cleanPath(v))}`);
318
+ }
284
319
  }
285
320
  const abs = [];
286
321
  for (const p of [...new Set(picked)]) {
287
322
  if (!fs.existsSync(p)) console.log(` ✗ ${p}: folder not found — skipped`);
288
323
  else if (!isRepo(p)) console.log(` ✗ ${p}: not a git repo — skipped`);
289
- else abs.push(run("git", ["rev-parse", "--show-toplevel"], p) || p);
324
+ else abs.push(gitTop(p));
290
325
  }
291
326
  if (!abs.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).");
292
327
 
@@ -299,7 +334,7 @@ async function chooseRepos(d) {
299
334
  const defRoot = opt("workspace") ? path.resolve(DIR, expandHome(opt("workspace")))
300
335
  : allUnderScan ? scanRoot
301
336
  : tooBroad ? path.join(HOME, ".spec-driven", path.basename(abs[0]) + "-workspace") : parent;
302
- const root = path.resolve(DIR, expandHome(await ask("Where should this workspace's .spec-driven.yml live", defRoot)));
337
+ const root = cleanPath(await askText("Folder for this workspace's settings file (.spec-driven.yml)", defRoot));
303
338
  fs.mkdirSync(root, { recursive: true });
304
339
  const repos = [...new Set(abs)].map((p) => relOrAbs(root, p));
305
340
  console.log(` Workspace repos: ${repos.join(", ")}`);
@@ -321,9 +356,17 @@ function register(file, repoPaths) {
321
356
  async function choosePackages(d) {
322
357
  const root = d.root;
323
358
  const fromFlag = opt("packages");
324
- const answer = fromFlag || (await ask("Package folders, comma-separated, relative to the repo root (empty if none yet)", d.repos.join(",")));
359
+ let chosen = fromFlag ? splitList(fromFlag) : d.repos.length
360
+ ? await askMulti("Which packages are part of this monorepo?", d.repos.map((r) => ({ value: r, title: r, selected: true })))
361
+ : [];
362
+ if (!fromFlag && !YES) for (;;) {
363
+ const v = await askText("Add another package folder (relative to the repo root), or press Enter to finish", "",
364
+ (x) => !String(x || "").trim() || fs.existsSync(path.resolve(root, String(x).trim())) || `Folder not found in ${root}`);
365
+ if (!v) break;
366
+ chosen.push(v);
367
+ }
325
368
  const pkgs = [];
326
- for (const p of splitList(answer)) {
369
+ for (const p of chosen) {
327
370
  if (fs.existsSync(path.resolve(root, p))) pkgs.push(relOrAbs(root, path.resolve(root, p)));
328
371
  else console.log(` ✗ ${p}: folder not found in ${root} — skipped`);
329
372
  }
@@ -342,7 +385,7 @@ async function setup() {
342
385
  const { root, repos } = layout === "multi-repo" ? await chooseRepos(d) : layout === "monorepo" ? await choosePackages(d) : { root: d.layout === "multi-repo" ? DIR : d.root, repos: [] };
343
386
  if (layout === "single" && !isRepo(root)) console.log(` • ${root} isn't a git repo — run setup inside the repo, or choose multi-repo.`);
344
387
  const file = path.join(root, ".spec-driven.yml");
345
- if (fs.existsSync(file) && (await ask(`${file} exists — overwrite? (y/n)`, "n")) !== "y") {
388
+ if (fs.existsSync(file) && !(await askConfirm(`${file} already exists. Replace it?`, false))) {
346
389
  console.log("• Kept the existing config. Run the spec-setup skill in your AI tool to review it.");
347
390
  return;
348
391
  }
@@ -352,22 +395,44 @@ async function setup() {
352
395
  const g = infer(facts);
353
396
  if (facts.length) console.log(`Detected: tracker ${g.trackerType}${g.key ? ` (${g.key}-…)` : ""}, base ${g.base}, branches "${g.branchName}", commits ${g.commitStyle}`);
354
397
 
355
- const trackerType = await ask("Issue tracker (jira/github/linear/none)", g.trackerType);
356
- const key = trackerType === "jira" || trackerType === "linear" ? await ask("Ticket key prefix (e.g. STP)", g.key) : "";
357
- const url = trackerType === "none" ? "" : await ask("Tracker URL (optional)", "");
358
- const base = await ask('Base branch work is cut from ("default" = repo default branch)', g.base);
359
- const pr = await ask("Branch PRs target", base);
360
- const branchName = await ask("Branch name pattern ({ticket} {slug} {type})", g.branchName);
361
- const commitStyle = await ask("Commit style (conventional/ticket/freeform)", g.commitStyle);
362
- const specsDir = await ask("Where specs/<feature>/ folders go", "specs");
363
- console.log("After you approve a spec, the workflow can stop and wait for you at each stage, or run");
364
- console.log("implement → verify → checkpoint in one go (it stops at any failure, and never pushes without asking).");
365
- let afterApproval = opt("after-approval");
366
- if (!afterApproval) for (;;) {
367
- afterApproval = await ask("After approval: stop or run-through", "stop");
368
- if (["stop", "run-through"].includes(afterApproval)) break;
369
- console.log(" Please type stop or run-through");
370
- }
398
+ const det = (v, d) => (v === d ? " (detected)" : "");
399
+ const trackerType = await askSelect("Where do your tickets live?", [
400
+ { title: `Jira${det("jira", g.trackerType)}`, value: "jira" },
401
+ { title: `GitHub Issues${det("github", g.trackerType)}`, value: "github" },
402
+ { title: `Linear${det("linear", g.trackerType)}`, value: "linear" },
403
+ { title: `No tracker${det("none", g.trackerType)}`, value: "none", description: "work from transcripts, docs, or plain descriptions" },
404
+ ], g.trackerType);
405
+ const key = trackerType === "jira" || trackerType === "linear" ? (await askText("Ticket key prefix (the STP in STP-123)", g.key)).toUpperCase() : "";
406
+ const url = trackerType === "none" ? "" : await askText("Tracker URL (optional, press Enter to skip)", "");
407
+ const branchChoices = (initial) => [
408
+ { title: "Each repo's default branch", value: "default", description: "e.g. main or master" },
409
+ ...[...new Set(facts.map((f) => f.integration).filter(Boolean).concat(initial !== "default" ? [initial] : []))].map((b) => ({ title: b, value: b })),
410
+ { title: "Another branch…", value: "__other" },
411
+ ];
412
+ const pickBranch = async (message, initial) => {
413
+ const v = await askSelect(message, branchChoices(initial), initial);
414
+ return v === "__other" ? askText("Branch name", "") : v;
415
+ };
416
+ const base = await pickBranch("Which branch is new work started from?", g.base);
417
+ const pr = await pickBranch("Which branch do pull requests go into?", base);
418
+ const ex = key || "STP";
419
+ let branchName = await askSelect("How are work branches named?", [
420
+ { title: `${ex}-123${det("{ticket}", g.branchName)}`, value: "{ticket}", description: "just the ticket ID" },
421
+ { title: `feat/${ex}-123-add-login${det("{type}/{ticket}-{slug}", g.branchName)}`, value: "{type}/{ticket}-{slug}" },
422
+ { title: `feat/add-login${det("{type}/{slug}", g.branchName)}`, value: "{type}/{slug}", description: "no ticket ID" },
423
+ { title: "Custom pattern…", value: "__custom" },
424
+ ], g.branchName);
425
+ if (branchName === "__custom") branchName = await askText("Pattern using {ticket}, {slug}, {type}", g.branchName, (x) => /\{(ticket|slug)\}/.test(x) || "Include {ticket} or {slug}");
426
+ const commitStyle = await askSelect("How are commit messages written?", [
427
+ { title: `feat: add login${det("conventional", g.commitStyle)}`, value: "conventional", description: "conventional commits" },
428
+ { title: `${ex}-123: add login${det("ticket", g.commitStyle)}`, value: "ticket", description: "ticket ID first" },
429
+ { title: `Add login${det("freeform", g.commitStyle)}`, value: "freeform", description: "no fixed format" },
430
+ ], g.commitStyle);
431
+ const specsDir = await askText("Folder for specs (one sub-folder per feature)", "specs");
432
+ const afterApproval = opt("after-approval") || await askSelect("After you approve a spec…", [
433
+ { title: "Stop after each stage", value: "stop", description: "you start implement, verify and checkpoint yourself" },
434
+ { title: "Run straight through", value: "run-through", description: "implement → verify → commit in one go; stops at any failure, never pushes without asking" },
435
+ ], "stop");
371
436
 
372
437
  const config = {
373
438
  layout, tracker: { type: trackerType, key, url }, specsDir, devUrl: "", afterApproval,
@@ -412,7 +477,8 @@ function parseYaml(text) {
412
477
  }
413
478
  const m = line.match(/^([\w-]+):\s*(.*)$/);
414
479
  if (!m) continue;
415
- const [, key, val] = m;
480
+ const [, key, rawVal] = m;
481
+ const val = rawVal.replace(/(^|\s+)#.*$/, "").trim(); // `key: # comment` opens a block
416
482
  if (val === "") {
417
483
  const next = text.split("\n").slice(text.split("\n").indexOf(raw) + 1).find((l) => l.trim() && !l.trim().startsWith("#"));
418
484
  parent[key] = next && next.trim().startsWith("- ") ? [] : {};
@@ -445,6 +511,11 @@ function doctor() {
445
511
  if (!["single", "monorepo", "multi-repo"].includes(c.layout)) problems.push(`layout "${c.layout}" must be single, monorepo, or multi-repo`);
446
512
  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`);
447
513
  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`);
514
+ for (const [tool, m] of Object.entries(c.models || {})) {
515
+ if (typeof m !== "object") { problems.push(`models.${tool} must list light / standard / deep`); continue; }
516
+ for (const t of TIERS) if (!m[t]) problems.push(`models.${tool}.${t} is missing (use "default" for the session's model)`);
517
+ for (const k of Object.keys(m)) if (!TIERS.includes(k)) problems.push(`models.${tool}.${k}: unknown tier (use light, standard, deep)`);
518
+ }
448
519
  if (c.pipeline?.after_approval && !["stop", "run-through"].includes(c.pipeline.after_approval)) problems.push(`pipeline.after_approval "${c.pipeline.after_approval}" must be stop or run-through`);
449
520
  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}`);
450
521
  const repos = c.layout === "single" ? [{ path: "." }] : Array.isArray(c.repos) ? c.repos : [];
@@ -504,7 +575,7 @@ try {
504
575
  else if (command === "status") status();
505
576
  else if (command === "install") {
506
577
  await install();
507
- if (!flag("no-setup") && (YES || (await ask("\nSet up this project now? (y/n)", "y")) === "y")) await setup();
578
+ if (!flag("no-setup") && (await askConfirm("Set up this project now?", true))) await setup();
508
579
  } else if (command === "setup") await setup();
509
580
  else throw new Error(`Unknown command "${command}" — use install (default), setup, doctor, or status`);
510
581
  if (["install", "setup"].includes(command)) console.log('\nNext: in your AI tool, say "investigate <ticket>" or "create a spec".');
@@ -512,5 +583,4 @@ try {
512
583
  console.error(`✗ ${e.message}`);
513
584
  process.exitCode = 1;
514
585
  } finally {
515
- rl?.close();
516
586
  }
package/package.json CHANGED
@@ -1,14 +1,39 @@
1
1
  {
2
2
  "name": "@benjaminthomas/spec-driven-dev",
3
- "version": "1.5.1",
3
+ "version": "1.7.0",
4
4
  "description": "Spec-driven AI development workflow (investigate → spec → implement → verify → ship) as Agent Skills for Claude Code, Cursor, Codex CLI and Antigravity.",
5
5
  "type": "module",
6
- "bin": { "spec-driven-dev": "bin/spec-driven-dev.cjs" },
7
- "files": ["bin", "skills"],
8
- "scripts": { "test": "python3 scripts/check.py && node scripts/test-cli.mjs" },
9
- "engines": { "node": ">=14" },
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" },
6
+ "bin": {
7
+ "spec-driven-dev": "bin/spec-driven-dev.cjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "skills"
12
+ ],
13
+ "scripts": {
14
+ "test": "python3 scripts/check.py && node scripts/test-cli.mjs"
15
+ },
16
+ "engines": {
17
+ "node": ">=14"
18
+ },
19
+ "keywords": [
20
+ "agent-skills",
21
+ "claude-code",
22
+ "cursor",
23
+ "codex",
24
+ "antigravity",
25
+ "spec-driven-development",
26
+ "ai-agents"
27
+ ],
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/benjaminthomas/spec-driven-dev.git"
31
+ },
12
32
  "license": "MIT",
13
- "publishConfig": { "access": "public" }
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "dependencies": {
37
+ "prompts": "2.4.2"
38
+ }
14
39
  }
@@ -68,6 +68,11 @@ Think carefully about granularity. Too coarse and agents can't work in parallel.
68
68
 
69
69
  Tests ship with the code they cover: fill in each task's **Tests** section (see the task template) instead of creating separate "write tests" tasks. Only add a standalone testing task for cross-cutting suites (e.g. a new E2E harness) or when the user asks for one.
70
70
 
71
+ **Rate each task's complexity.** Read `references/complexity-rubric.md` and give every task a tier
72
+ (`light`, `standard`, or `deep`) with the signals that justify it. `spec-implement` uses the tier to
73
+ pick the model for that task, so a risky task under-rated as `light` is the costly mistake. Round up
74
+ when unsure. If one task scores `deep` mainly because it's doing too much, split it first.
75
+
71
76
  ### Step 4: Build the Dependency Graph
72
77
 
73
78
  For each task, identify:
@@ -130,6 +135,10 @@ Files created:
130
135
  - action-required.md
131
136
  - tasks/ ({T} task files)
132
137
 
138
+ Model plan: {n} light · {n} standard · {n} deep
139
+ (models from .spec-driven.yml → light: {model}, standard: {model}, deep: {model})
140
+ Deep tasks: {task-nn — main reason}
141
+
133
142
  Batch breakdown:
134
143
  - Batch 1: {count} tasks (parallel) — {brief description}
135
144
  - Batch 2: {count} tasks (parallel) — {brief description}
@@ -0,0 +1,57 @@
1
+ # Complexity Rubric
2
+
3
+ Use this to give every task a **tier**: `light`, `standard`, or `deep`. `spec-implement` runs the
4
+ task on the model mapped to that tier in `.spec-driven.yml` (`models:`). Rate from the evidence in
5
+ the task file and investigation, not from how long the description is.
6
+
7
+ ## Contents
8
+ - Scoring
9
+ - Tier from score
10
+ - Overrides
11
+ - Examples
12
+ - What to write in the task file
13
+
14
+ ## Scoring
15
+
16
+ Add one point for each signal that applies:
17
+
18
+ | Signal | Point when… |
19
+ |---|---|
20
+ | Spread | the task changes more than 3 files, or files in more than one module/package/repo |
21
+ | New design | there's no existing pattern in the repo to copy (new abstraction, algorithm, or integration) |
22
+ | Data | it changes a schema, migration, persisted format, or data backfill |
23
+ | Concurrency | it involves async ordering, queues, retries, locking, caching, or idempotency |
24
+ | Security | it touches auth, permissions, tenant isolation, secrets, payments, or untrusted input |
25
+ | Public contract | it changes an API, event, or interface that other code or other repos depend on |
26
+ | Ambiguity | acceptance criteria rely on judgement, or the investigation left UNKNOWNs in this area |
27
+ | Hard to verify | correctness can't be fully shown by the tests listed (timing, performance, UI feel) |
28
+
29
+ ## Tier from score
30
+
31
+ | Score | Tier | Typical work |
32
+ |---|---|---|
33
+ | 0–1 | `light` | copy change, config value, small UI tweak, adding a field following an existing pattern |
34
+ | 2–3 | `standard` | a normal feature slice: a route + service + tests following existing patterns |
35
+ | 4+ | `deep` | cross-cutting design, migrations with backfills, auth/permission logic, concurrency fixes |
36
+
37
+ ## Overrides
38
+
39
+ - **Security or Data alone make a task at least `standard`**, even if nothing else applies.
40
+ - **Security + Data, or Security + Concurrency, make it `deep`**, whatever the score.
41
+ - If the score is borderline, round up: a tier too high costs money, a tier too low costs rework.
42
+
43
+ ## Examples
44
+
45
+ - "Rename the Export button to Download CSV" → 0 → `light`
46
+ - "Add `exported_at` column and show it on the orders page" → Data, Spread → 2 → `standard`
47
+ - "Make the CSV export stream rows instead of loading 10k into memory" → New design, Concurrency, Hard to verify → 3 → `standard`
48
+ - "Enforce tenant isolation on all export endpoints and backfill missing tenant IDs" → Security + Data → `deep`
49
+
50
+ ## What to write in the task file
51
+
52
+ ```markdown
53
+ ## Complexity
54
+
55
+ **Tier:** standard
56
+ **Signals:** Data (adds `exported_at` migration), Spread (api + web)
57
+ ```
@@ -29,10 +29,12 @@ graph TD
29
29
 
30
30
  ## Batches
31
31
 
32
- | Batch | Tasks | Description |
33
- |------|-------|-------------|
34
- | 1 | task-01, task-02 | {Brief description of what this batch accomplishes} |
35
- | 2 | task-03 | {Brief description} |
32
+ | Batch | Tasks | Tiers | Description |
33
+ |------|-------|-------|-------------|
34
+ | 1 | task-01, task-02 | light, standard | {Brief description of what this batch accomplishes} |
35
+ | 2 | task-03 | deep | {Brief description} |
36
+
37
+ **Model plan:** {n} light · {n} standard · {n} deep. Deep tasks: {task-03 — reason}.
36
38
 
37
39
  ## Task Status
38
40
 
@@ -15,6 +15,11 @@ pending
15
15
 
16
16
  {N}
17
17
 
18
+ ## Complexity
19
+
20
+ **Tier:** {light | standard | deep}
21
+ **Signals:** {which rubric signals applied, with the evidence — or "none"}
22
+
18
23
  ## Description
19
24
 
20
25
  {2-5 sentences describing what this task accomplishes and why it matters in the context of the overall feature. Include enough context that an agent unfamiliar with the planning conversation understands the purpose.}
@@ -82,5 +87,6 @@ pending
82
87
  - The **Dependencies** section includes prose context (not just filenames) so the agent understands its inputs without reading other files. This is the most important part of making task files self-contained.
83
88
  - **Files to Create** and **Files to Modify** make the task's scope explicit. Tasks in the same batch should not overlap on these lists.
84
89
  - **Technical Details** captures everything from the planning conversation — CLI commands, schemas, code snippets, file paths, env vars, API endpoints. If it was discussed during planning and is relevant to this task, it belongs here.
90
+ - **Complexity** comes from `complexity-rubric.md`. `spec-implement` picks the coder's model from this tier, so rate honestly: under-rating a risky task is the expensive mistake.
85
91
  - **Tests** names the tests the coder writes alongside the code. A behavioural acceptance criterion without a matching test is a gap — add the test or explain why it can only be checked by `spec-verify`.
86
92
  - **Acceptance Criteria** should be specific enough that completion can be verified objectively (by a code review agent or a human).
@@ -108,6 +108,25 @@ Read `references/coder-prompt-template.md` and construct each agent's prompt by
108
108
 
109
109
  The coder agents should NOT commit their changes — the orchestrator handles commits after review.
110
110
 
111
+ **Pick each coder's model from the task's tier.** Read the task's `## Complexity` tier (treat a
112
+ missing tier as `standard`) and look up the model in `.spec-driven.yml` → `models.<tool>.<tier>`,
113
+ where `<tool>` is the host you're running in (`claude`, `codex`, `cursor`, `antigravity`). Without a
114
+ `models` section, use `models.claude` defaults: `light → haiku`, `standard → sonnet`,
115
+ `deep → opus`.
116
+
117
+ - **Host can set a model per agent** (e.g. Claude Code's Agent tool `model` parameter): dispatch
118
+ each coder with its tier's model.
119
+ - **Host can't** (one model per session, as in Cursor or Codex): don't silently run deep tasks on a
120
+ light model. Before dispatching a batch, compare its highest tier with the model you're running
121
+ on. If the batch needs a stronger one, stop and say:
122
+ `Batch {N} has deep tasks ({task-nn: reason}). Switch to {models.<tool>.deep} for this batch, then
123
+ say "continue".` If the batch only has light tasks and you're on the deep model, mention that a
124
+ cheaper model would do, but carry on.
125
+ - If the mapped value is `default`, use whatever model the session already runs, without
126
+ recommending a switch.
127
+
128
+ Report the model used for each task in the batch summary.
129
+
111
130
  **Isolate parallel coders in git worktrees when the host allows it.** Coders sharing one working
112
131
  tree can clobber each other's edits and each other's lint/test runs. If your host can run an agent
113
132
  in its own worktree (e.g. Claude Code's `isolation: "worktree"`), use it for every coder in a
@@ -143,6 +162,9 @@ Read `references/review-prompt-template.md` and construct the review prompt by f
143
162
  - **{task_summaries}**: for each task in this batch, the task title and the coder agent's completion summary
144
163
  - **{verification_commands}**: `commands.lint`/`typecheck`/`test` from `.spec-driven.yml` for each repo or package touched, or the project's own commands if there's no config (e.g., `pnpm lint && pnpm typecheck && pnpm test`)
145
164
 
165
+ Run the reviewer on the model for the **highest tier in the batch**, and never below `standard`: the
166
+ review is where deep mistakes get caught.
167
+
146
168
  The review agent should:
147
169
  1. Run lint, typecheck, and tests
148
170
  2. Verify acceptance criteria and the tests named in each task file
@@ -160,6 +182,9 @@ If the review returns **FAIL**:
160
182
  - **{issues}**: the specific issues for this task group
161
183
  - **{task_content}**: the original task file for context
162
184
  4. After fix agents complete, re-run the review (Step 6)
185
+ 5. **Escalate on repeat failure.** If a task's issues survive a fix cycle, run its next fix one tier
186
+ higher (`light → standard → deep`). Note the escalation in the batch report. When a task needed
187
+ escalating, its tier was probably wrong, which is useful feedback for future specs.
163
188
 
164
189
  **Cap at 3 review cycles per batch.** If the third review still fails, stop and report to the user:
165
190
 
@@ -288,6 +288,11 @@ gitignores `specs/`, matching whatever this repo already does there). Use the st
288
288
  Every section should be specific and evidence-backed, or explicitly say "None found" /
289
289
  "Not applicable to this ticket" rather than being left looking like an oversight.
290
290
 
291
+ **Complexity Estimate** uses the same signals and tiers `spec-create` will apply per task: spread,
292
+ new design, data, concurrency, security, public contract, ambiguity, hard to verify. Security plus
293
+ data, or security plus concurrency, means `deep`. Say which model is worth planning this on, since a
294
+ deep ticket deserves the strongest model for `spec-create` too.
295
+
291
296
  **Recommended Implementation Direction** describes the technically-supported direction the
292
297
  evidence points to — reusing existing patterns the investigation actually found, not a novel
293
298
  architecture invented on the spot. It is not permission to start building; flag any business
@@ -48,6 +48,11 @@ Copy this structure into `specs/{feature-slug}/investigation.md`.
48
48
  ### Proposed Solutions Discussed
49
49
  ### Open Questions
50
50
  ### Conflicts / Discrepancies
51
+ ## Complexity Estimate
52
+ - Overall tier: (light / standard / deep): the tier of the hardest part of the work
53
+ - Signals: (which of spread, new design, data, concurrency, security, public contract, ambiguity, hard to verify apply, each with its evidence)
54
+ - Suggested model for planning this: (the `deep` model if the tier is deep; otherwise the session's model is fine)
55
+
51
56
  ## Recommended Implementation Direction
52
57
  ## References
53
58
  ```
@@ -69,7 +69,10 @@ Read, don't guess. Collect a draft of every field in `references/config-template
69
69
  steps (CI is often the most truthful source). Dev URL from the dev script, `.env.example`, or
70
70
  framework defaults.
71
71
 
72
- 6. **After approval.** Ask whether an approved spec should stop after each stage (`stop`), or
72
+ 6. **Models per tier.** Write the `models:` block from the template. Ask which models the user
73
+ has for each tool they installed, only if they want to tune it: the Claude defaults (haiku /
74
+ sonnet / opus) work as-is, and `default` is safe for the rest.
75
+ 7. **After approval.** Ask whether an approved spec should stop after each stage (`stop`), or
73
76
  run implement → verify → checkpoint in one go (`run-through`), stopping at any failure and never
74
77
  pushing. Store it as `pipeline.after_approval`.
75
78
 
@@ -42,6 +42,28 @@ commands:
42
42
  dev: "" # how spec-verify starts the app
43
43
  dev_url: "" # where it answers, e.g. http://localhost:3000
44
44
 
45
+ # Which model runs each task tier (spec-create rates every task light / standard / deep).
46
+ # Values are whatever your tool accepts; "default" means "whatever model the session is using".
47
+ # Hosts that can pick a model per agent (Claude Code) switch automatically; others (Cursor, Codex)
48
+ # tell you which model to switch to before a heavier batch.
49
+ models:
50
+ claude:
51
+ light: haiku
52
+ standard: sonnet
53
+ deep: opus
54
+ cursor:
55
+ light: default
56
+ standard: default
57
+ deep: default
58
+ codex:
59
+ light: default
60
+ standard: default
61
+ deep: default
62
+ antigravity:
63
+ light: default
64
+ standard: default
65
+ deep: default
66
+
45
67
  pipeline:
46
68
  # stop — every stage waits for you (default)
47
69
  # run-through — after you approve a spec: implement → verify → checkpoint in one go,