@benjaminthomas/spec-driven-dev 1.8.0 → 1.11.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 (39) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +220 -63
  3. package/bin/cli.mjs +203 -19
  4. package/bin/risk-eval.mjs +152 -0
  5. package/bin/risk.mjs +137 -0
  6. package/package.json +4 -3
  7. package/plugin/.claude-plugin/icon.png +0 -0
  8. package/plugin/.claude-plugin/plugin.json +20 -0
  9. package/{skills → plugin/skills}/spec-checkpoint/SKILL.md +25 -0
  10. package/{skills → plugin/skills}/spec-create/SKILL.md +30 -1
  11. package/plugin/skills/spec-create/references/log-template.md +27 -0
  12. package/{skills → plugin/skills}/spec-create/references/readme-template.md +12 -0
  13. package/{skills → plugin/skills}/spec-create/references/requirements-template.md +12 -4
  14. package/{skills → plugin/skills}/spec-create/references/task-template.md +18 -0
  15. package/{skills → plugin/skills}/spec-implement/SKILL.md +25 -6
  16. package/{skills → plugin/skills}/spec-implement/references/review-prompt-template.md +2 -1
  17. package/{skills → plugin/skills}/spec-investigate/SKILL.md +45 -5
  18. package/{skills → plugin/skills}/spec-investigate/references/report-template.md +12 -0
  19. package/plugin/skills/spec-review/SKILL.md +135 -0
  20. package/plugin/skills/spec-review/references/report-template.md +44 -0
  21. package/{skills → plugin/skills}/spec-setup/SKILL.md +54 -9
  22. package/plugin/skills/spec-setup/references/audit.md +71 -0
  23. package/{skills → plugin/skills}/spec-setup/references/config-template.yml +19 -2
  24. package/plugin/skills/spec-setup/references/context-template.md +83 -0
  25. package/{skills → plugin/skills}/spec-ship/SKILL.md +10 -0
  26. package/{skills → plugin/skills}/spec-verify/SKILL.md +10 -2
  27. /package/{skills → plugin/skills}/generative-seo/SKILL.md +0 -0
  28. /package/{skills → plugin/skills}/generative-seo/assets/citation-log-template.md +0 -0
  29. /package/{skills → plugin/skills}/generative-seo/assets/competitor-analysis-template.md +0 -0
  30. /package/{skills → plugin/skills}/generative-seo/assets/content-plan-template.md +0 -0
  31. /package/{skills → plugin/skills}/generative-seo/assets/geo-config-template.md +0 -0
  32. /package/{skills → plugin/skills}/generative-seo/assets/geo-playbook-template.md +0 -0
  33. /package/{skills → plugin/skills}/generative-seo/assets/social-queue-template.md +0 -0
  34. /package/{skills → plugin/skills}/generative-seo/references/geo-methodology.md +0 -0
  35. /package/{skills → plugin/skills}/spec-create/references/action-required-template.md +0 -0
  36. /package/{skills → plugin/skills}/spec-create/references/complexity-rubric.md +0 -0
  37. /package/{skills → plugin/skills}/spec-implement/references/coder-prompt-template.md +0 -0
  38. /package/{skills → plugin/skills}/spec-implement/references/fix-prompt-template.md +0 -0
  39. /package/{skills → plugin/skills}/spec-verify/references/report-template.md +0 -0
package/bin/cli.mjs CHANGED
@@ -5,19 +5,24 @@
5
5
  // spec-driven-dev install + project setup (interactive)
6
6
  // spec-driven-dev setup project setup only
7
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)
8
+ // spec-driven-dev status list specs with unfinished work and each one's "Resume here" next step
9
+ // spec-driven-dev context print specs/CONTEXT.md; --areas=billing,api prints only those area files
10
+ // spec-driven-dev risk GitRisk: score files from git history into specs/context/risk.md (--files=a,b --json --top=N --since=…)
11
+ // spec-driven-dev settings switch features on or off from a menu (GitRisk, run-through); or --risk=on|off --after-approval=…
12
+ // spec-driven-dev risk-eval U8 experiment: run tickets with risk on vs off and compare agent behaviour (--tickets=<file> --runs=N --model=… --arms=on,off --timeout=20)
9
13
  // flags: --yes --global --tools=claude,cursor --skills=spec-create,... --no-setup --dir=<path>
10
14
  // --layout=single|monorepo|multi-repo --repos=~/Projects/frontend,/srv/backend --workspace=<config dir>
11
- // --packages=apps/web,packages/ui --after-approval=stop|run-through --version
15
+ // --packages=apps/web,packages/ui --after-approval=stop|run-through --project=new|existing --risk=on|off --version
12
16
  import fs from "node:fs";
13
17
  import os from "node:os";
14
18
  import path from "node:path";
15
19
  import { execFileSync } from "node:child_process";
16
20
  import prompts from "prompts"; // arrow-key menus that work in PowerShell, cmd, macOS and Linux terminals
17
21
  import { fileURLToPath } from "node:url";
22
+ import { scoreRepo, riskMarkdown, normWeights, weightProblems, setConfigValue } from "./risk.mjs";
18
23
 
19
24
  const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
20
- const SKILLS_DIR = path.join(ROOT, "skills");
25
+ const SKILLS_DIR = path.join(ROOT, "plugin", "skills");
21
26
  const HOME = process.env.SPEC_DRIVEN_HOME || os.homedir(); // overridable for tests
22
27
  const ALL_SKILLS = fs.readdirSync(SKILLS_DIR).filter((d) => fs.existsSync(path.join(SKILLS_DIR, d, "SKILL.md")));
23
28
 
@@ -33,7 +38,7 @@ const args = process.argv.slice(2);
33
38
  const flag = (n) => args.includes(`--${n}`);
34
39
  const opt = (n) => args.find((a) => a.startsWith(`--${n}=`))?.split("=")[1];
35
40
  const command = args.find((a) => !a.startsWith("-")) || "install";
36
- const YES = flag("yes") || flag("y") || !process.stdin.isTTY || ["doctor", "status"].includes(command);
41
+ const YES = flag("yes") || flag("y") || !process.stdin.isTTY || ["doctor", "status", "context", "risk", "risk-eval"].includes(command);
37
42
  const DIR = path.resolve(opt("dir") || process.cwd());
38
43
 
39
44
  if (flag("version") || flag("v")) {
@@ -42,22 +47,23 @@ if (flag("version") || flag("v")) {
42
47
  }
43
48
  // No terminal to answer in (e.g. `!` in Claude Code, an IDE task, a pipe) and nothing passed explicitly:
44
49
  // stop rather than write a config from guesses.
45
- const ANSWER_FLAGS = ["yes", "y", "layout", "repos", "packages", "tools", "workspace", "after-approval", "skills", "global", "no-setup"];
46
- if (["install", "setup"].includes(command) && !process.stdin.isTTY && !args.some((a) => ANSWER_FLAGS.some((f) => a === `--${f}` || a.startsWith(`--${f}=`)))) {
47
- console.log(`✗ No interactive terminal, so setup can't ask its questions. Nothing was changed.
50
+ const ANSWER_FLAGS = ["risk", "yes", "y", "layout", "repos", "packages", "tools", "workspace", "after-approval", "skills", "global", "no-setup", "project"];
51
+ if (["install", "setup", "settings"].includes(command) && !process.stdin.isTTY && !args.some((a) => ANSWER_FLAGS.some((f) => a === `--${f}` || a.startsWith(`--${f}=`)))) {
52
+ console.log(`✗ No interactive terminal, so ${command === "settings" ? "the settings menu can't open" : "setup can't ask its questions"}. Nothing was changed.
48
53
 
49
54
  This happens when it runs through another program: a "!" command in Claude Code, an editor task,
50
55
  or piped input. Either:
51
56
  • run it yourself in a terminal window (Terminal, iTerm, or your editor's terminal panel):
52
- npx @benjaminthomas/spec-driven-dev@latest ${command === "setup" ? "setup" : ""}
57
+ npx @benjaminthomas/spec-driven-dev@latest ${command === "install" ? "" : command}
53
58
  • or give the answers as flags, for example:
54
- npx @benjaminthomas/spec-driven-dev@latest setup --layout=multi-repo --repos=~/Projects/frontend,~/Projects/backend
59
+ npx @benjaminthomas/spec-driven-dev@latest ${command === "settings" ? "settings --risk=off" : "setup --layout=multi-repo --repos=~/Projects/frontend,~/Projects/backend"}
55
60
  (add --yes to accept everything it detects)`);
56
61
  process.exit(1);
57
62
  }
58
63
 
59
64
  if (flag("help") || flag("h")) {
60
- console.log(fs.readFileSync(fileURLToPath(import.meta.url), "utf8").split("\n").slice(1, 11).join("\n").replace(/^\/\/ ?/gm, ""));
65
+ const lines = fs.readFileSync(fileURLToPath(import.meta.url), "utf8").split("\n").slice(1);
66
+ console.log(lines.slice(0, lines.findIndex((l) => !l.startsWith("//"))).join("\n").replace(/^\/\/ ?/gm, "")); // the whole header comment
61
67
  process.exit(0);
62
68
  }
63
69
 
@@ -109,7 +115,7 @@ async function install() {
109
115
 
110
116
  // Claude Code users who already have the plugin would get every skill twice.
111
117
  const plugins = path.join(HOME, ".claude/plugins/installed_plugins.json");
112
- if (tools.includes("claude") && !opt("tools") && fs.existsSync(plugins) && fs.readFileSync(plugins, "utf8").includes('"spec-driven-dev@')) {
118
+ if (tools.includes("claude") && !opt("tools") && fs.existsSync(plugins) && /"(spec-driven-dev|ticket-to-pr)@/.test(fs.readFileSync(plugins, "utf8"))) {
113
119
  console.log("• Claude Code already has the spec-driven-dev plugin, so it's skipped here (update it with /plugin).");
114
120
  tools = tools.filter((t) => t !== "claude");
115
121
  }
@@ -239,7 +245,9 @@ function toYaml(c) {
239
245
  .map(([tool, m]) => ` ${tool}:\n` + MODEL_KEYS.map((t) => ` ${t}: ${m[t]}\n`).join("")).join("");
240
246
  out += "effort: # effort / reasoning level per tier (Claude Code, Codex): low | medium | high | xhigh | max | default\n"
241
247
  + MODEL_KEYS.map((t) => ` ${t}: ${EFFORT_DEFAULTS[t]}\n`).join("");
242
- out += `pipeline:\n after_approval: ${c.afterApproval || "stop"} # stop | run-through (implement → verify → checkpoint; ship always asks)\n`;
248
+ out += `context: # project memory: {specs_dir}/CONTEXT.md (index) + context/<area>.md\n project: ${c.project || "existing"} # existing | new\n graph: auto # auto = use graphify if installed | off\n`;
249
+ out += "risk: # GitRisk: historical risk per file from git (spec-driven-dev risk). Weights are a hypothesis to calibrate\n enabled: on # on | off (off = skills ignore risk; risk-eval compares the two)\n since: \"2 years ago\"\n weights:\n churn: 0.30\n bug_density: 0.35\n fan_out: 0.20\n security: 0.15\n";
250
+ out += `pipeline:\n after_approval: ${c.afterApproval || "stop"} # stop | run-through (implement → verify → review → checkpoint; ship always asks)\n`;
243
251
  return out + `specs_dir: ${y(c.specsDir)}\n`;
244
252
  }
245
253
 
@@ -381,7 +389,16 @@ async function choosePackages(d) {
381
389
  return { root, repos: pkgs };
382
390
  }
383
391
 
392
+ // shortcut: counts only (no model here), so a big template repo with 1 commit reads as existing; the spec-setup skill confirms.
393
+ function detectProject(dirs) {
394
+ const n = (d, a) => Number(run("git", a, d).split("\n").filter(Boolean).length);
395
+ const commits = dirs.reduce((t, d) => t + (Number(run("git", ["rev-list", "--count", "HEAD"], d)) || 0), 0);
396
+ const files = dirs.reduce((t, d) => t + n(d, ["ls-files"]), 0);
397
+ return { commits, files, kind: commits >= 20 || files >= 50 ? "existing" : "new" };
398
+ }
399
+
384
400
  async function setup() {
401
+ if (opt("project") && !["new", "existing"].includes(opt("project"))) throw new Error("--project must be new or existing");
385
402
  if (opt("after-approval") && !["stop", "run-through"].includes(opt("after-approval"))) throw new Error("--after-approval must be stop or run-through");
386
403
  console.log("\nProject setup — detecting how this project works…");
387
404
  const d = detectLayout(DIR);
@@ -439,11 +456,17 @@ async function setup() {
439
456
  const specsDir = await askText("Folder for specs (one sub-folder per feature)", "specs");
440
457
  const afterApproval = opt("after-approval") || await askSelect("After you approve a spec…", [
441
458
  { title: "Stop after each stage", value: "stop", description: "you start implement, verify and checkpoint yourself" },
442
- { title: "Run straight through", value: "run-through", description: "implement → verify → commit in one go; stops at any failure, never pushes without asking" },
459
+ { title: "Run straight through", value: "run-through", description: "implement → verify → review → commit in one go; stops at any failure or review blocker, never pushes without asking" },
443
460
  ], "stop");
444
461
 
462
+ const pd = detectProject(repoDirs.filter(isRepo));
463
+ const project = opt("project") || await askSelect("Is this a new project or an existing one?", [
464
+ { title: `Existing project${pd.kind === "existing" ? " (detected)" : ""}`, value: "existing", description: `${pd.commits} commits, ${pd.files} files — your AI tool audits it once and saves what it learns` },
465
+ { title: `New project${pd.kind === "new" ? " (detected)" : ""}`, value: "new", description: "nothing to audit — your AI tool asks about the stack and conventions you want" },
466
+ ], pd.kind);
467
+
445
468
  const config = {
446
- layout, tracker: { type: trackerType, key, url }, specsDir, devUrl: "", afterApproval,
469
+ project, layout, tracker: { type: trackerType, key, url }, specsDir, devUrl: "", afterApproval,
447
470
  git: { remote: "origin", base_branch: base, pr_target: pr, branch_name: branchName, commit_style: commitStyle },
448
471
  commands: layout === "single" ? commandsFor(repoDirs[0]) : commandsFor(root),
449
472
  repos: repos.map((r) => {
@@ -456,7 +479,9 @@ async function setup() {
456
479
  if (layout === "multi-repo") register(real(file), repos.map((r) => real(path.resolve(root, r))));
457
480
  console.log(`✓ Wrote ${file}`);
458
481
  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.");
459
- console.log(' Fill in repo roles, dev_url, and non-Node commands by asking your AI tool to "run spec-setup".');
482
+ console.log(project === "existing"
483
+ ? ` Next: ask your AI tool to "run spec-setup". It audits the ${layout === "multi-repo" ? "repos" : "repo"}, asks a few questions, and saves ${specsDir}/CONTEXT.md.`
484
+ : ` Next: ask your AI tool to "run spec-setup". It asks about your stack and conventions and saves ${specsDir}/CONTEXT.md.`);
460
485
  }
461
486
 
462
487
  // ---------- reading the config ----------
@@ -496,10 +521,20 @@ function parseYaml(text) {
496
521
  return root;
497
522
  }
498
523
 
524
+ // A multi-repo config applies only in its own folder and inside the repos it lists, so a repo that
525
+ // merely sits under that folder (e.g. ~/Projects/other-project) doesn't pick up the workspace's rules.
526
+ function configApplies(f, dir) {
527
+ let c;
528
+ try { c = parseYaml(fs.readFileSync(f, "utf8")); } catch { return true; }
529
+ if (c.layout !== "multi-repo" || !Array.isArray(c.repos)) return true;
530
+ const here = real(dir), base = path.dirname(f);
531
+ return here === real(base) || c.repos.some((r) => { const p = real(path.resolve(base, expandHome(String(r.path || "")))); return here === p || here.startsWith(p + path.sep); });
532
+ }
533
+
499
534
  function findConfig(dir) {
500
535
  for (let d = dir; ; d = path.dirname(d)) {
501
536
  const f = path.join(d, ".spec-driven.yml");
502
- if (fs.existsSync(f)) return f;
537
+ if (fs.existsSync(f) && configApplies(f, dir)) return f;
503
538
  if (d === path.dirname(d) || d === HOME) break;
504
539
  }
505
540
  // Not above us: a multi-repo workspace elsewhere may list this repo.
@@ -554,6 +589,15 @@ function doctor() {
554
589
  }
555
590
  const specs = path.join(root, c.specs_dir || "specs");
556
591
  if (!fs.existsSync(specs)) notes.push(`${c.specs_dir || "specs"}/ doesn't exist yet — created on first spec`);
592
+ if (c.context?.project && !["new", "existing"].includes(c.context.project)) problems.push(`context.project "${c.context.project}" must be new or existing`);
593
+ if (c.context?.graph && !["auto", "off"].includes(c.context.graph)) problems.push(`context.graph "${c.context.graph}" must be auto or off`);
594
+ const repoRoots = c.layout === "multi-repo" ? (c.repos || []).map((r) => path.resolve(root, expandHome(r.path))) : [root];
595
+ checkContext(specs, c.specs_dir || "specs", repoRoots, problems, notes);
596
+ if (c.risk) {
597
+ if (c.risk.enabled && !["on", "off"].includes(c.risk.enabled)) problems.push(`risk.enabled "${c.risk.enabled}" must be on or off`);
598
+ problems.push(...weightProblems(c.risk.weights));
599
+ if (c.risk.enabled !== "off" && !fs.existsSync(path.join(specs, "context", "risk.md"))) notes.push(`risk is on but ${c.specs_dir || "specs"}/context/risk.md doesn't exist — run: spec-driven-dev risk`);
600
+ }
557
601
  console.log(`Checked ${file}`);
558
602
  for (const n of notes) console.log(` • ${n}`);
559
603
  for (const p of problems) console.log(` ✗ ${p}`);
@@ -561,7 +605,140 @@ function doctor() {
561
605
  else console.log("✓ Config matches the repos.");
562
606
  }
563
607
 
564
- // Silent unless something is unfinished, so it costs no context in a session start hook.
608
+ const LIMITS = { "CONTEXT.md": 60, area: 150 };
609
+ // Context lines point at code as `path` or `path:line`; flag the ones that no longer exist anywhere in the repos.
610
+ function checkContext(specs, specsName, repoRoots, problems, notes) {
611
+ const index = path.join(specs, "CONTEXT.md");
612
+ if (!fs.existsSync(index)) { notes.push(`no ${specsName}/CONTEXT.md yet — ask your AI tool to "run spec-setup" to audit the project`); return; }
613
+ const areaDir = path.join(specs, "context");
614
+ const files = [index, ...(fs.existsSync(areaDir) ? fs.readdirSync(areaDir).filter((f) => f.endsWith(".md") && f !== "archive.md").map((f) => path.join(areaDir, f)) : [])];
615
+ for (const f of files) {
616
+ const text = fs.readFileSync(f, "utf8");
617
+ const rel = path.relative(specs, f), max = f === index ? LIMITS["CONTEXT.md"] : LIMITS.area, lines = text.split("\n").length;
618
+ if (lines > max) problems.push(`${specsName}/${rel} is ${lines} lines (limit ${max}) — merge duplicates or move old entries to context/archive.md`);
619
+ for (const [, p] of text.matchAll(/`([\w.@-]+(?:\/[\w.@-]+)+\/?)(?::\d+)?`/g)) {
620
+ if (p.startsWith("context/") || /^(?:https?|www|@)/.test(p)) continue; // @scope/pkg is a package name, not a path
621
+ if (!repoRoots.some((r) => fs.existsSync(path.join(r, p)))) notes.push(`${specsName}/${rel} mentions \`${p}\`, which no longer exists — stale context?`);
622
+ }
623
+ }
624
+ }
625
+
626
+ function context() {
627
+ const file = findConfig(DIR);
628
+ const root = file ? path.dirname(file) : DIR;
629
+ const specsName = (file && parseYaml(fs.readFileSync(file, "utf8")).specs_dir) || "specs";
630
+ const index = path.join(root, specsName, "CONTEXT.md");
631
+ const areas = splitList(opt("areas"));
632
+ if (!areas.length) {
633
+ if (!fs.existsSync(index)) { console.log(`No ${specsName}/CONTEXT.md yet. Ask your AI tool to "run spec-setup" to create it.`); return; }
634
+ process.stdout.write(fs.readFileSync(index, "utf8"));
635
+ return;
636
+ }
637
+ for (const a of areas) {
638
+ const f = path.join(root, specsName, "context", `${a.replace(/[^\w-]/g, "")}.md`);
639
+ if (fs.existsSync(f)) process.stdout.write(fs.readFileSync(f, "utf8").replace(/\n*$/, "\n\n"));
640
+ else console.log(`(no ${specsName}/context/${a}.md — nothing recorded for this area yet)\n`);
641
+ }
642
+ }
643
+
644
+ function loadConfig() {
645
+ const file = findConfig(DIR);
646
+ return { file, root: file ? path.dirname(file) : gitTop(DIR), c: file ? parseYaml(fs.readFileSync(file, "utf8")) : {} };
647
+ }
648
+
649
+ function risk() {
650
+ const { root, c } = loadConfig();
651
+ const bad = weightProblems(c.risk?.weights);
652
+ if (bad.length) throw new Error(bad.join("; "));
653
+ const weights = normWeights(c.risk?.weights), since = opt("since") || c.risk?.since || "2 years ago";
654
+ const repoDirs = c.layout === "multi-repo" ? (c.repos || []).map((r) => ({ dir: path.resolve(root, expandHome(r.path)), name: r.path })) : [{ dir: root, name: path.basename(root) }];
655
+ const scored = repoDirs.filter((r) => isRepo(r.dir)).map((r) => ({ ...r, ...scoreRepo(r.dir, { since, weights, prefix: c.layout === "multi-repo" ? r.name : "" }) }));
656
+ if (!scored.length) throw new Error(`No git repo found at ${root}`);
657
+ const rows = scored.flatMap((s) => s.rows).sort((a, b) => b.score - a.score || b.commits - a.commits);
658
+ const wanted = splitList(opt("files"));
659
+ if (wanted.length) {
660
+ const norm = (f) => f.replace(/\\/g, "/").replace(/^\.\//, "");
661
+ const hits = wanted.map((f) => rows.find((r) => r.file === norm(f) || r.file.endsWith("/" + norm(f))) || { file: norm(f), score: null, tier: "no history" });
662
+ if (flag("json")) console.log(JSON.stringify(hits, null, 2));
663
+ else for (const h of hits) console.log(h.score === null ? `${h.file}: no history (new, untracked or not code)` : `${h.file}: ${h.score} ${h.tier} (churn ${h.factors.churn}, bug density ${h.factors.bug_density}, fan-out ${h.factors.fan_out}, security ${h.factors.security})`);
664
+ return;
665
+ }
666
+ if (flag("json")) { console.log(JSON.stringify({ weights, since, rows }, null, 2)); return; }
667
+ const specs = path.join(root, c.specs_dir || "specs");
668
+ fs.mkdirSync(path.join(specs, "context"), { recursive: true });
669
+ const out = path.join(specs, "context", "risk.md");
670
+ fs.writeFileSync(out, riskMarkdown({ rows, repos: scored, weights, since, top: Number(opt("top")) || 40 }));
671
+ const count = (t) => rows.filter((r) => r.tier === t).length;
672
+ console.log(`✓ Scored ${rows.length} files from ${scored.reduce((t, s) => t + s.commits, 0)} commits → ${path.relative(DIR, out) || out}`);
673
+ console.log(` ${count("critical")} critical · ${count("high")} high · ${count("medium")} medium. Top 5:`);
674
+ for (const r of rows.slice(0, 5)) console.log(` ${String(r.score).padStart(5)} ${r.tier.padEnd(8)} ${r.file}`);
675
+ if (c.risk?.enabled === "off") console.log(" Note: risk.enabled is off in .spec-driven.yml, so the skills won't use this until you turn it on.");
676
+ }
677
+
678
+ async function riskEvalCmd() {
679
+ const { file, root, c } = loadConfig();
680
+ const repo = gitTop(DIR);
681
+ if (c.layout === "multi-repo" && real(repo) === real(root)) throw new Error("risk-eval runs on one repo: run it inside one of the workspace's repos");
682
+ const ticketsFile = opt("tickets");
683
+ if (!ticketsFile) throw new Error("Pass --tickets=<file> with one ticket per line");
684
+ const tickets = fs.readFileSync(path.resolve(DIR, ticketsFile), "utf8").split("\n").map((l) => l.replace(/^\s*[-*]\s+/, "").trim()).filter((l) => l && !l.startsWith("#"));
685
+ const claude = opt("claude") || "claude";
686
+ if (!opt("claude") && !onPath("claude")) throw new Error("risk-eval drives Claude Code headlessly; install it first (https://code.claude.com)");
687
+ const { riskEval } = await import("./risk-eval.mjs");
688
+ const runs = Number(opt("runs")) || 1, stages = splitList(opt("stages") || "investigate,create");
689
+ console.log(`GitRisk U8 experiment: ${tickets.length} ticket(s) × ${runs} run(s) × 2 arms (risk on / off) on ${root}`);
690
+ // A workspace config's repo paths don't resolve inside a lone clone, so a multi-repo member gets a single-repo config.
691
+ const configText = c.layout === "multi-repo" ? "version: 1\nlayout: single\n" : file ? fs.readFileSync(file, "utf8") : "";
692
+ const arms = splitList(opt("arms") || "on,off");
693
+ if (!arms.length || arms.some((a) => !["on", "off"].includes(a))) throw new Error("--arms must be on, off, or on,off");
694
+ const { dir } = await riskEval({ repo, tickets, arms, timeoutMin: Number(opt("timeout")) || 20, runs, model: opt("model") || "sonnet", stages, claude, out: path.resolve(DIR, opt("out") || "experiments"),
695
+ pluginDir: path.join(ROOT, "plugin"), since: c.risk?.since || "2 years ago", weights: c.risk?.weights, configText });
696
+ console.log(`✓ Report: ${path.join(dir, "report.md")}`);
697
+ }
698
+
699
+ // Plain-language switches, so anyone can change them without editing YAML.
700
+ const SETTINGS = [
701
+ { block: "risk", key: "enabled", flag: "risk", name: "GitRisk", about: "extra care on files with a history of bugs",
702
+ get: (c) => c.risk?.enabled || "on", options: [
703
+ { value: "on", title: "On", description: "investigations, plans and reviews look harder at historically risky files" },
704
+ { value: "off", title: "Off", description: "the skills ignore risk completely" }] },
705
+ { block: "pipeline", key: "after_approval", flag: "after-approval", name: "After you approve a spec", about: "how far it goes on its own",
706
+ get: (c) => c.pipeline?.after_approval || "stop", options: [
707
+ { value: "stop", title: "Stop after each stage", description: "you start implement, verify, review and commit yourself" },
708
+ { value: "run-through", title: "Run straight through", description: "implement → verify → review → commit; stops at any failure, never pushes" }] },
709
+ ];
710
+
711
+ async function settings() {
712
+ const file = findConfig(DIR);
713
+ if (!file) throw new Error(`No .spec-driven.yml in ${DIR} or above. Run: spec-driven-dev setup`);
714
+ let text = fs.readFileSync(file, "utf8");
715
+ const label = (s, v) => s.options.find((o) => o.value === v)?.title || v;
716
+ const save = (s, v) => {
717
+ if (!s.options.some((o) => o.value === v)) throw new Error(`--${s.flag} must be ${s.options.map((o) => o.value).join(" or ")}`);
718
+ text = setConfigValue(text, s.block, s.key, v);
719
+ fs.writeFileSync(file, text);
720
+ console.log(`✓ ${s.name}: ${label(s, v)} (saved in ${file})`);
721
+ };
722
+ const given = SETTINGS.filter((s) => opt(s.flag));
723
+ if (given.length) given.forEach((s) => save(s, opt(s.flag)));
724
+ else for (;;) {
725
+ const c = parseYaml(text);
726
+ const pick = await askSelect("Settings: pick one to change", [
727
+ ...SETTINGS.map((s) => ({ title: `${s.name}: ${label(s, s.get(c))}`, description: s.about, value: s })),
728
+ { title: "Done", value: null }], null);
729
+ if (!pick) break;
730
+ const v = await askSelect(`${pick.name}`, pick.options, pick.get(c));
731
+ if (v !== pick.get(c)) save(pick, v);
732
+ }
733
+ const c = parseYaml(text);
734
+ if (c.risk?.enabled !== "off" && !fs.existsSync(path.join(path.dirname(file), c.specs_dir || "specs", "context", "risk.md"))) {
735
+ if (given.length || !(await askConfirm("GitRisk is on but files haven't been scored yet. Score them now? (uses git only, takes seconds)", true)))
736
+ console.log(" GitRisk is on, but nothing is scored yet. Run: spec-driven-dev risk");
737
+ else risk();
738
+ }
739
+ }
740
+
741
+ // Silent unless something is unfinished, so it's cheap to run at the start of a session.
565
742
  function status() {
566
743
  const file = findConfig(DIR);
567
744
  const root = file ? path.dirname(file) : DIR;
@@ -575,9 +752,12 @@ function status() {
575
752
  continue;
576
753
  }
577
754
  const t = fs.readFileSync(readme, "utf8");
755
+ const next = t.match(/^## Resume here[\s\S]*?^- \*\*Next:\*\*\s*(.+)$/m)?.[1]?.trim();
578
756
  const done = (t.match(/^\s*- \[x\]/gim) || []).length, todo = (t.match(/^\s*- \[ \]/gm) || []).length;
579
- if (todo) lines.push(`- ${specsName}/${name}: ${done}/${done + todo} tasks done → next: spec-implement`);
757
+ if (todo) lines.push(`- ${specsName}/${name}: ${done}/${done + todo} tasks done → next: ${next || "spec-implement"}`);
580
758
  else if (done && !fs.existsSync(path.join(specs, name, "verification-report.md"))) lines.push(`- ${specsName}/${name}: all tasks done, not verified → next: spec-verify`);
759
+ else if (done && !fs.existsSync(path.join(specs, name, "review.md"))) lines.push(`- ${specsName}/${name}: verified, not reviewed → next: spec-review`);
760
+ else if (done && /\[blocker\][^\n]*\n(?:(?!###)[^\n]*\n)*?- \*\*Status:\*\* open/.test(fs.readFileSync(path.join(specs, name, "review.md"), "utf8"))) lines.push(`- ${specsName}/${name}: review has open blockers → next: fix them (spec-review)`);
581
761
  }
582
762
  if (lines.length) console.log(`spec-driven-dev: unfinished work in ${root}\n${lines.join("\n")}`);
583
763
  }
@@ -585,11 +765,15 @@ function status() {
585
765
  try {
586
766
  if (command === "doctor") doctor();
587
767
  else if (command === "status") status();
768
+ else if (command === "context") context();
769
+ else if (command === "risk") risk();
770
+ else if (command === "settings") await settings();
771
+ else if (command === "risk-eval") await riskEvalCmd();
588
772
  else if (command === "install") {
589
773
  await install();
590
774
  if (!flag("no-setup") && (await askConfirm("Set up this project now?", true))) await setup();
591
775
  } else if (command === "setup") await setup();
592
- else throw new Error(`Unknown command "${command}" — use install (default), setup, doctor, or status`);
776
+ else throw new Error(`Unknown command "${command}" — use install (default), setup, settings, doctor, status, context, risk, or risk-eval`);
593
777
  if (["install", "setup"].includes(command)) console.log('\nNext: in your AI tool, say "investigate <ticket>" or "create a spec".');
594
778
  } catch (e) {
595
779
  console.error(`✗ ${e.message}`);
@@ -0,0 +1,152 @@
1
+ // GitRisk U8 experiment: does giving the agent the risk signal change what it does?
2
+ // Each ticket runs twice in fresh clones of the repo, risk ON and risk OFF, with the same model and
3
+ // prompt. We then measure behaviour from what the agent read and wrote, and keep a dated log.
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { execFileSync, spawnSync } from "node:child_process";
8
+ import { scoreRepo, riskMarkdown, normWeights, setConfigValue } from "./risk.mjs";
9
+
10
+ const TIER_RANK = { light: 1, standard: 2, deep: 3 };
11
+ const sh = (cwd, cmd, args) => execFileSync(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"], maxBuffer: 512 * 1024 * 1024 }).toString();
12
+
13
+ export const setRiskEnabled = (text, on) => setConfigValue(text, "risk", "enabled", on ? "on" : "off");
14
+
15
+ // Behaviour metrics from the agent's transcript (stream-json) and the spec it wrote.
16
+ export function measure({ transcript, clone, risky, tracked = [] }) {
17
+ const reads = new Set();
18
+ let cost = 0, turns = 0, inTok = 0, outTok = 0, error = "";
19
+ for (const line of transcript.split("\n")) {
20
+ let e;
21
+ try { e = JSON.parse(line); } catch { continue; }
22
+ if (e.type === "assistant") for (const c of e.message?.content || []) {
23
+ // A file counts as looked at when a Read, Grep or Bash call names it (agents often use grep/sed/cat, not Read).
24
+ if (c.type !== "tool_use" || !["Read", "Grep", "Bash", "Glob"].includes(c.name)) continue;
25
+ const text = JSON.stringify(c.input || {}).split(clone + "/").join("").split(clone.replace(/^\/private/, "") + "/").join("");
26
+ for (const f of tracked) if (text.includes(f)) reads.add(f);
27
+ }
28
+ if (e.type === "result") {
29
+ cost = e.total_cost_usd || 0; turns = e.num_turns || 0;
30
+ inTok = (e.usage?.input_tokens || 0) + (e.usage?.cache_read_input_tokens || 0) + (e.usage?.cache_creation_input_tokens || 0);
31
+ outTok = e.usage?.output_tokens || 0;
32
+ if (e.is_error) error = String(e.result || "error").slice(0, 200);
33
+ }
34
+ }
35
+ const specsDir = path.join(clone, "specs");
36
+ const feature = (fs.existsSync(specsDir) ? fs.readdirSync(specsDir) : []).find((d) => fs.existsSync(path.join(specsDir, d, "investigation.md")) || fs.existsSync(path.join(specsDir, d, "tasks")));
37
+ const read = (f) => { try { return fs.readFileSync(f, "utf8"); } catch { return ""; } };
38
+ const inv = feature ? read(path.join(specsDir, feature, "investigation.md")) : "";
39
+ const tasks = [];
40
+ const tdir = feature && path.join(specsDir, feature, "tasks");
41
+ if (tdir && fs.existsSync(tdir)) for (const t of fs.readdirSync(tdir).filter((f) => f.endsWith(".md"))) {
42
+ const text = read(path.join(tdir, t));
43
+ const section = (name) => (text.match(new RegExp(`^## ${name}\\s*\\n([\\s\\S]*?)(?=^## |$(?![\\s\\S]))`, "m")) || [])[1] || "";
44
+ const files = [...(section("Files to Create") + section("Files to Modify")).matchAll(/`([^`\s]+\.[\w]+)`/g)].map((m) => m[1]);
45
+ const tests = section("Tests").trim();
46
+ tasks.push({ name: t, files, tier: (text.match(/\*\*Tier:\*\*\s*(light|standard|deep)/i) || [])[1]?.toLowerCase(), hasTests: !!tests && !/^none\b/i.test(tests) });
47
+ }
48
+ const isRisky = (f) => risky.has(f);
49
+ const planFiles = new Set(tasks.flatMap((t) => t.files));
50
+ const riskyPlan = [...planFiles].filter(isRisky);
51
+ const riskyTasks = tasks.filter((t) => t.files.some(isRisky));
52
+ const avg = (xs) => (xs.length ? Math.round((xs.reduce((a, b) => a + b, 0) / xs.length) * 100) / 100 : null);
53
+ return {
54
+ feature: feature || null,
55
+ files_read: reads.size,
56
+ risky_files_read: [...reads].filter(isRisky).length,
57
+ investigation_risky_mentions: [...risky].filter((f) => inv.includes(f)).length,
58
+ plan_files: planFiles.size,
59
+ risky_plan_files: riskyPlan.length,
60
+ risky_files_with_tests: riskyPlan.filter((f) => tasks.some((t) => t.files.includes(f) && t.hasTests)).length,
61
+ tier_on_risky_tasks: avg(riskyTasks.map((t) => TIER_RANK[t.tier]).filter(Boolean)),
62
+ tier_all_tasks: avg(tasks.map((t) => TIER_RANK[t.tier]).filter(Boolean)),
63
+ tasks: tasks.length,
64
+ cost_usd: Math.round(cost * 10000) / 10000, turns, input_tokens: inTok, output_tokens: outTok, error,
65
+ };
66
+ }
67
+
68
+ const METRICS = [
69
+ ["risky_files_read", "Risky files looked at"], ["investigation_risky_mentions", "Risky files named in investigation"],
70
+ ["risky_plan_files", "Risky files in the plan"], ["risky_files_with_tests", "…of those, with tests planned"],
71
+ ["tier_on_risky_tasks", "Avg tier of tasks on risky files (1–3)"], ["files_read", "Repo files looked at (Read/Grep/Bash)"],
72
+ ["tasks", "Tasks"], ["cost_usd", "Cost (USD)"], ["output_tokens", "Output tokens"],
73
+ ];
74
+
75
+ function report({ meta, results }) {
76
+ const lines = [`# GitRisk U8 experiment — ${meta.started}`, "",
77
+ "Question: does feeding GitRisk's historical-risk signal into the spec-driven-dev workflow change what the AI agent does?", "",
78
+ `- Repo: \`${meta.repo}\` at ${meta.head}; risky set = ${meta.risky} files scored high or critical (weights ${JSON.stringify(meta.weights)})`,
79
+ `- Model: ${meta.model} · stages: ${meta.stages.join(" → ")} · runs per arm: ${meta.runs} · tickets: ${meta.tickets.length}`,
80
+ "- Arms: **on** = risk.enabled on + specs/context/risk.md present; **off** = risk.enabled off, no risk file. Fresh clone per run, same prompt.",
81
+ "", "## Results (mean per arm)", "", "| Ticket | Metric | Off | On | Δ (on − off) |", "|---|---|---|---|---|"];
82
+ const mean = (rs, k) => { const v = rs.map((r) => r.metrics?.[k]).filter((x) => typeof x === "number"); return v.length ? Math.round((v.reduce((a, b) => a + b, 0) / v.length) * 100) / 100 : null; };
83
+ meta.tickets.forEach((t, i) => {
84
+ const off = results.filter((r) => r.ticket === i && r.arm === "off" && !r.metrics?.error), on = results.filter((r) => r.ticket === i && r.arm === "on" && !r.metrics?.error);
85
+ for (const [k, label] of METRICS) {
86
+ const a = mean(off, k), b = mean(on, k);
87
+ lines.push(`| ${i + 1}. ${t.slice(0, 40).replace(/\|/g, "/")} | ${label} | ${a ?? "—"} | ${b ?? "—"} | ${a !== null && b !== null ? Math.round((b - a) * 100) / 100 : "—"} |`);
88
+ }
89
+ });
90
+ const failed = results.filter((r) => r.metrics?.error || r.exit !== 0);
91
+ lines.push("", "## Runs", "", "| Ticket | Arm | Run | Exit | Feature folder | Error |", "|---|---|---|---|---|---|",
92
+ ...results.map((r) => `| ${r.ticket + 1} | ${r.arm} | ${r.run} | ${r.exit} | ${r.metrics?.feature || "—"} | ${(r.metrics?.error || "").replace(/\|/g, "/")} |`));
93
+ lines.push("", "## Caveats", "",
94
+ `- ${meta.runs} run(s) per arm is a pilot, not a significance test. Agent behaviour varies run to run; increase --runs before drawing conclusions.`,
95
+ "- Metrics are proxies read from the transcript and the written spec (files the agent opened, files and tests it planned, task tiers). They don't measure whether the eventual code has fewer bugs.",
96
+ "- The risky set comes from the same repo history the ON arm sees, so a higher \"risky files read\" in ON is expected; the interesting signal is whether that changes the plan (tests, tiers) without raising cost much.",
97
+ `- Failed runs: ${failed.length}. They are excluded from the means but kept in results.json.`);
98
+ return lines.join("\n") + "\n";
99
+ }
100
+
101
+ export async function riskEval(opts) {
102
+ const { repo, tickets, runs = 1, arms = ["on", "off"], timeoutMin = 20, model = "sonnet", stages = ["investigate", "create"], claude = "claude", out, pluginDir, since, weights, configText = "" } = opts;
103
+ if (!tickets.length) throw new Error("No tickets: pass --tickets=<file> with one ticket per line");
104
+ const started = new Date().toISOString().replace(/\.\d+Z$/, "Z");
105
+ const dir = path.join(out, `risk-eval-${started.replace(/[:]/g, "-")}`);
106
+ fs.mkdirSync(dir, { recursive: true });
107
+ const w = normWeights(weights);
108
+ const scored = scoreRepo(repo, { since, weights: w });
109
+ scored.rows.sort((a, b) => b.score - a.score);
110
+ const risky = new Set(scored.rows.filter((r) => r.tier === "high" || r.tier === "critical").map((r) => r.file));
111
+ const riskMd = riskMarkdown({ rows: scored.rows, repos: [{ name: path.basename(repo), head: scored.head, commits: scored.commits }], weights: w, since, top: 60 });
112
+ fs.writeFileSync(path.join(dir, "risk.md"), riskMd);
113
+ const meta = { started, repo, head: scored.head, risky: risky.size, weights: w, model, stages, runs, tickets };
114
+ const stageText = stages.map((s) => `spec-${s}`).join(", then ");
115
+ const results = [];
116
+ for (let t = 0; t < tickets.length; t++) for (let r = 1; r <= runs; r++) for (const arm of ((t + r) % 2 ? ["off", "on"] : ["on", "off"]).filter((a) => arms.includes(a))) {
117
+ const clone = fs.mkdtempSync(path.join(os.tmpdir(), `risk-eval-${arm}-`));
118
+ sh(os.tmpdir(), "git", ["clone", "-q", "--no-hardlinks", repo, clone]);
119
+ for (const extra of ["specs/CONTEXT.md", "specs/context"]) {
120
+ const src = path.join(repo, extra);
121
+ if (fs.existsSync(src) && !fs.existsSync(path.join(clone, extra))) fs.cpSync ? fs.cpSync(src, path.join(clone, extra), { recursive: true }) : sh(repo, "cp", ["-R", src, path.join(clone, extra)]);
122
+ }
123
+ fs.rmSync(path.join(clone, "specs/context/risk.md"), { force: true });
124
+ fs.writeFileSync(path.join(clone, ".spec-driven.yml"), setRiskEnabled(configText || "version: 1\nlayout: single\n", arm === "on"));
125
+ if (arm === "on") { fs.mkdirSync(path.join(clone, "specs/context"), { recursive: true }); fs.writeFileSync(path.join(clone, "specs/context/risk.md"), riskMd); }
126
+ const prompt = `Use the ticket-to-pr plugin skills. This is a non-interactive run: don't ask questions, make reasonable assumptions and record them. Ticket (plain text, no tracker): ${tickets[t]}\nRun ${stageText}. Work on the current branch; don't create branches, commit, push, or implement anything.`;
127
+ const res = spawnSync(claude, ["-p", "--output-format", "stream-json", "--verbose", "--model", model, "--plugin-dir", pluginDir, "--permission-mode", "acceptEdits",
128
+ "--allowedTools", "Read Write Edit Glob Grep Skill Bash(git:*) Bash(ls:*) Bash(cat:*) Bash(head:*) Bash(tail:*) Bash(wc:*) Bash(find:*) Bash(grep:*) Bash(node:*) Bash(npx:*) Bash(spec-driven-dev:*)"],
129
+ { cwd: clone, input: prompt, encoding: "utf8", maxBuffer: 512 * 1024 * 1024, timeout: timeoutMin * 60 * 1000 });
130
+ const transcript = res.stdout || "";
131
+ const tag = `t${t + 1}-${arm}-r${r}`;
132
+ fs.writeFileSync(path.join(dir, `${tag}.jsonl`), transcript);
133
+ const tracked = sh(clone, "git", ["ls-files"]).split("\n").filter((f) => f.includes("/") || f.includes("."));
134
+ const metrics = measure({ transcript, clone, risky, tracked });
135
+ if (res.status !== 0 && !metrics.error) {
136
+ // Say where it stopped, so a stall (usage limit, API wait) is told apart from a skill problem.
137
+ const events = transcript.split("\n").filter(Boolean).length;
138
+ const lastTool = [...transcript.matchAll(/"type":"tool_use","id":"[^"]+","name":"(\w+)"/g)].pop()?.[1] || "none";
139
+ metrics.error = `${res.error?.code === "ETIMEDOUT" ? `timed out after ${timeoutMin} min` : `exit ${res.status}`} · ${events} events · last tool ${lastTool} ${(res.stderr || "").slice(0, 120)}`.trim();
140
+ }
141
+ if (metrics.feature) fs.cpSync ? fs.cpSync(path.join(clone, "specs", metrics.feature), path.join(dir, tag), { recursive: true }) : sh(clone, "cp", ["-R", path.join("specs", metrics.feature), path.join(dir, tag)]);
142
+ results.push({ ticket: t, arm, run: r, exit: res.status, metrics });
143
+ fs.rmSync(clone, { recursive: true, force: true });
144
+ console.log(` ${tag}: ${metrics.error ? `failed (${metrics.error.slice(0, 80)})` : `read ${metrics.risky_files_read} risky / ${metrics.files_read} files, ${metrics.risky_plan_files} risky in plan, $${metrics.cost_usd}`}`);
145
+ }
146
+ fs.writeFileSync(path.join(dir, "results.json"), JSON.stringify({ meta, results }, null, 2));
147
+ const md = report({ meta, results });
148
+ fs.writeFileSync(path.join(dir, "report.md"), md);
149
+ const failed = results.filter((x) => x.metrics.error).length;
150
+ fs.appendFileSync(path.join(out, "LOG.md"), `${fs.existsSync(path.join(out, "LOG.md")) ? "" : "# GitRisk experiment log\n\nAppend-only record of every run, including failures.\n\n"}- ${started} · ${tickets.length} ticket(s) × ${runs} run(s) × 2 arms on \`${path.basename(repo)}\` at ${scored.head}, ${model}, ${stages.join("+")} · ${results.length - failed} ok, ${failed} failed · [report](${path.basename(dir)}/report.md)\n`);
151
+ return { dir, report: md, results };
152
+ }