@benjaminthomas/spec-driven-dev 1.14.2 → 1.16.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
@@ -180,6 +180,16 @@ every team repo with its git URL, so setup offers them, `doctor` shows which are
180
180
  the clone command), and an investigation stops and says so when a ticket needs a repo that isn't on
181
181
  this machine.
182
182
 
183
+ **Joining a team that's already set up.** The first person's setup moves the team's rules (tracker,
184
+ base branch, branch and commit style, per-repo commands, models, GitRisk, sharing) into **`team.yml`
185
+ in the specs repo**, which is committed with the specs. Each person's own `.spec-driven.yml` keeps only
186
+ where their repos are on that machine and personal preferences. A colleague clones the specs repo and
187
+ the code repos they work on, then says "run spec-setup": it finds the team's setup, offers **Join**, and
188
+ asks only which of the team's repos they have. No audit, no convention questions, and nothing shared
189
+ changes. From a terminal: `spec-driven-dev setup --join=../soreto-specs`. Changing a team rule is an
190
+ edit to `team.yml` and a commit, and anything set in your own file overrides it for you only (`doctor`
191
+ lists those overrides). An existing setup can be converted with `spec-driven-dev team init`.
192
+
183
193
  **For scripts and CI** there's `spec-driven-dev setup` with flags (it asks nothing):
184
194
  `--yes`, `--layout=multi-repo --repos=~/Projects/frontend,~/Work/backend --workspace=~/Projects`,
185
195
  `--layout=monorepo --packages=apps/web,packages/ui`, `--specs-dir=<path>`, `--share=all|memory|none`,
@@ -294,6 +304,20 @@ Off means every skill ignores risk completely.
294
304
  hypothesis: change them under `risk.weights` in `.spec-driven.yml` once you've backtested them
295
305
  against your own past bugs.
296
306
 
307
+ **Keep the hotspots current on every merge (GitHub Actions).** With a dedicated specs repo on GitHub:
308
+
309
+ ```bash
310
+ spec-driven-dev ci --notify
311
+ ```
312
+
313
+ This writes `.github/workflows/gitrisk.yml` into the specs repo, plus, with `--notify`, a small
314
+ `gitrisk-notify.yml` into each code repo on your machine. When something lands on a repo's base
315
+ branch, it pings the specs repo, which checks out every repo in `team-repos.yml` with full history,
316
+ runs `risk`, and commits the **Risk hotspots** in `CONTEXT.md` only when they changed. A date-only
317
+ change isn't committed. It also runs nightly, and on demand from the Actions tab. You add one secret,
318
+ `GITRISK_TOKEN` (read access to the code repos, write access to the specs repo), and commit the
319
+ workflow files yourself. The command prints the exact steps.
320
+
297
321
  **Does it change what the agent does?** `spec-driven-dev risk-eval --tickets=tickets.md` runs each
298
322
  ticket twice, in fresh clones with risk on and off, through investigate and create on the same
299
323
  model. It then compares what the agent did:
@@ -646,6 +670,9 @@ spec-driven-dev doctor # check .spec-driven.yml again
646
670
  spec-driven-dev status # list unfinished specs and each one's next step
647
671
  spec-driven-dev context --areas=billing # print the project memory (index, or chosen areas)
648
672
  spec-driven-dev settings # switch GitRisk, run-through and sharing from a menu
673
+ spec-driven-dev team init # share the team's rules in the specs repo (team.yml)
674
+ spec-driven-dev setup --join=../team-specs # join a team that's already set up
675
+ spec-driven-dev ci --notify # GitHub Actions: refresh GitRisk hotspots on every merge
649
676
  spec-driven-dev uninstall # remove the skills, plugin and cache (--all: settings too)
650
677
  spec-driven-dev risk # GitRisk: score files from git history → specs/context/risk.md
651
678
  spec-driven-dev risk-eval --tickets=tickets.md # compare agent behaviour with the risk signal on vs off
@@ -760,16 +787,16 @@ skips that step rather than failing.
760
787
 
761
788
  | Skill | Stage | SKILL.md lines | Words | Supporting files |
762
789
  |---|---|---:|---:|---:|
763
- | `spec-setup` | 0 · configure | 273 | 2,829 | 4 |
764
- | `spec-investigate` | 1 · understand | 387 | 3,367 | 1 |
765
- | `spec-create` | 2 · plan | 198 | 1,782 | 6 |
766
- | `spec-implement` | 3 · build | 327 | 2,753 | 3 |
767
- | `spec-verify` | 4 · prove | 163 | 1,557 | 1 |
768
- | `spec-review` | 5 · review | 137 | 1,294 | 1 |
769
- | `spec-checkpoint` | 6 · commit | 140 | 1,144 | 0 |
770
- | `spec-ship` | 7 · PR | 230 | 1,388 | 0 |
790
+ | `spec-setup` | 0 · configure | 298 | 3,141 | 4 |
791
+ | `spec-investigate` | 1 · understand | 387 | 3,391 | 1 |
792
+ | `spec-create` | 2 · plan | 198 | 1,806 | 6 |
793
+ | `spec-implement` | 3 · build | 327 | 2,777 | 3 |
794
+ | `spec-verify` | 4 · prove | 163 | 1,581 | 1 |
795
+ | `spec-review` | 5 · review | 137 | 1,318 | 1 |
796
+ | `spec-checkpoint` | 6 · commit | 140 | 1,168 | 0 |
797
+ | `spec-ship` | 7 · PR | 230 | 1,412 | 0 |
771
798
  | `generative-seo` | standalone | 255 | 2,209 | 7 |
772
- | **Total** | | **2,110** | **18,323** | **23** |
799
+ | **Total** | | **2,135** | **18,803** | **23** |
773
800
 
774
801
  - **8** skills: **1** setup + **6** workflow stages + **1** standalone
775
802
  - **4** tools supported natively (Claude Code, Codex CLI, Cursor, Antigravity), plus an MCP fallback
@@ -822,7 +849,7 @@ plugin/evals/ `claude plugin eval` cases
822
849
  1. Increase `version` in both `package.json` and `plugin/.claude-plugin/plugin.json` (the check fails if
823
850
  they differ).
824
851
  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`.
852
+ 3. Tag the release and push the tag: `git tag v1.16.0 && git push origin v1.16.0`.
826
853
  4. Watch the **publish** run in the repo's Actions tab. When it's green, the new version is on
827
854
  npm. Claude Code plugin users get it from the marketplace once the commit is on `main`.
828
855
 
package/bin/cli.mjs CHANGED
@@ -4,6 +4,9 @@
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 team init move the team's shared rules into the specs repo (team.yml) so colleagues can join
8
+ // spec-driven-dev setup --join=<specs repo path or git URL> join a team that's already set up (asks nothing else)
9
+ // spec-driven-dev ci write GitHub Actions that refresh GitRisk hotspots on every merge (--notify also writes the per-repo trigger)
7
10
  // spec-driven-dev uninstall remove the skills (all tools), the Claude Code plugin and the download cache; --all also removes the settings file
8
11
  // spec-driven-dev doctor check .spec-driven.yml against the real repos (exit 1 on problems)
9
12
  // spec-driven-dev status list specs with unfinished work and each one's "Resume here" next step
@@ -22,6 +25,7 @@ import prompts from "prompts"; // arrow-key menus that work in PowerShell, cmd,
22
25
  import { fileURLToPath } from "node:url";
23
26
  import { scoreRepo, riskMarkdown, normWeights, weightProblems, setConfigValue, riskSummaryBlock, withRiskSummary } from "./risk.mjs";
24
27
  import { SHARE_MODES, shareState, planShare, shareTitle } from "./share.mjs";
28
+ import { mergeConfig, overrides, splitConfig, dumpYaml, TEAM_HEADER, PERSONAL_HEADER, teamFileFor, readIf } from "./team.mjs";
25
29
 
26
30
  const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
27
31
  const SKILLS_DIR = path.join(ROOT, "plugin", "skills");
@@ -40,7 +44,7 @@ const args = process.argv.slice(2);
40
44
  const flag = (n) => args.includes(`--${n}`);
41
45
  const opt = (n) => args.find((a) => a.startsWith(`--${n}=`))?.split("=")[1];
42
46
  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"));
47
+ const YES = flag("yes") || flag("y") || !process.stdin.isTTY || ["doctor", "status", "context", "risk", "risk-eval", "setup", "ci", "team"].includes(command) || (command === "uninstall" && flag("yes"));
44
48
  const DIR = path.resolve(opt("dir") || process.cwd());
45
49
 
46
50
  // Colour only for a person at a terminal; pipes, CI and NO_COLOR get plain text (and the tests stay plain).
@@ -64,7 +68,7 @@ if (flag("version") || flag("v")) {
64
68
  }
65
69
  // No terminal to answer in (e.g. `!` in Claude Code, an IDE task, a pipe) and nothing passed explicitly:
66
70
  // stop rather than write a config from guesses.
67
- const ANSWER_FLAGS = ["share", "specs-dir", "risk", "yes", "y", "layout", "repos", "packages", "tools", "workspace", "after-approval", "skills", "global", "no-setup", "project"];
71
+ const ANSWER_FLAGS = ["join", "share", "specs-dir", "risk", "yes", "y", "layout", "repos", "packages", "tools", "workspace", "after-approval", "skills", "global", "no-setup", "project"];
68
72
  const NO_TTY = !process.stdin.isTTY && !args.some((a) => ANSWER_FLAGS.some((f) => a === `--${f}` || a.startsWith(`--${f}=`)));
69
73
  // Installing the skills needs no answers, so the default command still does that without a terminal
70
74
  // (e.g. `!` in Claude Code) and hands the questions to the spec-setup skill, which asks them in the chat.
@@ -588,6 +592,21 @@ function configApplies(f, dir) {
588
592
  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); });
589
593
  }
590
594
 
595
+ // The effective config: the team's rules (team.yml in the specs repo) with the personal file on top.
596
+ function readConfig(file) {
597
+ const personal = parseYaml(fs.readFileSync(file, "utf8"));
598
+ const tf = teamFileFor(file, personal.specs_dir);
599
+ const team = readIf(tf);
600
+ const c = team ? { ...mergeConfig(personal, parseYaml(team)), _teamFile: tf, _personal: personal, _team: parseYaml(team) } : personal;
601
+ // Repo roles are team knowledge kept in team-repos.yml; fill them in rather than copying them into each person's file.
602
+ const listed = parseYaml(readIf(path.join(path.dirname(tf), "team-repos.yml")) || "").repos;
603
+ if (Array.isArray(listed) && Array.isArray(c.repos)) {
604
+ const roles = new Map(listed.map((r) => [r.name, r.role]));
605
+ c.repos = c.repos.map((r) => (r.role ? r : { ...r, role: roles.get(path.basename(String(r.path || "").replace(/[\\/]+$/, ""))) || r.role }));
606
+ }
607
+ return c;
608
+ }
609
+
591
610
  function findConfig(dir) {
592
611
  for (let d = dir; ; d = path.dirname(d)) {
593
612
  const f = path.join(d, ".spec-driven.yml");
@@ -607,8 +626,12 @@ function doctor() {
607
626
  if (!file) throw new Error(`No .spec-driven.yml in ${DIR} or above. In your AI tool, say "run spec-setup".`);
608
627
  const root = path.dirname(file);
609
628
  let c;
610
- try { c = parseYaml(fs.readFileSync(file, "utf8")); } catch (e) { throw new Error(`${file} could not be read: ${e.message}`); }
629
+ try { c = readConfig(file); } catch (e) { throw new Error(`${file} could not be read: ${e.message}`); }
611
630
  const problems = [], notes = [];
631
+ if (c._teamFile) {
632
+ notes.push(`team rules come from ${c._teamFile}`);
633
+ for (const o of overrides(c._personal, c._team)) notes.push(`your ${o.key} (${JSON.stringify(o.yours)}) overrides the team's (${JSON.stringify(o.team)}) for you only`);
634
+ }
612
635
  if (!["single", "monorepo", "multi-repo"].includes(c.layout)) problems.push(`layout "${c.layout}" must be single, monorepo, or multi-repo`);
613
636
  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`);
614
637
  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`);
@@ -706,7 +729,7 @@ function checkContext(specs, specsName, repoRoots, problems, notes) {
706
729
  function context() {
707
730
  const file = findConfig(DIR);
708
731
  const root = file ? path.dirname(file) : DIR;
709
- const specsName = (file && parseYaml(fs.readFileSync(file, "utf8")).specs_dir) || "specs";
732
+ const specsName = (file && readConfig(file).specs_dir) || "specs";
710
733
  const index = path.join(root, specsName, "CONTEXT.md");
711
734
  const areas = splitList(opt("areas"));
712
735
  if (!areas.length) {
@@ -790,11 +813,14 @@ function ignoreGenerated(file) {
790
813
 
791
814
  function loadConfig() {
792
815
  const file = findConfig(DIR);
793
- return { file, root: file ? path.dirname(file) : gitTop(DIR), c: file ? parseYaml(fs.readFileSync(file, "utf8")) : {} };
816
+ return { file, root: file ? path.dirname(file) : gitTop(DIR), c: file ? readConfig(file) : {} };
794
817
  }
795
818
 
796
819
  function risk() {
797
- const { root, c } = loadConfig();
820
+ const loaded = loadConfig();
821
+ const { root } = loaded;
822
+ // CI has no personal .spec-driven.yml: --repos and --specs-dir describe the checkout instead.
823
+ 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
824
  const bad = weightProblems(c.risk?.weights);
799
825
  if (bad.length) throw new Error(bad.join("; "));
800
826
  const weights = normWeights(c.risk?.weights), since = opt("since") || c.risk?.since || "2 years ago";
@@ -853,7 +879,7 @@ async function riskEvalCmd() {
853
879
 
854
880
  // Plain-language switches, so anyone can change them without editing YAML.
855
881
  const SETTINGS = [
856
- { block: "risk", key: "enabled", flag: "risk", name: "GitRisk", about: "extra care on files with a history of bugs",
882
+ { block: "risk", key: "enabled", flag: "risk", team: true, name: "GitRisk", about: "extra care on files with a history of bugs",
857
883
  get: (c) => c.risk?.enabled || "on", options: [
858
884
  { value: "on", title: "On", description: "investigations, plans and reviews look harder at historically risky files" },
859
885
  { value: "off", title: "Off", description: "the skills ignore risk completely" }] },
@@ -861,7 +887,7 @@ const SETTINGS = [
861
887
  get: (c) => c.pipeline?.after_approval || "stop", options: [
862
888
  { value: "stop", title: "Stop after each stage", description: "you start implement, verify, review and commit yourself" },
863
889
  { value: "run-through", title: "Run straight through", description: "implement → verify → review → commit; stops at any failure, never pushes" }] },
864
- { block: "context", key: "share", flag: "share", name: "Share specs with the team", about: "what's committed from the specs folder",
890
+ { block: "context", key: "share", flag: "share", team: true, name: "Share specs with the team", about: "what's committed from the specs folder",
865
891
  get: (c) => c.context?.share || "all", after: (file, c, v) => applyShare(path.dirname(file), c.specs_dir || "specs", v), options: [
866
892
  { value: "all", title: "Share everything", description: "commit plans, investigations, reviews and project memory" },
867
893
  { value: "memory", title: "Share project memory only", description: "commit CONTEXT.md and context/; keep feature files local" },
@@ -872,18 +898,22 @@ async function settings() {
872
898
  const file = findConfig(DIR);
873
899
  if (!file) throw new Error(`No .spec-driven.yml in ${DIR} or above. In your AI tool, say "run spec-setup" first.`);
874
900
  let text = fs.readFileSync(file, "utf8");
901
+ const tf = readConfig(file)._teamFile;
875
902
  const label = (s, v) => s.options.find((o) => o.value === v)?.title || v;
876
903
  const save = async (s, v) => {
877
904
  if (!s.options.some((o) => o.value === v)) throw new Error(`--${s.flag} must be ${s.options.map((o) => o.value).join(" or ")}`);
878
- text = setConfigValue(text, s.block, s.key, v);
879
- fs.writeFileSync(file, text);
880
- console.log(`✓ ${s.name}: ${label(s, v)} (saved in ${file})`);
881
- if (s.after) await s.after(file, parseYaml(text), v);
905
+ // Team rules go to the shared team.yml when there is one; the rest stays personal.
906
+ const target = s.team && tf ? tf : file;
907
+ const updated = setConfigValue(fs.readFileSync(target, "utf8"), s.block, s.key, v);
908
+ fs.writeFileSync(target, updated);
909
+ if (target === file) text = updated;
910
+ console.log(`✓ ${s.name}: ${label(s, v)} (saved in ${target}${target === tf ? ": shared with the team, commit it" : ""})`);
911
+ if (s.after) await s.after(file, readConfig(file), v);
882
912
  };
883
913
  const given = SETTINGS.filter((s) => opt(s.flag));
884
914
  if (given.length) for (const s of given) await save(s, opt(s.flag));
885
915
  else for (;;) {
886
- const c = parseYaml(text);
916
+ const c = readConfig(file);
887
917
  const pick = await askSelect("Settings: pick one to change", [
888
918
  ...SETTINGS.map((s) => ({ title: `${s.name}: ${label(s, s.get(c))}`, description: s.about, value: s })),
889
919
  { title: "Done", value: null }], null);
@@ -891,7 +921,7 @@ async function settings() {
891
921
  const v = await askSelect(`${pick.name}`, pick.options, pick.get(c));
892
922
  if (v !== pick.get(c)) await save(pick, v);
893
923
  }
894
- const c = parseYaml(text);
924
+ const c = readConfig(file);
895
925
  if (c.risk?.enabled !== "off" && !fs.existsSync(path.join(path.dirname(file), c.specs_dir || "specs", "context", "risk.md"))) {
896
926
  if (given.length || !(await askConfirm("GitRisk is on but files haven't been scored yet. Score them now? (uses git only, takes seconds)", true)))
897
927
  console.log(" GitRisk is on, but nothing is scored yet. Run: spec-driven-dev risk");
@@ -938,11 +968,167 @@ async function uninstall() {
938
968
  console.log("Done. Reinstall any time with: npx @benjaminthomas/spec-driven-dev@latest");
939
969
  }
940
970
 
971
+ // ---------- team: shared rules in the specs repo ----------
972
+ function teamInit() {
973
+ const file = findConfig(DIR);
974
+ if (!file) throw new Error('No .spec-driven.yml found. Say "run spec-setup" in your AI tool first.');
975
+ const personal = parseYaml(fs.readFileSync(file, "utf8"));
976
+ const tf = teamFileFor(file, personal.specs_dir);
977
+ if (fs.existsSync(tf)) throw new Error(`${tf} already exists. To change a team rule, edit it and commit, or say "change our branch rules" in your AI tool.`);
978
+ if (!specsInGit(path.dirname(file), personal.specs_dir || "specs")) console.log(`• ${path.dirname(tf)} isn't in a git repo, so team.yml can't be shared yet. Keep specs in a repo (a dedicated specs repo is best).`);
979
+ const { team, personal: mine } = splitConfig(personal);
980
+ fs.mkdirSync(path.dirname(tf), { recursive: true });
981
+ fs.writeFileSync(tf, TEAM_HEADER + dumpYaml(team));
982
+ fs.copyFileSync(file, `${file}.bak`);
983
+ fs.writeFileSync(file, PERSONAL_HEADER(path.relative(path.dirname(file), tf)) + dumpYaml(mine));
984
+ step(1, "Team rules shared", ink.cyan);
985
+ console.log(`✓ Wrote ${tf} (tracker, git conventions, commands, models, GitRisk, sharing)
986
+ ✓ ${file} now keeps only your local settings (the old file is ${path.basename(file)}.bak)
987
+ Next: commit team.yml in the specs repo. Colleagues who clone it run: spec-driven-dev setup --join=<path to the specs repo>
988
+ or say "run spec-setup" in their AI tool, which finds it and offers to join.`);
989
+ }
990
+
991
+ function join() {
992
+ const target = opt("join");
993
+ let specsDir = /^(https?:|git@|ssh:)/.test(target) ? "" : path.resolve(DIR, expandHome(target));
994
+ const workspace = path.resolve(DIR, expandHome(opt("workspace") || (specsDir ? path.dirname(specsDir) : DIR)));
995
+ if (!specsDir) {
996
+ const name = path.basename(target).replace(/\.git$/, "");
997
+ specsDir = path.join(workspace, name);
998
+ if (!fs.existsSync(specsDir)) execFileSync("git", ["clone", "-q", target, specsDir], { stdio: "inherit" });
999
+ }
1000
+ const team = parseYaml(readIf(path.join(specsDir, "team.yml")) || "");
1001
+ const repoList = parseYaml(readIf(path.join(specsDir, "team-repos.yml")) || "").repos;
1002
+ if (!Object.keys(team).length && !Array.isArray(repoList)) throw new Error(`${specsDir} has no team.yml or team-repos.yml, so there's no team to join. Ask whoever set it up to run: spec-driven-dev team init`);
1003
+ const known = Array.isArray(repoList) ? repoList : (team.repos || []);
1004
+ const wanted = opt("repos") ? splitList(opt("repos")) : known.map((r) => r.name);
1005
+ const have = wanted.filter((n) => isRepo(path.join(workspace, n)));
1006
+ const missing = known.filter((r) => wanted.includes(r.name) && !have.includes(r.name));
1007
+ if (!have.length) throw new Error(`None of the team's repos (${known.map((r) => r.name).join(", ")}) are cloned in ${workspace}. Clone the ones you work on, then join again (or pass --workspace=<folder>).`);
1008
+ const file = path.join(workspace, ".spec-driven.yml");
1009
+ if (fs.existsSync(file) && !flag("force")) throw new Error(`${file} already exists. Add --force to replace it with the team's setup (it's backed up first).`);
1010
+ if (fs.existsSync(file)) fs.copyFileSync(file, `${file}.bak`);
1011
+ const mine = { version: 1, layout: have.length > 1 ? "multi-repo" : "single", repos: have.map((n) => ({ path: n })), specs_dir: path.relative(workspace, specsDir) || ".", pipeline: { after_approval: opt("after-approval") || "stop" } };
1012
+ if (mine.layout === "single") { delete mine.repos; }
1013
+ fs.writeFileSync(file, PERSONAL_HEADER(path.relative(workspace, path.join(specsDir, "team.yml"))) + dumpYaml(mine));
1014
+ if (mine.layout === "multi-repo") register(real(file), have.map((n) => real(path.join(workspace, n))));
1015
+ step(1, "Joined the team", ink.cyan);
1016
+ console.log(`✓ Wrote ${file} with your repos: ${have.join(", ")}
1017
+ Team rules (tracker, branches, commits, commands, GitRisk) come from ${path.join(specsDir, "team.yml")}${Object.keys(team).length ? "" : " (not created yet: team-repos.yml only)"}.`);
1018
+ for (const r of missing) console.log(` • ${r.name} isn't cloned here${r.url ? `: git clone ${r.url} ${path.join(workspace, r.name)}` : ""}`);
1019
+ }
1020
+
1021
+ // ---------- CI: refresh GitRisk hotspots on merge ----------
1022
+ function ghSlug(url) { return (String(url || "").match(/github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/) || [])[1] || ""; }
1023
+
1024
+ function ciFiles({ specsSlug, repos, version }) {
1025
+ const checkouts = repos.map((r) => ` - uses: actions/checkout@v4
1026
+ with:
1027
+ repository: ${r.slug}
1028
+ path: ${r.name}
1029
+ fetch-depth: 0 # full history: GitRisk reads git log
1030
+ token: \${{ secrets.GITRISK_TOKEN }}`).join("\n");
1031
+ const specs = `# Refreshes the "Risk hotspots" block in CONTEXT.md from every team repo's git history.
1032
+ # Written by \`spec-driven-dev ci\`. Re-run that command after adding a repo to team-repos.yml.
1033
+ # Needs a repository secret GITRISK_TOKEN: a token that can read the code repos and write to this repo.
1034
+ name: GitRisk
1035
+ on:
1036
+ repository_dispatch:
1037
+ types: [code-merged] # sent by each code repo's gitrisk-notify workflow
1038
+ schedule:
1039
+ - cron: "0 3 * * *" # nightly safety net
1040
+ workflow_dispatch:
1041
+ permissions:
1042
+ contents: write
1043
+ concurrency:
1044
+ group: gitrisk
1045
+ cancel-in-progress: true
1046
+ jobs:
1047
+ score:
1048
+ runs-on: ubuntu-latest
1049
+ steps:
1050
+ - uses: actions/checkout@v4
1051
+ with:
1052
+ path: specs
1053
+ token: \${{ secrets.GITRISK_TOKEN }}
1054
+ ${checkouts}
1055
+ - uses: actions/setup-node@v4
1056
+ with:
1057
+ node-version: 20
1058
+ - name: Score files from git history
1059
+ run: npx -y @benjaminthomas/spec-driven-dev@${version} risk --dir=. --repos=${repos.map((r) => r.name).join(",")} --specs-dir=specs
1060
+ - name: Commit when the hotspots changed (not for a date-only change)
1061
+ working-directory: specs
1062
+ run: |
1063
+ git add CONTEXT.md .gitignore
1064
+ if git diff --cached --quiet -I '^Updated [0-9-]+$'; then echo "Hotspots unchanged"; exit 0; fi
1065
+ git -c user.name="gitrisk[bot]" -c user.email="gitrisk@users.noreply.github.com" commit -m "chore: refresh GitRisk hotspots"
1066
+ git push
1067
+ `;
1068
+ const notify = (branch) => `# Tells the specs repo to refresh GitRisk hotspots when work lands here.
1069
+ # Written by \`spec-driven-dev ci --notify\`. Needs the same GITRISK_TOKEN secret in this repo.
1070
+ name: GitRisk notify
1071
+ on:
1072
+ push:
1073
+ branches: [${branch}]
1074
+ jobs:
1075
+ notify:
1076
+ runs-on: ubuntu-latest
1077
+ steps:
1078
+ - run: gh api repos/${specsSlug}/dispatches -f event_type=code-merged
1079
+ env:
1080
+ GH_TOKEN: \${{ secrets.GITRISK_TOKEN }}
1081
+ `;
1082
+ return { specs, notify };
1083
+ }
1084
+
1085
+ function ci() {
1086
+ const { root, c } = loadConfig();
1087
+ if (!c.layout) throw new Error('No .spec-driven.yml found. Say "run spec-setup" in your AI tool first.');
1088
+ const specs = path.resolve(root, c.specs_dir || "specs");
1089
+ const g = specsInGit(root, c.specs_dir || "specs");
1090
+ 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).`);
1091
+ const specsSlug = ghSlug(run("git", ["remote", "get-url", "origin"], g.top));
1092
+ 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`);
1093
+ const team = parseYaml(fs.existsSync(path.join(specs, "team-repos.yml")) ? fs.readFileSync(path.join(specs, "team-repos.yml"), "utf8") : "").repos;
1094
+ 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)))) })))
1095
+ .map((r) => ({ name: r.name, slug: ghSlug(r.url) })).filter((r) => r.name && r.name !== path.basename(g.top));
1096
+ const missing = list.filter((r) => !r.slug);
1097
+ 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.`);
1098
+ if (!list.length) throw new Error("No code repos to score. List them in team-repos.yml or your workspace.");
1099
+ const version = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version;
1100
+ const { specs: specsYml, notify } = ciFiles({ specsSlug, repos: list, version });
1101
+ 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.`);
1102
+ const wf = path.join(g.top, ".github", "workflows", "gitrisk.yml");
1103
+ fs.mkdirSync(path.dirname(wf), { recursive: true });
1104
+ fs.writeFileSync(wf, specsYml);
1105
+ step(1, "GitRisk on every merge", ink.cyan);
1106
+ console.log(`✓ Wrote ${wf}\n Scores ${list.map((r) => r.name).join(", ")} nightly, on demand, and whenever a code repo pings it.`);
1107
+ const wrote = [];
1108
+ for (const r of (c.repos || [])) {
1109
+ const dir = path.resolve(root, expandHome(String(r.path)));
1110
+ if (!flag("notify") || !isRepo(dir) || real(dir) === real(g.top)) continue;
1111
+ 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";
1112
+ const f = path.join(dir, ".github", "workflows", "gitrisk-notify.yml");
1113
+ fs.mkdirSync(path.dirname(f), { recursive: true });
1114
+ fs.writeFileSync(f, notify(branch));
1115
+ wrote.push(`${path.basename(dir)} (on ${branch})`);
1116
+ }
1117
+ if (wrote.length) console.log(`✓ Wrote .github/workflows/gitrisk-notify.yml in: ${wrote.join(", ")}`);
1118
+ 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.");
1119
+ console.log(`
1120
+ Next:
1121
+ 1. Create a GitHub token (fine-grained): read access to ${list.map((r) => r.name).join(", ")}; contents: read & write on ${specsSlug}.
1122
+ 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}
1123
+ 3. Commit and push the workflow file(s). Nothing was committed for you.
1124
+ 4. Try it: gh workflow run GitRisk -R ${specsSlug}`);
1125
+ }
1126
+
941
1127
  // Silent unless something is unfinished, so it's cheap to run at the start of a session.
942
1128
  function status() {
943
1129
  const file = findConfig(DIR);
944
1130
  const root = file ? path.dirname(file) : DIR;
945
- const specsName = (file && parseYaml(fs.readFileSync(file, "utf8")).specs_dir) || "specs";
1131
+ const specsName = (file && readConfig(file).specs_dir) || "specs";
946
1132
  const specs = path.join(root, specsName);
947
1133
  const lines = [];
948
1134
  for (const name of subdirs(specs)) {
@@ -969,14 +1155,17 @@ try {
969
1155
  else if (command === "risk") risk();
970
1156
  else if (command === "settings") await settings();
971
1157
  else if (command === "uninstall") await uninstall();
1158
+ else if (command === "ci") ci();
1159
+ else if (command === "team") { if (args.includes("init")) teamInit(); else throw new Error("Use: spec-driven-dev team init"); }
972
1160
  else if (command === "risk-eval") await riskEvalCmd();
973
1161
  else if (command === "install") {
974
1162
  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");
975
1163
  const tools = await install();
976
1164
  if (tools.length && !flag("no-setup")) await handOver(tools);
977
- } else if (SETUP_IN_AGENT) handOver([], true);
1165
+ } else if (command === "setup" && opt("join")) join();
1166
+ else if (SETUP_IN_AGENT) handOver([], true);
978
1167
  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`);
1168
+ else throw new Error(`Unknown command "${command}" — use install (default), setup, settings, team, ci, uninstall, doctor, status, context, risk, or risk-eval`);
980
1169
  if (command === "setup" && !SETUP_IN_AGENT) console.log('\nNext: in your AI tool, say "investigate <ticket>" or "create a spec".');
981
1170
  } catch (e) {
982
1171
  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/bin/team.mjs ADDED
@@ -0,0 +1,92 @@
1
+ // The team's shared rules live in the specs repo as team.yml; each person's .spec-driven.yml keeps only
2
+ // local things (where their repos are, which ones they have, personal preferences). The effective
3
+ // config is team.yml with the personal file on top.
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+
7
+ // Shared: decided once by the team. Personal: differs per machine or per person.
8
+ export const TEAM_KEYS = ["tracker", "git", "commands", "models", "effort", "risk", "context"];
9
+ export const PERSONAL_KEYS = ["version", "layout", "repos", "specs_dir", "pipeline"];
10
+ const REPO_TEAM_KEYS = ["role", "git", "commands"];
11
+
12
+ const isObj = (v) => v && typeof v === "object" && !Array.isArray(v);
13
+ export function deepMerge(a, b) {
14
+ if (!isObj(a) || !isObj(b)) return b === undefined ? a : b;
15
+ const out = { ...a };
16
+ for (const [k, v] of Object.entries(b)) out[k] = isObj(v) && isObj(a[k]) ? deepMerge(a[k], v) : v;
17
+ return out;
18
+ }
19
+
20
+ // personal: the parsed .spec-driven.yml; team: the parsed team.yml (or null). Repos match by folder name.
21
+ export function mergeConfig(personal, team) {
22
+ if (!team) return personal;
23
+ const shared = Object.fromEntries(TEAM_KEYS.filter((k) => team[k] !== undefined).map((k) => [k, team[k]]));
24
+ const merged = deepMerge(shared, personal);
25
+ const byName = new Map((Array.isArray(team.repos) ? team.repos : []).map((r) => [r.name, r]));
26
+ if (Array.isArray(personal.repos)) {
27
+ merged.repos = personal.repos.map((r) => {
28
+ const t = byName.get(path.basename(String(r.path || "").replace(/[\\/]+$/, "")));
29
+ return t ? deepMerge(Object.fromEntries(REPO_TEAM_KEYS.filter((k) => t[k] !== undefined).map((k) => [k, t[k]])), r) : r;
30
+ });
31
+ }
32
+ return merged;
33
+ }
34
+
35
+ // Leaves that the personal file sets differently from the team, e.g. "git.base_branch".
36
+ export function overrides(personal, team, prefix = "") {
37
+ const out = [];
38
+ for (const k of prefix ? Object.keys(personal || {}) : TEAM_KEYS) {
39
+ const p = personal?.[k], t = team?.[k];
40
+ if (p === undefined || t === undefined) continue;
41
+ if (isObj(p) && isObj(t)) out.push(...overrides(p, t, `${prefix}${k}.`));
42
+ else if (JSON.stringify(p) !== JSON.stringify(t)) out.push({ key: `${prefix}${k}`, yours: p, team: t });
43
+ }
44
+ return out;
45
+ }
46
+
47
+ // Small YAML writer for nested maps, lists of maps and scalars (JSON strings are valid YAML scalars).
48
+ export function dumpYaml(obj, indent = "") {
49
+ const scalar = (v) => (typeof v === "string" ? (/^[\w./@~-]+$/.test(v) && !/^(on|off|yes|no|true|false|null|\d.*)$/i.test(v) ? v : JSON.stringify(v)) : String(v));
50
+ let out = "";
51
+ for (const [k, v] of Object.entries(obj)) {
52
+ if (v === undefined || v === null || (isObj(v) && !Object.keys(v).length)) continue;
53
+ if (Array.isArray(v)) {
54
+ out += `${indent}${k}:\n`;
55
+ for (const item of v) {
56
+ if (!isObj(item)) { out += `${indent} - ${scalar(item)}\n`; continue; }
57
+ const body = dumpYaml(item, `${indent} `).replace(/^\s+/, "");
58
+ out += `${indent} - ${body}`;
59
+ }
60
+ } else if (isObj(v)) out += `${indent}${k}:\n${dumpYaml(v, `${indent} `)}`;
61
+ else out += `${indent}${k}: ${scalar(v)}\n`;
62
+ }
63
+ return out;
64
+ }
65
+
66
+ // Split one config into the team's part and the personal part.
67
+ export function splitConfig(c) {
68
+ const team = { version: 1 };
69
+ for (const k of TEAM_KEYS) if (c[k] !== undefined) team[k] = c[k];
70
+ const repos = (Array.isArray(c.repos) ? c.repos : []).map((r) => {
71
+ const t = { name: path.basename(String(r.path || "").replace(/[\\/]+$/, "")) };
72
+ for (const k of REPO_TEAM_KEYS) if (r[k] !== undefined && r[k] !== "") t[k] = r[k];
73
+ return t;
74
+ }).filter((t) => Object.keys(t).length > 1);
75
+ if (repos.length) team.repos = repos;
76
+ const personal = {};
77
+ for (const k of PERSONAL_KEYS) if (c[k] !== undefined) personal[k] = c[k];
78
+ if (Array.isArray(personal.repos)) personal.repos = personal.repos.map((r) => ({ path: r.path }));
79
+ return { team, personal };
80
+ }
81
+
82
+ export const TEAM_HEADER = `# team.yml — the team's shared rules for spec-driven-dev (tracker, git conventions, commands,
83
+ # models, GitRisk, sharing). Committed in the specs repo, so everyone works the same way. Each
84
+ # person's .spec-driven.yml only says where their repos are; a value set there overrides this file
85
+ # for that person. Change a rule here and commit it (or say "change our branch rules" in your AI tool).
86
+ `;
87
+ export const PERSONAL_HEADER = (teamFile) => `# .spec-driven.yml — your local settings: where your repos are and personal preferences.
88
+ # The team's shared rules are in ${teamFile}. Anything you set here overrides them for you only.
89
+ `;
90
+
91
+ export const teamFileFor = (configFile, specsDir) => path.resolve(path.dirname(configFile), specsDir || "specs", "team.yml");
92
+ export const readIf = (f) => (fs.existsSync(f) ? fs.readFileSync(f, "utf8") : null);
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.16.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.16.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",
@@ -20,7 +20,7 @@ Load `.spec-driven.yml`: walk up from the current directory to the home folder,
20
20
  found wins (a `multi-repo` config only applies inside the repos it lists). If none is found, check
21
21
  `~/.spec-driven/workspaces.json`, which maps each multi-repo config to its repo paths. Use the config
22
22
  whose repos contain the current directory, because a workspace's repos can live in unrelated folders. Its values beat anything
23
- you would detect or assume. This skill uses `commands` (lint/typecheck/test/build), `git.commit_style`, and `layout`/`repos`.
23
+ you would detect or assume. If `{specs_dir}/team.yml` exists, it holds the team's shared rules (tracker, git conventions, commands, models, GitRisk, sharing): read it first, and let `.spec-driven.yml` override it. This skill uses `commands` (lint/typecheck/test/build), `git.commit_style`, and `layout`/`repos`.
24
24
 
25
25
  **Multi-repo:** run this in every listed repo that has changes, making one commit per repo with the same subject. **Monorepo:** run the checks for the packages that changed, or the root commands if that's how the repo works.
26
26
 
@@ -23,7 +23,7 @@ Load `.spec-driven.yml`: walk up from the current directory to the home folder,
23
23
  found wins (a `multi-repo` config only applies inside the repos it lists). If none is found, check
24
24
  `~/.spec-driven/workspaces.json`, which maps each multi-repo config to its repo paths. Use the config
25
25
  whose repos contain the current directory, because a workspace's repos can live in unrelated folders. Its values beat anything
26
- you would detect or assume. This skill uses `layout`/`repos` and `specs_dir`.
26
+ you would detect or assume. If `{specs_dir}/team.yml` exists, it holds the team's shared rules (tracker, git conventions, commands, models, GitRisk, sharing): read it first, and let `.spec-driven.yml` override it. This skill uses `layout`/`repos` and `specs_dir`.
27
27
 
28
28
  **Monorepo / multi-repo:** every task names its package or repo, and its file paths are relative to that. Keep each task inside one repo. Cross-repo work becomes separate tasks, with the contract (API shape, event, schema) written into both.
29
29
 
@@ -29,7 +29,7 @@ Load `.spec-driven.yml`: walk up from the current directory to the home folder,
29
29
  found wins (a `multi-repo` config only applies inside the repos it lists). If none is found, check
30
30
  `~/.spec-driven/workspaces.json`, which maps each multi-repo config to its repo paths. Use the config
31
31
  whose repos contain the current directory, because a workspace's repos can live in unrelated folders. Its values beat anything
32
- you would detect or assume. This skill uses `layout`/`repos`, `commands` (lint/typecheck/test per repo or package), and `git.commit_style`.
32
+ you would detect or assume. If `{specs_dir}/team.yml` exists, it holds the team's shared rules (tracker, git conventions, commands, models, GitRisk, sharing): read it first, and let `.spec-driven.yml` override it. This skill uses `layout`/`repos`, `commands` (lint/typecheck/test per repo or package), and `git.commit_style`.
33
33
 
34
34
  **Multi-repo:** create worktrees in each repo a task touches, run each repo's own commands in review, and make the batch commit in every repo that changed, using the same subject line.
35
35
 
@@ -36,7 +36,7 @@ Load `.spec-driven.yml`: walk up from the current directory to the home folder,
36
36
  found wins (a `multi-repo` config only applies inside the repos it lists). If none is found, check
37
37
  `~/.spec-driven/workspaces.json`, which maps each multi-repo config to its repo paths. Use the config
38
38
  whose repos contain the current directory, because a workspace's repos can live in unrelated folders. Its values beat anything
39
- you would detect or assume. This skill uses `layout`/`repos` (where to look and what each repo does), `tracker`, `git.remote`, `git.base_branch`, `git.branch_name`, and `specs_dir`.
39
+ you would detect or assume. If `{specs_dir}/team.yml` exists, it holds the team's shared rules (tracker, git conventions, commands, models, GitRisk, sharing): read it first, and let `.spec-driven.yml` override it. This skill uses `layout`/`repos` (where to look and what each repo does), `tracker`, `git.remote`, `git.base_branch`, `git.branch_name`, and `specs_dir`.
40
40
 
41
41
  **Monorepo:** name the package(s) involved. **Multi-repo:** search every listed repo whose role fits the ticket, and create the work branch, with the same name, only in the repos the investigation shows need changes.
42
42
 
@@ -28,7 +28,7 @@ investigate → create → approve → implement → verify → review → check
28
28
  Load `.spec-driven.yml`: walk up from the current directory to the home folder, and the first one
29
29
  found wins (a `multi-repo` config only applies inside the repos it lists). If none is found, check
30
30
  `~/.spec-driven/workspaces.json`, which maps each multi-repo config to its repo paths. Use the config
31
- whose repos contain the current directory. Its values beat anything you would detect or assume. This
31
+ whose repos contain the current directory. Its values beat anything you would detect or assume. If `{specs_dir}/team.yml` exists, it holds the team's shared rules (tracker, git conventions, commands, models, GitRisk, sharing): read it first, and let `.spec-driven.yml` override it. This
32
32
  skill uses `git` (base branch, PR target), `commands` (lint/typecheck/test), `models`/`effort`,
33
33
  `layout`/`repos`, and `specs_dir`.
34
34
 
@@ -32,11 +32,30 @@ first one found wins.
32
32
  |---|---|---|
33
33
  | `single` | the repo root | Commit it — the whole team gets the same rules |
34
34
  | `monorepo` | the repo root | Commit it |
35
- | `multi-repo` | the shared parent folder of the repos (usually not itself a git repo) | Personal by default; offer to also commit a copy into the repo the team treats as "home" |
35
+ | `multi-repo` | the shared parent folder of the repos (usually not itself a git repo) | The team's rules go in `team.yml` in the specs repo (shared); this file keeps only local paths |
36
36
 
37
37
  A multi-repo config only applies inside the repos it lists. If the current repo sits under that
38
38
  folder but isn't listed, treat it as having no config (ask whether to add it).
39
39
 
40
+ ## Step 0 — Join a team that's already set up
41
+
42
+ Before detecting anything, look for a team setup someone already shared: a folder next to this repo
43
+ (or in the workspace folder) containing `team.yml` or `team-repos.yml`, or a specs repo the user
44
+ names (a path, or a git URL to clone). If you find one, say so with what it is, and ask with a menu:
45
+
46
+ > Found your team's setup in ~/Projects/soreto-specs: 5 repos, Jira STP, branches from staging.
47
+ > `Join it (Recommended)`: use the team's rules; I'll only ask which of its repos you have here.
48
+ > `Set up separately`: ignore it and answer everything yourself.
49
+
50
+ **Joining:** ask only which of the team's repos you work on (tick boxes, from `team-repos.yml`;
51
+ mark the ones not cloned here and offer their `git clone` command, cloning only on a yes), then run
52
+ `spec-driven-dev setup --join=<specs repo> --repos=<chosen>` (or `npx -y @benjaminthomas/spec-driven-dev …`).
53
+ Don't audit, don't ask about conventions, and never change the team's files: `CONTEXT.md`,
54
+ `team.yml` and `team-repos.yml` are shared. Then run `doctor`, and finish with "You've joined. Say
55
+ *investigate STP-123* to start." Only `team-repos.yml` and no `team.yml` (an older setup): join
56
+ anyway, and tell the user the team's rules aren't shared yet, so the person who set it up should
57
+ run `spec-driven-dev team init`.
58
+
40
59
  ## Step 1 — Detect before asking
41
60
 
42
61
  Read, don't guess. Collect a draft of every field in `references/config-template.yml`:
@@ -204,6 +223,11 @@ for the rest.
204
223
  under `repos:` where they differ from the top level.
205
224
  2. If it's inside a repo, tell the user it's meant to be committed (so teammates get the same
206
225
  rules) — don't commit it yourself.
226
+ **Multi-repo with specs in a repo:** run `spec-driven-dev team init` right after writing the
227
+ file. It moves the team's rules (tracker, git conventions, commands, models, GitRisk, sharing)
228
+ into `{specs_dir}/team.yml`, which is committed with the specs, and leaves only local paths in
229
+ `.spec-driven.yml`. Tell the user to commit `team.yml`, and that colleagues then just say
230
+ "run spec-setup" and choose *Join*.
207
231
  3. If `npx` is available, run `npx -y @benjaminthomas/spec-driven-dev doctor` from the config's folder. It checks
208
232
  that every branch, folder, and `npm run` script in the file really exists. Fix anything it
209
233
  reports before finishing.
@@ -249,6 +273,7 @@ keep every other line and comment, and confirm in one sentence. Don't re-run dis
249
273
  | "turn GitRisk on", "use the risk scores" | `risk.enabled: on`, then run `spec-driven-dev risk` if `{specs_dir}/context/risk.md` doesn't exist yet |
250
274
  | "run straight through after I approve", "fewer stops" | `pipeline.after_approval: run-through` |
251
275
  | "stop after each stage" | `pipeline.after_approval: stop` |
276
+ | "change our branch rules", "use main for PRs" (when `team.yml` exists) | edit `team.yml`, since it's a team rule, and remind them to commit it; only change `.spec-driven.yml` if they say it's just for them |
252
277
  | "add reverb-react to the workspace", "I also work on X" | add it under `repos:` (clone first if needed, from `team-repos.yml`), and to `team-repos.yml` if it's new to the team |
253
278
  | "share the specs with the team", "keep specs local", "share only the project memory" | `context.share: all / none / memory`: show the `.gitignore` change, then run `spec-driven-dev settings --share=…` on a yes |
254
279
 
@@ -1,4 +1,6 @@
1
1
  # .spec-driven.yml — project rules for the spec-driven-dev skills.
2
+ # Multi-repo teams: the shared parts (tracker, git, commands, models, effort, risk, context) go in
3
+ # {specs_dir}/team.yml (spec-driven-dev team init), so colleagues can join; this file then keeps local paths.
2
4
  # Written by spec-setup from what it detected + what you confirmed. Edit freely, or re-run
3
5
  # spec-setup to update it. Every spec-* skill reads this before doing anything else.
4
6
  version: 1
@@ -22,7 +22,7 @@ Load `.spec-driven.yml`: walk up from the current directory to the home folder,
22
22
  found wins (a `multi-repo` config only applies inside the repos it lists). If none is found, check
23
23
  `~/.spec-driven/workspaces.json`, which maps each multi-repo config to its repo paths. Use the config
24
24
  whose repos contain the current directory, because a workspace's repos can live in unrelated folders. Its values beat anything
25
- you would detect or assume. This skill uses `git.remote`, `git.pr_target`, `git.branch_name`, `tracker` (to link the ticket in the PR), and `layout`/`repos`.
25
+ you would detect or assume. If `{specs_dir}/team.yml` exists, it holds the team's shared rules (tracker, git conventions, commands, models, GitRisk, sharing): read it first, and let `.spec-driven.yml` override it. This skill uses `git.remote`, `git.pr_target`, `git.branch_name`, `tracker` (to link the ticket in the PR), and `layout`/`repos`.
26
26
 
27
27
  **Multi-repo:** push and open one PR per repo that has unpushed commits, and list the sibling PRs in each PR's body so reviewers see the whole change.
28
28
 
@@ -29,7 +29,7 @@ Load `.spec-driven.yml`: walk up from the current directory to the home folder,
29
29
  found wins (a `multi-repo` config only applies inside the repos it lists). If none is found, check
30
30
  `~/.spec-driven/workspaces.json`, which maps each multi-repo config to its repo paths. Use the config
31
31
  whose repos contain the current directory, because a workspace's repos can live in unrelated folders. Its values beat anything
32
- you would detect or assume. This skill uses `commands.dev`, `commands.dev_url`, and `layout`/`repos`.
32
+ you would detect or assume. If `{specs_dir}/team.yml` exists, it holds the team's shared rules (tracker, git conventions, commands, models, GitRisk, sharing): read it first, and let `.spec-driven.yml` override it. This skill uses `commands.dev`, `commands.dev_url`, and `layout`/`repos`.
33
33
 
34
34
  **Monorepo / multi-repo:** start every app or service the criteria need (e.g. the API and the frontend), each with its own `dev` command.
35
35