@benjaminthomas/spec-driven-dev 1.14.2 → 1.15.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
@@ -294,6 +294,20 @@ Off means every skill ignores risk completely.
294
294
  hypothesis: change them under `risk.weights` in `.spec-driven.yml` once you've backtested them
295
295
  against your own past bugs.
296
296
 
297
+ **Keep the hotspots current on every merge (GitHub Actions).** With a dedicated specs repo on GitHub:
298
+
299
+ ```bash
300
+ spec-driven-dev ci --notify
301
+ ```
302
+
303
+ This writes `.github/workflows/gitrisk.yml` into the specs repo, plus, with `--notify`, a small
304
+ `gitrisk-notify.yml` into each code repo on your machine. When something lands on a repo's base
305
+ branch, it pings the specs repo, which checks out every repo in `team-repos.yml` with full history,
306
+ runs `risk`, and commits the **Risk hotspots** in `CONTEXT.md` only when they changed. A date-only
307
+ change isn't committed. It also runs nightly, and on demand from the Actions tab. You add one secret,
308
+ `GITRISK_TOKEN` (read access to the code repos, write access to the specs repo), and commit the
309
+ workflow files yourself. The command prints the exact steps.
310
+
297
311
  **Does it change what the agent does?** `spec-driven-dev risk-eval --tickets=tickets.md` runs each
298
312
  ticket twice, in fresh clones with risk on and off, through investigate and create on the same
299
313
  model. It then compares what the agent did:
@@ -646,6 +660,7 @@ spec-driven-dev doctor # check .spec-driven.yml again
646
660
  spec-driven-dev status # list unfinished specs and each one's next step
647
661
  spec-driven-dev context --areas=billing # print the project memory (index, or chosen areas)
648
662
  spec-driven-dev settings # switch GitRisk, run-through and sharing from a menu
663
+ spec-driven-dev ci --notify # GitHub Actions: refresh GitRisk hotspots on every merge
649
664
  spec-driven-dev uninstall # remove the skills, plugin and cache (--all: settings too)
650
665
  spec-driven-dev risk # GitRisk: score files from git history → specs/context/risk.md
651
666
  spec-driven-dev risk-eval --tickets=tickets.md # compare agent behaviour with the risk signal on vs off
@@ -822,7 +837,7 @@ plugin/evals/ `claude plugin eval` cases
822
837
  1. Increase `version` in both `package.json` and `plugin/.claude-plugin/plugin.json` (the check fails if
823
838
  they differ).
824
839
  2. Add a `CHANGELOG.md` entry, then commit and push.
825
- 3. Tag the release and push the tag: `git tag v1.14.2 && git push origin v1.14.2`.
840
+ 3. Tag the release and push the tag: `git tag v1.15.0 && git push origin v1.15.0`.
826
841
  4. Watch the **publish** run in the repo's Actions tab. When it's green, the new version is on
827
842
  npm. Claude Code plugin users get it from the marketplace once the commit is on `main`.
828
843
 
package/bin/cli.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
  //
5
5
  // spec-driven-dev install the skills, then hand over to your AI tool ("run spec-setup")
6
6
  // spec-driven-dev setup scripted setup for CI: needs --yes or answer flags (people: say "run spec-setup")
7
+ // spec-driven-dev ci write GitHub Actions that refresh GitRisk hotspots on every merge (--notify also writes the per-repo trigger)
7
8
  // spec-driven-dev uninstall remove the skills (all tools), the Claude Code plugin and the download cache; --all also removes the settings file
8
9
  // spec-driven-dev doctor check .spec-driven.yml against the real repos (exit 1 on problems)
9
10
  // spec-driven-dev status list specs with unfinished work and each one's "Resume here" next step
@@ -40,7 +41,7 @@ const args = process.argv.slice(2);
40
41
  const flag = (n) => args.includes(`--${n}`);
41
42
  const opt = (n) => args.find((a) => a.startsWith(`--${n}=`))?.split("=")[1];
42
43
  const command = args.find((a) => !a.startsWith("-")) || "install";
43
- const YES = flag("yes") || flag("y") || !process.stdin.isTTY || ["doctor", "status", "context", "risk", "risk-eval", "setup"].includes(command) || (command === "uninstall" && flag("yes"));
44
+ const YES = flag("yes") || flag("y") || !process.stdin.isTTY || ["doctor", "status", "context", "risk", "risk-eval", "setup", "ci"].includes(command) || (command === "uninstall" && flag("yes"));
44
45
  const DIR = path.resolve(opt("dir") || process.cwd());
45
46
 
46
47
  // Colour only for a person at a terminal; pipes, CI and NO_COLOR get plain text (and the tests stay plain).
@@ -794,7 +795,10 @@ function loadConfig() {
794
795
  }
795
796
 
796
797
  function risk() {
797
- const { root, c } = loadConfig();
798
+ const loaded = loadConfig();
799
+ const { root } = loaded;
800
+ // CI has no personal .spec-driven.yml: --repos and --specs-dir describe the checkout instead.
801
+ const c = opt("repos") ? { ...loaded.c, layout: "multi-repo", repos: splitList(opt("repos")).map((p) => ({ path: p })), specs_dir: opt("specs-dir") || loaded.c.specs_dir } : loaded.c;
798
802
  const bad = weightProblems(c.risk?.weights);
799
803
  if (bad.length) throw new Error(bad.join("; "));
800
804
  const weights = normWeights(c.risk?.weights), since = opt("since") || c.risk?.since || "2 years ago";
@@ -938,6 +942,112 @@ async function uninstall() {
938
942
  console.log("Done. Reinstall any time with: npx @benjaminthomas/spec-driven-dev@latest");
939
943
  }
940
944
 
945
+ // ---------- CI: refresh GitRisk hotspots on merge ----------
946
+ function ghSlug(url) { return (String(url || "").match(/github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/) || [])[1] || ""; }
947
+
948
+ function ciFiles({ specsSlug, repos, version }) {
949
+ const checkouts = repos.map((r) => ` - uses: actions/checkout@v4
950
+ with:
951
+ repository: ${r.slug}
952
+ path: ${r.name}
953
+ fetch-depth: 0 # full history: GitRisk reads git log
954
+ token: \${{ secrets.GITRISK_TOKEN }}`).join("\n");
955
+ const specs = `# Refreshes the "Risk hotspots" block in CONTEXT.md from every team repo's git history.
956
+ # Written by \`spec-driven-dev ci\`. Re-run that command after adding a repo to team-repos.yml.
957
+ # Needs a repository secret GITRISK_TOKEN: a token that can read the code repos and write to this repo.
958
+ name: GitRisk
959
+ on:
960
+ repository_dispatch:
961
+ types: [code-merged] # sent by each code repo's gitrisk-notify workflow
962
+ schedule:
963
+ - cron: "0 3 * * *" # nightly safety net
964
+ workflow_dispatch:
965
+ permissions:
966
+ contents: write
967
+ concurrency:
968
+ group: gitrisk
969
+ cancel-in-progress: true
970
+ jobs:
971
+ score:
972
+ runs-on: ubuntu-latest
973
+ steps:
974
+ - uses: actions/checkout@v4
975
+ with:
976
+ path: specs
977
+ token: \${{ secrets.GITRISK_TOKEN }}
978
+ ${checkouts}
979
+ - uses: actions/setup-node@v4
980
+ with:
981
+ node-version: 20
982
+ - name: Score files from git history
983
+ run: npx -y @benjaminthomas/spec-driven-dev@${version} risk --dir=. --repos=${repos.map((r) => r.name).join(",")} --specs-dir=specs
984
+ - name: Commit when the hotspots changed (not for a date-only change)
985
+ working-directory: specs
986
+ run: |
987
+ git add CONTEXT.md .gitignore
988
+ if git diff --cached --quiet -I '^Updated [0-9-]+$'; then echo "Hotspots unchanged"; exit 0; fi
989
+ git -c user.name="gitrisk[bot]" -c user.email="gitrisk@users.noreply.github.com" commit -m "chore: refresh GitRisk hotspots"
990
+ git push
991
+ `;
992
+ const notify = (branch) => `# Tells the specs repo to refresh GitRisk hotspots when work lands here.
993
+ # Written by \`spec-driven-dev ci --notify\`. Needs the same GITRISK_TOKEN secret in this repo.
994
+ name: GitRisk notify
995
+ on:
996
+ push:
997
+ branches: [${branch}]
998
+ jobs:
999
+ notify:
1000
+ runs-on: ubuntu-latest
1001
+ steps:
1002
+ - run: gh api repos/${specsSlug}/dispatches -f event_type=code-merged
1003
+ env:
1004
+ GH_TOKEN: \${{ secrets.GITRISK_TOKEN }}
1005
+ `;
1006
+ return { specs, notify };
1007
+ }
1008
+
1009
+ function ci() {
1010
+ const { root, c } = loadConfig();
1011
+ if (!c.layout) throw new Error('No .spec-driven.yml found. Say "run spec-setup" in your AI tool first.');
1012
+ const specs = path.resolve(root, c.specs_dir || "specs");
1013
+ const g = specsInGit(root, c.specs_dir || "specs");
1014
+ if (!g) throw new Error(`${specs} isn't inside a git repo, so there's nowhere to run CI. Keep specs in a repo (a dedicated specs repo is best).`);
1015
+ const specsSlug = ghSlug(run("git", ["remote", "get-url", "origin"], g.top));
1016
+ if (!specsSlug) throw new Error(`The specs repo at ${g.top} has no GitHub remote yet. Push it first, e.g.: gh repo create <org>/<name> --private --source ${g.top} --push`);
1017
+ const team = parseYaml(fs.existsSync(path.join(specs, "team-repos.yml")) ? fs.readFileSync(path.join(specs, "team-repos.yml"), "utf8") : "").repos;
1018
+ const list = (Array.isArray(team) && team.length ? team : (c.repos || []).map((r) => ({ name: path.basename(String(r.path)), url: run("git", ["remote", "get-url", "origin"], path.resolve(root, expandHome(String(r.path)))) })))
1019
+ .map((r) => ({ name: r.name, slug: ghSlug(r.url) })).filter((r) => r.name && r.name !== path.basename(g.top));
1020
+ const missing = list.filter((r) => !r.slug);
1021
+ if (missing.length) throw new Error(`No GitHub URL for ${missing.map((r) => r.name).join(", ")}. Add a "url:" for each in team-repos.yml.`);
1022
+ if (!list.length) throw new Error("No code repos to score. List them in team-repos.yml or your workspace.");
1023
+ const version = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version;
1024
+ const { specs: specsYml, notify } = ciFiles({ specsSlug, repos: list, version });
1025
+ if (g.rel) throw new Error(`Specs live inside ${g.top}, a code repo. The CI step needs a dedicated specs repo, so its commits don't land in a code repo. Move specs with spec-setup, or use: spec-driven-dev risk after merges.`);
1026
+ const wf = path.join(g.top, ".github", "workflows", "gitrisk.yml");
1027
+ fs.mkdirSync(path.dirname(wf), { recursive: true });
1028
+ fs.writeFileSync(wf, specsYml);
1029
+ step(1, "GitRisk on every merge", ink.cyan);
1030
+ console.log(`✓ Wrote ${wf}\n Scores ${list.map((r) => r.name).join(", ")} nightly, on demand, and whenever a code repo pings it.`);
1031
+ const wrote = [];
1032
+ for (const r of (c.repos || [])) {
1033
+ const dir = path.resolve(root, expandHome(String(r.path)));
1034
+ if (!flag("notify") || !isRepo(dir) || real(dir) === real(g.top)) continue;
1035
+ const branch = r.git?.base_branch && r.git.base_branch !== "default" ? r.git.base_branch : c.git?.base_branch && c.git.base_branch !== "default" ? c.git.base_branch : run("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], dir).replace(/^origin\//, "") || "main";
1036
+ const f = path.join(dir, ".github", "workflows", "gitrisk-notify.yml");
1037
+ fs.mkdirSync(path.dirname(f), { recursive: true });
1038
+ fs.writeFileSync(f, notify(branch));
1039
+ wrote.push(`${path.basename(dir)} (on ${branch})`);
1040
+ }
1041
+ if (wrote.length) console.log(`✓ Wrote .github/workflows/gitrisk-notify.yml in: ${wrote.join(", ")}`);
1042
+ else console.log("• Add --notify to also write the small trigger workflow into each code repo on this machine, so a merge refreshes the hotspots straight away.");
1043
+ console.log(`
1044
+ Next:
1045
+ 1. Create a GitHub token (fine-grained): read access to ${list.map((r) => r.name).join(", ")}; contents: read & write on ${specsSlug}.
1046
+ 2. Add it as a secret named GITRISK_TOKEN in ${specsSlug}${wrote.length ? " and in each code repo" : ""}: gh secret set GITRISK_TOKEN -R ${specsSlug}
1047
+ 3. Commit and push the workflow file(s). Nothing was committed for you.
1048
+ 4. Try it: gh workflow run GitRisk -R ${specsSlug}`);
1049
+ }
1050
+
941
1051
  // Silent unless something is unfinished, so it's cheap to run at the start of a session.
942
1052
  function status() {
943
1053
  const file = findConfig(DIR);
@@ -969,6 +1079,7 @@ try {
969
1079
  else if (command === "risk") risk();
970
1080
  else if (command === "settings") await settings();
971
1081
  else if (command === "uninstall") await uninstall();
1082
+ else if (command === "ci") ci();
972
1083
  else if (command === "risk-eval") await riskEvalCmd();
973
1084
  else if (command === "install") {
974
1085
  if (HANDOFF) console.log("No interactive terminal (for example a \"!\" command in Claude Code), so the skills go to the AI tools it finds, for this project.\n");
@@ -976,7 +1087,7 @@ try {
976
1087
  if (tools.length && !flag("no-setup")) await handOver(tools);
977
1088
  } else if (SETUP_IN_AGENT) handOver([], true);
978
1089
  else if (command === "setup") await setup();
979
- else throw new Error(`Unknown command "${command}" — use install (default), setup, settings, uninstall, doctor, status, context, risk, or risk-eval`);
1090
+ else throw new Error(`Unknown command "${command}" — use install (default), setup, settings, ci, uninstall, doctor, status, context, risk, or risk-eval`);
980
1091
  if (command === "setup" && !SETUP_IN_AGENT) console.log('\nNext: in your AI tool, say "investigate <ticket>" or "create a spec".');
981
1092
  } catch (e) {
982
1093
  console.error(`✗ ${e.message}`);
package/bin/risk.mjs CHANGED
@@ -174,7 +174,7 @@ const RB = "<!-- risk:start (written by spec-driven-dev risk; don't edit) -->",
174
174
  export function riskSummaryBlock(rows, date) {
175
175
  const top = rows.filter((r) => r.tier === "critical" || r.tier === "high").slice(0, 5);
176
176
  const counts = ["critical", "high", "medium"].map((t) => `${rows.filter((r) => r.tier === t).length} ${t}`).join(" · ");
177
- return [RB, "## Risk hotspots", `GitRisk on ${date}: ${counts}. Full table: context/risk.md (regenerate: spec-driven-dev risk).`,
177
+ return [RB, "## Risk hotspots", `Updated ${date}`, `${counts}. Full table: context/risk.md (regenerate: spec-driven-dev risk).`,
178
178
  ...(top.length ? top.map((r) => `- \`${r.file}\` ${r.score} ${r.tier}`) : ["- No high or critical files."]), RE].join("\n");
179
179
  }
180
180
  export function withRiskSummary(text, block) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@benjaminthomas/spec-driven-dev",
3
- "version": "1.14.2",
3
+ "version": "1.15.0",
4
4
  "description": "Spec-driven AI development workflow (investigate → spec → implement → verify → review → ship) as Agent Skills for Claude Code, Cursor, Codex CLI and Antigravity.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticket-to-pr",
3
- "version": "1.14.2",
3
+ "version": "1.15.0",
4
4
  "description": "Parallel-batch spec-driven feature workflow (spec-setup, spec-investigate, spec-create, spec-implement, spec-verify, spec-review, spec-checkpoint, spec-ship) plus a portable SEO/GEO operations skill (generative-seo) for any codebase.",
5
5
  "author": {
6
6
  "name": "Benjamin Thomas",