@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 +37 -10
- package/bin/cli.mjs +206 -17
- package/bin/risk.mjs +1 -1
- package/bin/team.mjs +92 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/skills/spec-checkpoint/SKILL.md +1 -1
- package/plugin/skills/spec-create/SKILL.md +1 -1
- package/plugin/skills/spec-implement/SKILL.md +1 -1
- package/plugin/skills/spec-investigate/SKILL.md +1 -1
- package/plugin/skills/spec-review/SKILL.md +1 -1
- package/plugin/skills/spec-setup/SKILL.md +26 -1
- package/plugin/skills/spec-setup/references/config-template.yml +2 -0
- package/plugin/skills/spec-ship/SKILL.md +1 -1
- package/plugin/skills/spec-verify/SKILL.md +1 -1
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 |
|
|
764
|
-
| `spec-investigate` | 1 · understand | 387 | 3,
|
|
765
|
-
| `spec-create` | 2 · plan | 198 | 1,
|
|
766
|
-
| `spec-implement` | 3 · build | 327 | 2,
|
|
767
|
-
| `spec-verify` | 4 · prove | 163 | 1,
|
|
768
|
-
| `spec-review` | 5 · review | 137 | 1,
|
|
769
|
-
| `spec-checkpoint` | 6 · commit | 140 | 1,
|
|
770
|
-
| `spec-ship` | 7 · PR | 230 | 1,
|
|
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,
|
|
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.
|
|
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 =
|
|
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 &&
|
|
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 ?
|
|
816
|
+
return { file, root: file ? path.dirname(file) : gitTop(DIR), c: file ? readConfig(file) : {} };
|
|
794
817
|
}
|
|
795
818
|
|
|
796
819
|
function risk() {
|
|
797
|
-
const
|
|
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
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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 =
|
|
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 =
|
|
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 &&
|
|
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 (
|
|
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", `
|
|
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.
|
|
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.
|
|
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) |
|
|
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
|
|