@benjaminthomas/spec-driven-dev 1.11.0 → 1.11.2
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 +32 -14
- package/bin/cli.mjs +42 -8
- package/bin/risk.mjs +40 -8
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/skills/spec-checkpoint/SKILL.md +8 -1
- package/plugin/skills/spec-create/SKILL.md +3 -0
- package/plugin/skills/spec-implement/SKILL.md +5 -0
- package/plugin/skills/spec-implement/references/review-prompt-template.md +1 -1
- package/plugin/skills/spec-investigate/SKILL.md +3 -0
- package/plugin/skills/spec-review/SKILL.md +3 -1
- package/plugin/skills/spec-setup/SKILL.md +1 -1
package/README.md
CHANGED
|
@@ -63,9 +63,11 @@ start a new session and continue where you left off.
|
|
|
63
63
|
npx @benjaminthomas/spec-driven-dev@latest
|
|
64
64
|
```
|
|
65
65
|
|
|
66
|
-
This one command downloads the tool, installs the skills, and sets up the project.
|
|
67
|
-
|
|
68
|
-
terminal to answer in
|
|
66
|
+
This one command downloads the tool, installs the skills, and sets up the project. In a terminal
|
|
67
|
+
window it asks its setup questions there. Run it with `!` inside Claude Code (or any other place
|
|
68
|
+
without a terminal to answer in) and it installs the skills for the AI tools it finds, then tells
|
|
69
|
+
you to say **"run spec-setup"**, which asks the same questions in the chat. It never writes the
|
|
70
|
+
settings file from guesses.
|
|
69
71
|
|
|
70
72
|
> **Why not `npm install -g`?** npm doesn't let a package ask questions while it installs, and an
|
|
71
73
|
> install doesn't know which project it's for. That's why setup is a separate step you run inside
|
|
@@ -263,7 +265,22 @@ Risk = 0.30 × Churn + 0.35 × Bug Density + 0.20 × Fan-out + 0.15 × Security
|
|
|
263
265
|
| Fan-out | 20% | how many other files import it, against the repo's 90th percentile |
|
|
264
266
|
| Security | 15% | share of its commits that were security fixes (security, CVE, XSS, injection…) |
|
|
265
267
|
|
|
266
|
-
Tiers: critical ≥ 75, high ≥ 50, medium ≥ 25.
|
|
268
|
+
Tiers: critical ≥ 75, high ≥ 50, medium ≥ 25. Files added in the last 90 days with fewer than 3
|
|
269
|
+
commits, or not committed yet, are labelled **new** instead of being scored, because too little history isn't the same as
|
|
270
|
+
safe. Each new file shows the riskiest file it imports or is imported by, and inherits that file's
|
|
271
|
+
care when it's high or critical.
|
|
272
|
+
|
|
273
|
+
**When it updates:** setup scores the whole history once. After that it re-scores at every batch
|
|
274
|
+
commit in `spec-implement` and at `spec-checkpoint`. Each run takes under a second and uses no model,
|
|
275
|
+
so later batches see the new files and the extra fan-out they add. Lookups for the files a ticket
|
|
276
|
+
touches (`--files=`) always read the live history. Commits made outside the pipeline are picked up
|
|
277
|
+
at the next run. It doesn't hook into every commit.
|
|
278
|
+
|
|
279
|
+
`risk.md` is regenerated on every run, so the first run adds it to `.gitignore`, the way you'd ignore
|
|
280
|
+
a build output. Committing it would cause merge conflicts between branches. `doctor` flags it if
|
|
281
|
+
it's committed.
|
|
282
|
+
|
|
283
|
+
When `risk.enabled` is on, the skills use it:
|
|
267
284
|
- `spec-investigate` digs into high and critical files in the change surface (past fix commits,
|
|
268
285
|
dependents) and lists them as **Risk Hotspots**;
|
|
269
286
|
- `spec-create` raises those tasks one tier and requires a test aimed at each file's past failure
|
|
@@ -564,8 +581,9 @@ STP-123" or "ship it" picks the right skill.
|
|
|
564
581
|
Update to 1.5.1 or later (`npx @benjaminthomas/spec-driven-dev@latest --version`). Earlier versions
|
|
565
582
|
crashed on Node 14 or 16, which nvm often switches to inside older projects, and wrote a config
|
|
566
583
|
silently when there was no terminal. Now it runs on Node 14+. With no terminal to answer in (a `!`
|
|
567
|
-
command in Claude Code, an editor task, piped input), it
|
|
568
|
-
|
|
584
|
+
command in Claude Code, an editor task, piped input), it installs the skills and hands the questions
|
|
585
|
+
to your AI tool: say "run spec-setup" there. `spec-driven-dev setup` on its own still needs a
|
|
586
|
+
terminal, or flags such as `--layout=multi-repo --repos=…`.
|
|
569
587
|
|
|
570
588
|
**Setup didn't ask me about repos.**
|
|
571
589
|
It only asks about repos for a multi-repo or monorepo layout. If it detected `single`, type
|
|
@@ -726,16 +744,16 @@ skips that step rather than failing.
|
|
|
726
744
|
|
|
727
745
|
| Skill | Stage | SKILL.md lines | Words | Supporting files |
|
|
728
746
|
|---|---|---:|---:|---:|
|
|
729
|
-
| `spec-setup` | 0 · configure | 162 | 1,
|
|
730
|
-
| `spec-investigate` | 1 · understand |
|
|
731
|
-
| `spec-create` | 2 · plan |
|
|
732
|
-
| `spec-implement` | 3 · build |
|
|
747
|
+
| `spec-setup` | 0 · configure | 162 | 1,545 | 3 |
|
|
748
|
+
| `spec-investigate` | 1 · understand | 373 | 3,211 | 1 |
|
|
749
|
+
| `spec-create` | 2 · plan | 198 | 1,782 | 6 |
|
|
750
|
+
| `spec-implement` | 3 · build | 327 | 2,753 | 3 |
|
|
733
751
|
| `spec-verify` | 4 · prove | 163 | 1,557 | 1 |
|
|
734
|
-
| `spec-review` | 5 · review |
|
|
735
|
-
| `spec-checkpoint` | 6 · commit |
|
|
752
|
+
| `spec-review` | 5 · review | 137 | 1,294 | 1 |
|
|
753
|
+
| `spec-checkpoint` | 6 · commit | 140 | 1,144 | 0 |
|
|
736
754
|
| `spec-ship` | 7 · PR | 230 | 1,388 | 0 |
|
|
737
755
|
| `generative-seo` | standalone | 255 | 2,209 | 7 |
|
|
738
|
-
| **Total** | | **1,
|
|
756
|
+
| **Total** | | **1,985** | **16,883** | **22** |
|
|
739
757
|
|
|
740
758
|
- **8** skills: **1** setup + **6** workflow stages + **1** standalone
|
|
741
759
|
- **4** tools supported natively (Claude Code, Codex CLI, Cursor, Antigravity), plus an MCP fallback
|
|
@@ -787,7 +805,7 @@ plugin/evals/ `claude plugin eval` cases
|
|
|
787
805
|
1. Increase `version` in both `package.json` and `plugin/.claude-plugin/plugin.json` (the check fails if
|
|
788
806
|
they differ).
|
|
789
807
|
2. Add a `CHANGELOG.md` entry, then commit and push.
|
|
790
|
-
3. Tag the release and push the tag: `git tag v1.11.
|
|
808
|
+
3. Tag the release and push the tag: `git tag v1.11.2 && git push origin v1.11.2`.
|
|
791
809
|
4. Watch the **publish** run in the repo's Actions tab. When it's green, the new version is on
|
|
792
810
|
npm. Claude Code plugin users get it from the marketplace once the commit is on `main`.
|
|
793
811
|
|
package/bin/cli.mjs
CHANGED
|
@@ -48,13 +48,19 @@ if (flag("version") || flag("v")) {
|
|
|
48
48
|
// No terminal to answer in (e.g. `!` in Claude Code, an IDE task, a pipe) and nothing passed explicitly:
|
|
49
49
|
// stop rather than write a config from guesses.
|
|
50
50
|
const ANSWER_FLAGS = ["risk", "yes", "y", "layout", "repos", "packages", "tools", "workspace", "after-approval", "skills", "global", "no-setup", "project"];
|
|
51
|
-
|
|
51
|
+
const NO_TTY = !process.stdin.isTTY && !args.some((a) => ANSWER_FLAGS.some((f) => a === `--${f}` || a.startsWith(`--${f}=`)));
|
|
52
|
+
// Installing the skills needs no answers, so the default command still does that without a terminal
|
|
53
|
+
// (e.g. `!` in Claude Code) and hands the questions to the spec-setup skill, which asks them in the chat.
|
|
54
|
+
const HANDOFF = command === "install" && NO_TTY;
|
|
55
|
+
if (["setup", "settings"].includes(command) && NO_TTY) {
|
|
52
56
|
console.log(`✗ No interactive terminal, so ${command === "settings" ? "the settings menu can't open" : "setup can't ask its questions"}. Nothing was changed.
|
|
53
57
|
|
|
54
58
|
This happens when it runs through another program: a "!" command in Claude Code, an editor task,
|
|
55
59
|
or piped input. Either:
|
|
56
60
|
• run it yourself in a terminal window (Terminal, iTerm, or your editor's terminal panel):
|
|
57
|
-
npx @benjaminthomas/spec-driven-dev@latest ${command === "
|
|
61
|
+
npx @benjaminthomas/spec-driven-dev@latest ${command}${command === "setup" ? `
|
|
62
|
+
• or, in your AI tool (Claude Code, Cursor, Codex), say "run spec-setup": it asks the same
|
|
63
|
+
questions in the chat` : ""}
|
|
58
64
|
• or give the answers as flags, for example:
|
|
59
65
|
npx @benjaminthomas/spec-driven-dev@latest ${command === "settings" ? "settings --risk=off" : "setup --layout=multi-repo --repos=~/Projects/frontend,~/Projects/backend"}
|
|
60
66
|
(add --yes to accept everything it detects)`);
|
|
@@ -137,7 +143,8 @@ async function install() {
|
|
|
137
143
|
}
|
|
138
144
|
console.log(`✓ ${skills.length} skills → ${dest}`);
|
|
139
145
|
}
|
|
140
|
-
if (!dests.length) console.log("• No tools selected — nothing installed.");
|
|
146
|
+
if (!dests.length) console.log(HANDOFF ? "• No AI coding tools found (Claude Code, Cursor, Codex or Antigravity). Install one, or pass --tools=claude,cursor." : "• No tools selected — nothing installed.");
|
|
147
|
+
return dests.length;
|
|
141
148
|
}
|
|
142
149
|
|
|
143
150
|
// ---------- discovery ----------
|
|
@@ -593,6 +600,9 @@ function doctor() {
|
|
|
593
600
|
if (c.context?.graph && !["auto", "off"].includes(c.context.graph)) problems.push(`context.graph "${c.context.graph}" must be auto or off`);
|
|
594
601
|
const repoRoots = c.layout === "multi-repo" ? (c.repos || []).map((r) => path.resolve(root, expandHome(r.path))) : [root];
|
|
595
602
|
checkContext(specs, c.specs_dir || "specs", repoRoots, problems, notes);
|
|
603
|
+
const rmd = path.join(specs, "context", "risk.md"), rtop = run("git", ["rev-parse", "--show-toplevel"], root);
|
|
604
|
+
if (rtop && fs.existsSync(rmd) && run("git", ["ls-files", path.relative(path.resolve(rtop), real(rmd))], rtop))
|
|
605
|
+
problems.push(`${c.specs_dir || "specs"}/context/risk.md is committed to git. It's regenerated on every run, so it causes merge conflicts. Run: git rm --cached ${path.relative(path.resolve(rtop), real(rmd))} (spec-driven-dev risk adds it to .gitignore)`);
|
|
596
606
|
if (c.risk) {
|
|
597
607
|
if (c.risk.enabled && !["on", "off"].includes(c.risk.enabled)) problems.push(`risk.enabled "${c.risk.enabled}" must be on or off`);
|
|
598
608
|
problems.push(...weightProblems(c.risk.weights));
|
|
@@ -641,6 +651,19 @@ function context() {
|
|
|
641
651
|
}
|
|
642
652
|
}
|
|
643
653
|
|
|
654
|
+
// risk.md is regenerated on every run (it carries a date), so committing it means noisy diffs and merge
|
|
655
|
+
// conflicts between branches. Keep it out of git like a build output; anyone can recreate it in a second.
|
|
656
|
+
function ignoreGenerated(file) {
|
|
657
|
+
const top = run("git", ["rev-parse", "--show-toplevel"], path.dirname(file));
|
|
658
|
+
if (!top) return; // e.g. a multi-repo workspace folder that isn't a repo
|
|
659
|
+
const rel = path.relative(path.resolve(top), real(file)).split(path.sep).join("/");
|
|
660
|
+
if (rel.startsWith("..") || run("git", ["check-ignore", "--no-index", rel], top)) return;
|
|
661
|
+
const gi = path.join(top, ".gitignore");
|
|
662
|
+
const text = fs.existsSync(gi) ? fs.readFileSync(gi, "utf8") : "";
|
|
663
|
+
fs.writeFileSync(gi, `${text}${text && !text.endsWith("\n") ? "\n" : ""}# GitRisk scores, regenerated by spec-driven-dev risk\n${rel}\n`);
|
|
664
|
+
console.log(` Added ${rel} to .gitignore (it's regenerated on every run, so it shouldn't be committed).`);
|
|
665
|
+
}
|
|
666
|
+
|
|
644
667
|
function loadConfig() {
|
|
645
668
|
const file = findConfig(DIR);
|
|
646
669
|
return { file, root: file ? path.dirname(file) : gitTop(DIR), c: file ? parseYaml(fs.readFileSync(file, "utf8")) : {} };
|
|
@@ -660,7 +683,9 @@ function risk() {
|
|
|
660
683
|
const norm = (f) => f.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
661
684
|
const hits = wanted.map((f) => rows.find((r) => r.file === norm(f) || r.file.endsWith("/" + norm(f))) || { file: norm(f), score: null, tier: "no history" });
|
|
662
685
|
if (flag("json")) console.log(JSON.stringify(hits, null, 2));
|
|
663
|
-
else for (const h of hits) console.log(h.score === null ? `${h.file}:
|
|
686
|
+
else for (const h of hits) console.log(h.score === null ? `${h.file}: not scored (not a code file, or doesn't exist)`
|
|
687
|
+
: h.tier === "new" ? `${h.file}: new${h.committed ? "" : ", not committed yet"} — too little history to score.${h.near ? ` Connects to ${h.near.file} (${h.near.score} ${h.near.tier})${["high", "critical"].includes(h.near.tier) ? ": treat it as " + h.near.tier : ""}.` : " Give it standard care."}`
|
|
688
|
+
: `${h.file}: ${h.score} ${h.tier} (churn ${h.factors.churn}, bug density ${h.factors.bug_density}, fan-out ${h.factors.fan_out}, security ${h.factors.security})`);
|
|
664
689
|
return;
|
|
665
690
|
}
|
|
666
691
|
if (flag("json")) { console.log(JSON.stringify({ weights, since, rows }, null, 2)); return; }
|
|
@@ -668,10 +693,11 @@ function risk() {
|
|
|
668
693
|
fs.mkdirSync(path.join(specs, "context"), { recursive: true });
|
|
669
694
|
const out = path.join(specs, "context", "risk.md");
|
|
670
695
|
fs.writeFileSync(out, riskMarkdown({ rows, repos: scored, weights, since, top: Number(opt("top")) || 40 }));
|
|
696
|
+
ignoreGenerated(out);
|
|
671
697
|
const count = (t) => rows.filter((r) => r.tier === t).length;
|
|
672
698
|
console.log(`✓ Scored ${rows.length} files from ${scored.reduce((t, s) => t + s.commits, 0)} commits → ${path.relative(DIR, out) || out}`);
|
|
673
|
-
console.log(` ${count("critical")} critical · ${count("high")} high · ${count("medium")} medium. Top 5:`);
|
|
674
|
-
for (const r of rows.slice(0, 5)) console.log(` ${String(r.score).padStart(5)} ${r.tier.padEnd(8)} ${r.file}`);
|
|
699
|
+
console.log(` ${count("critical")} critical · ${count("high")} high · ${count("medium")} medium · ${count("new")} new. Top 5:`);
|
|
700
|
+
for (const r of rows.filter((x) => x.tier !== "new").slice(0, 5)) console.log(` ${String(r.score).padStart(5)} ${r.tier.padEnd(8)} ${r.file}`);
|
|
675
701
|
if (c.risk?.enabled === "off") console.log(" Note: risk.enabled is off in .spec-driven.yml, so the skills won't use this until you turn it on.");
|
|
676
702
|
}
|
|
677
703
|
|
|
@@ -769,12 +795,20 @@ try {
|
|
|
769
795
|
else if (command === "risk") risk();
|
|
770
796
|
else if (command === "settings") await settings();
|
|
771
797
|
else if (command === "risk-eval") await riskEvalCmd();
|
|
772
|
-
else if (command === "install") {
|
|
798
|
+
else if (command === "install" && HANDOFF) {
|
|
799
|
+
console.log("No interactive terminal (for example a \"!\" command in Claude Code), so this installs the skills\nwith the detected defaults and leaves the setup questions to your AI tool.\n");
|
|
800
|
+
const n = await install();
|
|
801
|
+
if (n) console.log(`
|
|
802
|
+
Next: in your AI tool, say "run spec-setup". It asks the setup questions in the chat
|
|
803
|
+
(single repo, monorepo or multi-repo, tracker, branches) and writes .spec-driven.yml.
|
|
804
|
+
If your AI tool doesn't recognise it yet, start a new session so it loads the new skills.
|
|
805
|
+
To answer in a terminal instead, open Terminal or iTerm and run: npx @benjaminthomas/spec-driven-dev setup`);
|
|
806
|
+
} else if (command === "install") {
|
|
773
807
|
await install();
|
|
774
808
|
if (!flag("no-setup") && (await askConfirm("Set up this project now?", true))) await setup();
|
|
775
809
|
} else if (command === "setup") await setup();
|
|
776
810
|
else throw new Error(`Unknown command "${command}" — use install (default), setup, settings, doctor, status, context, risk, or risk-eval`);
|
|
777
|
-
if (["install", "setup"].includes(command)) console.log('\nNext: in your AI tool, say "investigate <ticket>" or "create a spec".');
|
|
811
|
+
if (["install", "setup"].includes(command) && !HANDOFF) console.log('\nNext: in your AI tool, say "investigate <ticket>" or "create a spec".');
|
|
778
812
|
} catch (e) {
|
|
779
813
|
console.error(`✗ ${e.message}`);
|
|
780
814
|
process.exitCode = 1;
|
package/bin/risk.mjs
CHANGED
|
@@ -7,12 +7,13 @@ import { execFileSync } from "node:child_process";
|
|
|
7
7
|
|
|
8
8
|
export const DEFAULT_WEIGHTS = { churn: 0.3, bug_density: 0.35, fan_out: 0.2, security: 0.15 };
|
|
9
9
|
export const TIERS = [[75, "critical"], [50, "high"], [25, "medium"], [0, "low"]];
|
|
10
|
+
const NEW_DAYS = 90; // a file added within this many days with fewer than MIN_COMMITS commits (or not committed yet) has too little history to score fairly
|
|
10
11
|
const MIN_COMMITS = 3; // shortcut: a file with 1 commit that was a fix would score 100% bug density; divide by at least 3. Replace with Bayesian smoothing if calibration shows it matters.
|
|
11
12
|
|
|
12
13
|
// Classification uses the conventional-commit type when the repo uses one, and message keywords otherwise.
|
|
13
14
|
const BUG = /^(fix|hotfix|bugfix|revert)(\(.+\))?!?:|\b(fix(e[sd]|ing)?|bug|hotfix|regression|broken|crash(es|ed)?|patch(ed)?|defect)\b/i;
|
|
14
15
|
const SECURITY = /\b(security|cve-\d+|xss|csrf|ssrf|sqli|injection|vuln(erability)?|exploit|sanitiz(e|ation)|escap(e|ing) (html|input|output)|auth(entication|orization)? (bypass|fix)|privilege|secret(s)? (leak|exposed)|leak(ed|s)? (token|key|secret|credential)|rate.?limit|helmet|csp)\b/i;
|
|
15
|
-
const SKIP = /(^|\/)(node_modules|dist|build|out|vendor|coverage|\.next|graphify-out|specs)
|
|
16
|
+
const SKIP = /(^|\/)(node_modules|dist|build|out|vendor|coverage|\.next|graphify-out|specs|experiments)\/|(^|\/)(\.spec-driven\.yml|\.gitignore|\.gitattributes|\.gitmodules)$|\.(md|mdx|txt|lock|log|png|jpe?g|gif|svg|ico|webp|pdf|zip|map|snap|csv)$|(^|\/)(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|Cargo\.lock|go\.sum|poetry\.lock|composer\.lock)$/i;
|
|
16
17
|
const CODE = /\.(m?[jt]sx?|cjs|py|rb|go|rs|java|kt|php|cs|swift|scala|vue|svelte)$/i;
|
|
17
18
|
|
|
18
19
|
const git = (cwd, args) => {
|
|
@@ -50,13 +51,25 @@ function fanOut(repo, files) {
|
|
|
50
51
|
}
|
|
51
52
|
for (const t of targets) if (t !== f) dependents.get(t).add(f);
|
|
52
53
|
}
|
|
53
|
-
|
|
54
|
+
const imports = new Map(files.map((f) => [f, new Set()]));
|
|
55
|
+
for (const [t, srcs] of dependents) for (const f of srcs) imports.get(f).add(t);
|
|
56
|
+
return { counts: new Map([...dependents].map(([f, s]) => [f, s.size])), dependents, imports };
|
|
54
57
|
}
|
|
55
58
|
|
|
56
|
-
// Score every
|
|
59
|
+
// Score every code file in one repo, including new files not committed yet. `prefix` labels files in multi-repo workspaces.
|
|
57
60
|
export function scoreRepo(repo, { since = "2 years ago", weights = DEFAULT_WEIGHTS, prefix = "" } = {}) {
|
|
58
|
-
const
|
|
61
|
+
const committed = git(repo, ["ls-files"]).split("\n").filter((f) => f && !SKIP.test(f));
|
|
62
|
+
const untracked = git(repo, ["ls-files", "--others", "--exclude-standard"]).split("\n").filter((f) => f && !SKIP.test(f));
|
|
63
|
+
const files = [...committed, ...untracked];
|
|
59
64
|
const tracked = new Set(files);
|
|
65
|
+
// When each file was first added (all history, not just `since`), to tell new files from quiet old ones.
|
|
66
|
+
const added = new Map();
|
|
67
|
+
for (const block of git(repo, ["log", "--diff-filter=A", "--format=%x1e%ct", "--name-only"]).split("\x1e").slice(1)) {
|
|
68
|
+
const [ts, ...names] = block.split("\n");
|
|
69
|
+
for (const f of names) if (f) added.set(f, Number(ts)); // log is newest first, so the last write is the first add
|
|
70
|
+
}
|
|
71
|
+
const cutoff = Date.now() / 1000 - NEW_DAYS * 86400;
|
|
72
|
+
const recent = (f) => !added.has(f) || added.get(f) >= cutoff;
|
|
60
73
|
const stats = new Map(files.map((f) => [f, { commits: 0, bug: 0, security: 0 }]));
|
|
61
74
|
let commits = 0;
|
|
62
75
|
// \x1e starts each commit; the subject follows, then the changed file names.
|
|
@@ -72,7 +85,9 @@ export function scoreRepo(repo, { since = "2 years ago", weights = DEFAULT_WEIGH
|
|
|
72
85
|
if (c.security) s.security++;
|
|
73
86
|
}
|
|
74
87
|
}
|
|
75
|
-
|
|
88
|
+
// "New" = recent AND too few commits to judge. A young file that's already been fixed a lot has real history.
|
|
89
|
+
const isNew = new Set(files.filter((f) => untracked.includes(f) || (recent(f) && stats.get(f).commits < MIN_COMMITS)));
|
|
90
|
+
const graph = fanOut(repo, files), fan = graph.counts;
|
|
76
91
|
const expChurn = p90([...stats.values()].map((s) => s.commits)), expFan = p90([...fan.values()]);
|
|
77
92
|
const rows = files.map((f) => {
|
|
78
93
|
const s = stats.get(f), denom = Math.max(s.commits, MIN_COMMITS);
|
|
@@ -84,9 +99,20 @@ export function scoreRepo(repo, { since = "2 years ago", weights = DEFAULT_WEIGH
|
|
|
84
99
|
};
|
|
85
100
|
const score = Object.keys(DEFAULT_WEIGHTS).reduce((t, k) => t + (Number(weights[k]) || 0) * factors[k], 0);
|
|
86
101
|
const r = (x) => Math.round(x * 10) / 10;
|
|
87
|
-
return { file: prefix ? `${prefix}/${f}` : f, score: r(score), tier: tierOf(score), commits: s.commits, bug_fixes: s.bug, security_fixes: s.security, dependents: fan.get(f) || 0,
|
|
102
|
+
return { file: prefix ? `${prefix}/${f}` : f, path: f, score: r(score), tier: tierOf(score), commits: s.commits, bug_fixes: s.bug, security_fixes: s.security, dependents: fan.get(f) || 0,
|
|
88
103
|
factors: { churn: r(factors.churn), bug_density: r(factors.bug_density), fan_out: r(factors.fan_out), security: r(factors.security) } };
|
|
89
104
|
});
|
|
105
|
+
// New files: too little history for a fair score, so label them "new" and show the riskiest established
|
|
106
|
+
// file they import or are imported by. New code next to a hotspot deserves the hotspot's care.
|
|
107
|
+
const byPath = new Map(rows.map((x) => [x.path, x]));
|
|
108
|
+
for (const x of rows) {
|
|
109
|
+
if (!isNew.has(x.path)) continue;
|
|
110
|
+
x.tier = "new";
|
|
111
|
+
x.committed = !untracked.includes(x.path);
|
|
112
|
+
const near = [...graph.imports.get(x.path), ...graph.dependents.get(x.path)].map((n) => byPath.get(n)).filter((n) => n && !isNew.has(n.path)).sort((a, b) => b.score - a.score)[0];
|
|
113
|
+
if (near) x.near = { file: near.file, score: near.score, tier: near.tier };
|
|
114
|
+
}
|
|
115
|
+
for (const x of rows) delete x.path;
|
|
90
116
|
return { commits, head: git(repo, ["rev-parse", "--short", "HEAD"]).trim(), rows };
|
|
91
117
|
}
|
|
92
118
|
|
|
@@ -110,7 +136,9 @@ export function weightProblems(w) {
|
|
|
110
136
|
export function riskMarkdown({ rows, repos, weights, since, top }) {
|
|
111
137
|
const pct = (x) => `${Math.round(x * 100)}%`;
|
|
112
138
|
const w = weights;
|
|
113
|
-
const
|
|
139
|
+
const fresh = rows.filter((r) => r.tier === "new");
|
|
140
|
+
const freshShown = fresh.filter((r) => r.near).sort((a, b) => b.near.score - a.near.score).slice(0, 15);
|
|
141
|
+
const shown = rows.filter((r) => r.score > 0 && r.tier !== "new").slice(0, Math.min(top, 120 - freshShown.length));
|
|
114
142
|
const lines = [
|
|
115
143
|
"# Risk",
|
|
116
144
|
"<!-- Generated by `spec-driven-dev risk`. Don't edit by hand: re-run it. Skills read this only when risk.enabled is on. -->",
|
|
@@ -123,7 +151,11 @@ export function riskMarkdown({ rows, repos, weights, since, top }) {
|
|
|
123
151
|
"|---|---|---|---|---|---|---|---|",
|
|
124
152
|
...shown.map((r) => `| \`${r.file}\` | ${r.score} | ${r.tier} | ${r.factors.churn} | ${r.factors.bug_density} | ${r.factors.fan_out} | ${r.factors.security} | ${r.commits} (${r.bug_fixes} / ${r.security_fixes}) |`),
|
|
125
153
|
];
|
|
126
|
-
if (
|
|
154
|
+
if (fresh.length) {
|
|
155
|
+
lines.push("", `## New files (${fresh.length})`, "", `Added in the last ${NEW_DAYS} days with fewer than ${MIN_COMMITS} commits, or not committed yet, so there's too little history to score. Give them standard care, and the care of the riskiest file they connect to when that file is high or critical.`);
|
|
156
|
+
if (freshShown.length) lines.push("", "| New file | Connects to | Its score / tier |", "|---|---|---|", ...freshShown.map((r) => `| \`${r.file}\` | \`${r.near.file}\` | ${r.near.score} ${r.near.tier} |`));
|
|
157
|
+
}
|
|
158
|
+
if (rows.length > shown.length + freshShown.length) lines.push("", `${rows.length - shown.length - freshShown.length} more files not shown. \`spec-driven-dev risk --files=a,b\` prints any file's score.`);
|
|
127
159
|
return lines.join("\n") + "\n";
|
|
128
160
|
}
|
|
129
161
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@benjaminthomas/spec-driven-dev",
|
|
3
|
-
"version": "1.11.
|
|
3
|
+
"version": "1.11.2",
|
|
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.11.
|
|
3
|
+
"version": "1.11.2",
|
|
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",
|
|
@@ -66,9 +66,16 @@ Skip this step unless the changes belong to a spec (`{specs_dir}/{feature}/` exi
|
|
|
66
66
|
one line to CONTEXT.md's **Specs** list. Follow the rules in
|
|
67
67
|
references/context-template.md inside the installed `spec-setup` skill folder (not this repo): dated, with a source, added at the end, within the size
|
|
68
68
|
limits, superseded lines moved to `context/archive.md`.
|
|
69
|
+
- **New areas and files.** Compare `git diff --name-status --diff-filter=A <base>...HEAD` with
|
|
70
|
+
CONTEXT.md's **Areas** table. Add a row for any new folder that holds a real area (not a
|
|
71
|
+
single helper), and one line per important new file (an entry point, a shared module others
|
|
72
|
+
will import) to its area file. Stay within the size limits.
|
|
73
|
+
- **Areas that grew.** If the feature changed what an existing area does (new commands,
|
|
74
|
+
responsibilities, or sub-folders), update that row's "What it does" in **Areas** so the
|
|
75
|
+
map stays true. Keep it to one line.
|
|
69
76
|
- Show the context changes in the report so the user can correct them.
|
|
70
77
|
3. If `risk.enabled` is on and the CLI is available, re-run `spec-driven-dev risk` so the scores
|
|
71
|
-
include this work. It's git-only, so it costs no tokens.
|
|
78
|
+
include this work. It's git-only, so it costs no tokens. `risk.md` is gitignored: don't stage it.
|
|
72
79
|
4. No CONTEXT.md? Skip the distilling and suggest running `spec-setup` to create one.
|
|
73
80
|
|
|
74
81
|
### Step 3: Stage Everything
|
|
@@ -57,6 +57,9 @@ creates or modifies a **high** or **critical** file (from the investigation's **
|
|
|
57
57
|
- Its **Tests** must include at least one test aimed at that past failure pattern, not just the new
|
|
58
58
|
behaviour.
|
|
59
59
|
|
|
60
|
+
A task that creates a **new** file next to a high or critical one (it imports it, or the hotspot will
|
|
61
|
+
import it) gets the same treatment, because the new code inherits that file's failure modes.
|
|
62
|
+
|
|
60
63
|
## When to Use
|
|
61
64
|
|
|
62
65
|
- After a planning conversation where requirements and technical details have been discussed
|
|
@@ -225,6 +225,11 @@ After the batch passes review (or the user chooses to proceed):
|
|
|
225
225
|
2. **Update README.md**: change `- [ ]` to `- [x]` for each completed task
|
|
226
226
|
3. **Update memory**: append a log entry (what this batch decided or changed from the spec) and
|
|
227
227
|
overwrite the README's **Resume here** (`Stage: batch {N}/{total} done`, `Next: …`).
|
|
228
|
+
**Refresh risk** after the commit when `risk.enabled` is on and the CLI is available: run
|
|
229
|
+
`spec-driven-dev risk`. It takes well under a second with no model. New files from this batch
|
|
230
|
+
show as `new`, and files they import gain fan-out, so later batches and the reviewers see
|
|
231
|
+
current scores, not the ones from before this feature started. `risk.md` is gitignored (the
|
|
232
|
+
command adds it), so never stage or commit it.
|
|
228
233
|
4. **Commit the batch**:
|
|
229
234
|
```
|
|
230
235
|
git add -A
|
|
@@ -25,7 +25,7 @@ You are reviewing the combined output of Batch {batch_number} of a feature imple
|
|
|
25
25
|
- No duplicate or conflicting definitions
|
|
26
26
|
- Shared state (env vars, config) is consistent
|
|
27
27
|
4. **Code Quality**: Check for security issues, performance problems, and adherence to project conventions.
|
|
28
|
-
5. **Risk**:
|
|
28
|
+
5. **Risk**: new files show as tier `new`, which means unproven, not safe; review them at standard care, or at the tier of the hotspot they connect to. For every task with a filled **Risk** section, check the code handles the past failure pattern it names, and that the test aimed at it exists and would fail without the fix.
|
|
29
29
|
6. **Scope**: Verify each task stayed within its defined scope (Files to Create/Modify). Flag any unexpected file changes.
|
|
30
30
|
|
|
31
31
|
## Verdict
|
|
@@ -80,6 +80,9 @@ clean.
|
|
|
80
80
|
breaking there in the report's **Risk Hotspots** section.
|
|
81
81
|
3. A score is a reason to look harder, never evidence. Hotspot findings still need their
|
|
82
82
|
VERIFIED / INFERRED labels.
|
|
83
|
+
4. **New files** (added in the last 90 days with fewer than 3 commits, or not committed yet) show as tier `new`: too little
|
|
84
|
+
history to score, which is not the same as safe. Give them standard care. When one connects to a
|
|
85
|
+
high or critical file (`Connects to …` in the lookup), treat it at that file's tier.
|
|
83
86
|
|
|
84
87
|
## Invocation
|
|
85
88
|
|
|
@@ -77,7 +77,9 @@ review on a weaker model than the code was written with misses things.
|
|
|
77
77
|
**Risk.** When `risk.enabled` is on and `{specs_dir}/context/risk.md` exists, look up the changed
|
|
78
78
|
files (`spec-driven-dev risk --files=…`). Give **high** and **critical** files a line-by-line read
|
|
79
79
|
of their error paths and callers, check that the tests target their past failure patterns, and
|
|
80
|
-
put the score in any finding on them.
|
|
80
|
+
put the score in any finding on them. New files show as tier `new`: review them at standard care,
|
|
81
|
+
or at the tier of the high or critical file they connect to. When risk is `off`, skip this and
|
|
82
|
+
don't mention it.
|
|
81
83
|
4. **Run the checks.**4. **Run the checks.** Run `commands.lint`, `typecheck`, and `test` once if verify didn't just run
|
|
82
84
|
them on this exact commit. A failing check is a blocker.
|
|
83
85
|
|
|
@@ -120,7 +120,7 @@ Skip if `{specs_dir}/CONTEXT.md` already exists (offer a refresh instead, see `r
|
|
|
120
120
|
Keep the index to 60 lines and area files to 150.
|
|
121
121
|
3. **Risk scores.** For an existing project, run `spec-driven-dev risk` (or
|
|
122
122
|
`npx -y @benjaminthomas/spec-driven-dev risk`) to write `{specs_dir}/context/risk.md`. It uses
|
|
123
|
-
git only, no model. Skip it for a new project with no history. Mention the hotspot count in the
|
|
123
|
+
git only, no model, and adds `risk.md` to `.gitignore` because it's regenerated on every run. Skip it for a new project with no history. Mention the hotspot count in the
|
|
124
124
|
report. Set `risk.enabled: off` only if the user doesn't want it.
|
|
125
125
|
4. **Offer, don't do:** ask whether to add this one line to `CLAUDE.md` / `AGENTS.md` (whichever
|
|
126
126
|
the project uses), so hosts reload the pointer after compaction and in every new session:
|