@benjaminthomas/spec-driven-dev 1.15.0 → 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`,
@@ -660,6 +670,8 @@ spec-driven-dev doctor # check .spec-driven.yml again
660
670
  spec-driven-dev status # list unfinished specs and each one's next step
661
671
  spec-driven-dev context --areas=billing # print the project memory (index, or chosen areas)
662
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
663
675
  spec-driven-dev ci --notify # GitHub Actions: refresh GitRisk hotspots on every merge
664
676
  spec-driven-dev uninstall # remove the skills, plugin and cache (--all: settings too)
665
677
  spec-driven-dev risk # GitRisk: score files from git history → specs/context/risk.md
@@ -775,16 +787,16 @@ skips that step rather than failing.
775
787
 
776
788
  | Skill | Stage | SKILL.md lines | Words | Supporting files |
777
789
  |---|---|---:|---:|---:|
778
- | `spec-setup` | 0 · configure | 273 | 2,829 | 4 |
779
- | `spec-investigate` | 1 · understand | 387 | 3,367 | 1 |
780
- | `spec-create` | 2 · plan | 198 | 1,782 | 6 |
781
- | `spec-implement` | 3 · build | 327 | 2,753 | 3 |
782
- | `spec-verify` | 4 · prove | 163 | 1,557 | 1 |
783
- | `spec-review` | 5 · review | 137 | 1,294 | 1 |
784
- | `spec-checkpoint` | 6 · commit | 140 | 1,144 | 0 |
785
- | `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 |
786
798
  | `generative-seo` | standalone | 255 | 2,209 | 7 |
787
- | **Total** | | **2,110** | **18,323** | **23** |
799
+ | **Total** | | **2,135** | **18,803** | **23** |
788
800
 
789
801
  - **8** skills: **1** setup + **6** workflow stages + **1** standalone
790
802
  - **4** tools supported natively (Claude Code, Codex CLI, Cursor, Antigravity), plus an MCP fallback
@@ -837,7 +849,7 @@ plugin/evals/ `claude plugin eval` cases
837
849
  1. Increase `version` in both `package.json` and `plugin/.claude-plugin/plugin.json` (the check fails if
838
850
  they differ).
839
851
  2. Add a `CHANGELOG.md` entry, then commit and push.
840
- 3. Tag the release and push the tag: `git tag v1.15.0 && git push origin v1.15.0`.
852
+ 3. Tag the release and push the tag: `git tag v1.16.0 && git push origin v1.16.0`.
841
853
  4. Watch the **publish** run in the repo's Actions tab. When it's green, the new version is on
842
854
  npm. Claude Code plugin users get it from the marketplace once the commit is on `main`.
843
855
 
package/bin/cli.mjs CHANGED
@@ -4,6 +4,8 @@
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)
7
9
  // spec-driven-dev ci write GitHub Actions that refresh GitRisk hotspots on every merge (--notify also writes the per-repo trigger)
8
10
  // spec-driven-dev uninstall remove the skills (all tools), the Claude Code plugin and the download cache; --all also removes the settings file
9
11
  // spec-driven-dev doctor check .spec-driven.yml against the real repos (exit 1 on problems)
@@ -23,6 +25,7 @@ import prompts from "prompts"; // arrow-key menus that work in PowerShell, cmd,
23
25
  import { fileURLToPath } from "node:url";
24
26
  import { scoreRepo, riskMarkdown, normWeights, weightProblems, setConfigValue, riskSummaryBlock, withRiskSummary } from "./risk.mjs";
25
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";
26
29
 
27
30
  const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
28
31
  const SKILLS_DIR = path.join(ROOT, "plugin", "skills");
@@ -41,7 +44,7 @@ const args = process.argv.slice(2);
41
44
  const flag = (n) => args.includes(`--${n}`);
42
45
  const opt = (n) => args.find((a) => a.startsWith(`--${n}=`))?.split("=")[1];
43
46
  const command = args.find((a) => !a.startsWith("-")) || "install";
44
- const YES = flag("yes") || flag("y") || !process.stdin.isTTY || ["doctor", "status", "context", "risk", "risk-eval", "setup", "ci"].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"));
45
48
  const DIR = path.resolve(opt("dir") || process.cwd());
46
49
 
47
50
  // Colour only for a person at a terminal; pipes, CI and NO_COLOR get plain text (and the tests stay plain).
@@ -65,7 +68,7 @@ if (flag("version") || flag("v")) {
65
68
  }
66
69
  // No terminal to answer in (e.g. `!` in Claude Code, an IDE task, a pipe) and nothing passed explicitly:
67
70
  // stop rather than write a config from guesses.
68
- 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"];
69
72
  const NO_TTY = !process.stdin.isTTY && !args.some((a) => ANSWER_FLAGS.some((f) => a === `--${f}` || a.startsWith(`--${f}=`)));
70
73
  // Installing the skills needs no answers, so the default command still does that without a terminal
71
74
  // (e.g. `!` in Claude Code) and hands the questions to the spec-setup skill, which asks them in the chat.
@@ -589,6 +592,21 @@ function configApplies(f, dir) {
589
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); });
590
593
  }
591
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
+
592
610
  function findConfig(dir) {
593
611
  for (let d = dir; ; d = path.dirname(d)) {
594
612
  const f = path.join(d, ".spec-driven.yml");
@@ -608,8 +626,12 @@ function doctor() {
608
626
  if (!file) throw new Error(`No .spec-driven.yml in ${DIR} or above. In your AI tool, say "run spec-setup".`);
609
627
  const root = path.dirname(file);
610
628
  let c;
611
- 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}`); }
612
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
+ }
613
635
  if (!["single", "monorepo", "multi-repo"].includes(c.layout)) problems.push(`layout "${c.layout}" must be single, monorepo, or multi-repo`);
614
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`);
615
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`);
@@ -707,7 +729,7 @@ function checkContext(specs, specsName, repoRoots, problems, notes) {
707
729
  function context() {
708
730
  const file = findConfig(DIR);
709
731
  const root = file ? path.dirname(file) : DIR;
710
- const specsName = (file && parseYaml(fs.readFileSync(file, "utf8")).specs_dir) || "specs";
732
+ const specsName = (file && readConfig(file).specs_dir) || "specs";
711
733
  const index = path.join(root, specsName, "CONTEXT.md");
712
734
  const areas = splitList(opt("areas"));
713
735
  if (!areas.length) {
@@ -791,7 +813,7 @@ function ignoreGenerated(file) {
791
813
 
792
814
  function loadConfig() {
793
815
  const file = findConfig(DIR);
794
- 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) : {} };
795
817
  }
796
818
 
797
819
  function risk() {
@@ -857,7 +879,7 @@ async function riskEvalCmd() {
857
879
 
858
880
  // Plain-language switches, so anyone can change them without editing YAML.
859
881
  const SETTINGS = [
860
- { 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",
861
883
  get: (c) => c.risk?.enabled || "on", options: [
862
884
  { value: "on", title: "On", description: "investigations, plans and reviews look harder at historically risky files" },
863
885
  { value: "off", title: "Off", description: "the skills ignore risk completely" }] },
@@ -865,7 +887,7 @@ const SETTINGS = [
865
887
  get: (c) => c.pipeline?.after_approval || "stop", options: [
866
888
  { value: "stop", title: "Stop after each stage", description: "you start implement, verify, review and commit yourself" },
867
889
  { value: "run-through", title: "Run straight through", description: "implement → verify → review → commit; stops at any failure, never pushes" }] },
868
- { 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",
869
891
  get: (c) => c.context?.share || "all", after: (file, c, v) => applyShare(path.dirname(file), c.specs_dir || "specs", v), options: [
870
892
  { value: "all", title: "Share everything", description: "commit plans, investigations, reviews and project memory" },
871
893
  { value: "memory", title: "Share project memory only", description: "commit CONTEXT.md and context/; keep feature files local" },
@@ -876,18 +898,22 @@ async function settings() {
876
898
  const file = findConfig(DIR);
877
899
  if (!file) throw new Error(`No .spec-driven.yml in ${DIR} or above. In your AI tool, say "run spec-setup" first.`);
878
900
  let text = fs.readFileSync(file, "utf8");
901
+ const tf = readConfig(file)._teamFile;
879
902
  const label = (s, v) => s.options.find((o) => o.value === v)?.title || v;
880
903
  const save = async (s, v) => {
881
904
  if (!s.options.some((o) => o.value === v)) throw new Error(`--${s.flag} must be ${s.options.map((o) => o.value).join(" or ")}`);
882
- text = setConfigValue(text, s.block, s.key, v);
883
- fs.writeFileSync(file, text);
884
- console.log(`✓ ${s.name}: ${label(s, v)} (saved in ${file})`);
885
- 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);
886
912
  };
887
913
  const given = SETTINGS.filter((s) => opt(s.flag));
888
914
  if (given.length) for (const s of given) await save(s, opt(s.flag));
889
915
  else for (;;) {
890
- const c = parseYaml(text);
916
+ const c = readConfig(file);
891
917
  const pick = await askSelect("Settings: pick one to change", [
892
918
  ...SETTINGS.map((s) => ({ title: `${s.name}: ${label(s, s.get(c))}`, description: s.about, value: s })),
893
919
  { title: "Done", value: null }], null);
@@ -895,7 +921,7 @@ async function settings() {
895
921
  const v = await askSelect(`${pick.name}`, pick.options, pick.get(c));
896
922
  if (v !== pick.get(c)) await save(pick, v);
897
923
  }
898
- const c = parseYaml(text);
924
+ const c = readConfig(file);
899
925
  if (c.risk?.enabled !== "off" && !fs.existsSync(path.join(path.dirname(file), c.specs_dir || "specs", "context", "risk.md"))) {
900
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)))
901
927
  console.log(" GitRisk is on, but nothing is scored yet. Run: spec-driven-dev risk");
@@ -942,6 +968,56 @@ async function uninstall() {
942
968
  console.log("Done. Reinstall any time with: npx @benjaminthomas/spec-driven-dev@latest");
943
969
  }
944
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
+
945
1021
  // ---------- CI: refresh GitRisk hotspots on merge ----------
946
1022
  function ghSlug(url) { return (String(url || "").match(/github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/) || [])[1] || ""; }
947
1023
 
@@ -1052,7 +1128,7 @@ Next:
1052
1128
  function status() {
1053
1129
  const file = findConfig(DIR);
1054
1130
  const root = file ? path.dirname(file) : DIR;
1055
- const specsName = (file && parseYaml(fs.readFileSync(file, "utf8")).specs_dir) || "specs";
1131
+ const specsName = (file && readConfig(file).specs_dir) || "specs";
1056
1132
  const specs = path.join(root, specsName);
1057
1133
  const lines = [];
1058
1134
  for (const name of subdirs(specs)) {
@@ -1080,14 +1156,16 @@ try {
1080
1156
  else if (command === "settings") await settings();
1081
1157
  else if (command === "uninstall") await uninstall();
1082
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"); }
1083
1160
  else if (command === "risk-eval") await riskEvalCmd();
1084
1161
  else if (command === "install") {
1085
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");
1086
1163
  const tools = await install();
1087
1164
  if (tools.length && !flag("no-setup")) await handOver(tools);
1088
- } else if (SETUP_IN_AGENT) handOver([], true);
1165
+ } else if (command === "setup" && opt("join")) join();
1166
+ else if (SETUP_IN_AGENT) handOver([], true);
1089
1167
  else if (command === "setup") await setup();
1090
- else throw new Error(`Unknown command "${command}" — use install (default), setup, settings, ci, 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`);
1091
1169
  if (command === "setup" && !SETUP_IN_AGENT) console.log('\nNext: in your AI tool, say "investigate <ticket>" or "create a spec".');
1092
1170
  } catch (e) {
1093
1171
  console.error(`✗ ${e.message}`);
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.15.0",
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.15.0",
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