@benjaminthomas/spec-driven-dev 1.8.0 → 1.11.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/LICENSE +1 -1
- package/README.md +220 -63
- package/bin/cli.mjs +203 -19
- package/bin/risk-eval.mjs +152 -0
- package/bin/risk.mjs +137 -0
- package/package.json +4 -3
- package/plugin/.claude-plugin/icon.png +0 -0
- package/plugin/.claude-plugin/plugin.json +20 -0
- package/{skills → plugin/skills}/spec-checkpoint/SKILL.md +25 -0
- package/{skills → plugin/skills}/spec-create/SKILL.md +30 -1
- package/plugin/skills/spec-create/references/log-template.md +27 -0
- package/{skills → plugin/skills}/spec-create/references/readme-template.md +12 -0
- package/{skills → plugin/skills}/spec-create/references/requirements-template.md +12 -4
- package/{skills → plugin/skills}/spec-create/references/task-template.md +18 -0
- package/{skills → plugin/skills}/spec-implement/SKILL.md +25 -6
- package/{skills → plugin/skills}/spec-implement/references/review-prompt-template.md +2 -1
- package/{skills → plugin/skills}/spec-investigate/SKILL.md +45 -5
- package/{skills → plugin/skills}/spec-investigate/references/report-template.md +12 -0
- package/plugin/skills/spec-review/SKILL.md +135 -0
- package/plugin/skills/spec-review/references/report-template.md +44 -0
- package/{skills → plugin/skills}/spec-setup/SKILL.md +54 -9
- package/plugin/skills/spec-setup/references/audit.md +71 -0
- package/{skills → plugin/skills}/spec-setup/references/config-template.yml +19 -2
- package/plugin/skills/spec-setup/references/context-template.md +83 -0
- package/{skills → plugin/skills}/spec-ship/SKILL.md +10 -0
- package/{skills → plugin/skills}/spec-verify/SKILL.md +10 -2
- /package/{skills → plugin/skills}/generative-seo/SKILL.md +0 -0
- /package/{skills → plugin/skills}/generative-seo/assets/citation-log-template.md +0 -0
- /package/{skills → plugin/skills}/generative-seo/assets/competitor-analysis-template.md +0 -0
- /package/{skills → plugin/skills}/generative-seo/assets/content-plan-template.md +0 -0
- /package/{skills → plugin/skills}/generative-seo/assets/geo-config-template.md +0 -0
- /package/{skills → plugin/skills}/generative-seo/assets/geo-playbook-template.md +0 -0
- /package/{skills → plugin/skills}/generative-seo/assets/social-queue-template.md +0 -0
- /package/{skills → plugin/skills}/generative-seo/references/geo-methodology.md +0 -0
- /package/{skills → plugin/skills}/spec-create/references/action-required-template.md +0 -0
- /package/{skills → plugin/skills}/spec-create/references/complexity-rubric.md +0 -0
- /package/{skills → plugin/skills}/spec-implement/references/coder-prompt-template.md +0 -0
- /package/{skills → plugin/skills}/spec-implement/references/fix-prompt-template.md +0 -0
- /package/{skills → plugin/skills}/spec-verify/references/report-template.md +0 -0
package/bin/cli.mjs
CHANGED
|
@@ -5,19 +5,24 @@
|
|
|
5
5
|
// spec-driven-dev install + project setup (interactive)
|
|
6
6
|
// spec-driven-dev setup project setup only
|
|
7
7
|
// spec-driven-dev doctor check .spec-driven.yml against the real repos (exit 1 on problems)
|
|
8
|
-
// spec-driven-dev status list specs with unfinished
|
|
8
|
+
// spec-driven-dev status list specs with unfinished work and each one's "Resume here" next step
|
|
9
|
+
// spec-driven-dev context print specs/CONTEXT.md; --areas=billing,api prints only those area files
|
|
10
|
+
// spec-driven-dev risk GitRisk: score files from git history into specs/context/risk.md (--files=a,b --json --top=N --since=…)
|
|
11
|
+
// spec-driven-dev settings switch features on or off from a menu (GitRisk, run-through); or --risk=on|off --after-approval=…
|
|
12
|
+
// spec-driven-dev risk-eval U8 experiment: run tickets with risk on vs off and compare agent behaviour (--tickets=<file> --runs=N --model=… --arms=on,off --timeout=20)
|
|
9
13
|
// flags: --yes --global --tools=claude,cursor --skills=spec-create,... --no-setup --dir=<path>
|
|
10
14
|
// --layout=single|monorepo|multi-repo --repos=~/Projects/frontend,/srv/backend --workspace=<config dir>
|
|
11
|
-
// --packages=apps/web,packages/ui --after-approval=stop|run-through --version
|
|
15
|
+
// --packages=apps/web,packages/ui --after-approval=stop|run-through --project=new|existing --risk=on|off --version
|
|
12
16
|
import fs from "node:fs";
|
|
13
17
|
import os from "node:os";
|
|
14
18
|
import path from "node:path";
|
|
15
19
|
import { execFileSync } from "node:child_process";
|
|
16
20
|
import prompts from "prompts"; // arrow-key menus that work in PowerShell, cmd, macOS and Linux terminals
|
|
17
21
|
import { fileURLToPath } from "node:url";
|
|
22
|
+
import { scoreRepo, riskMarkdown, normWeights, weightProblems, setConfigValue } from "./risk.mjs";
|
|
18
23
|
|
|
19
24
|
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
20
|
-
const SKILLS_DIR = path.join(ROOT, "skills");
|
|
25
|
+
const SKILLS_DIR = path.join(ROOT, "plugin", "skills");
|
|
21
26
|
const HOME = process.env.SPEC_DRIVEN_HOME || os.homedir(); // overridable for tests
|
|
22
27
|
const ALL_SKILLS = fs.readdirSync(SKILLS_DIR).filter((d) => fs.existsSync(path.join(SKILLS_DIR, d, "SKILL.md")));
|
|
23
28
|
|
|
@@ -33,7 +38,7 @@ const args = process.argv.slice(2);
|
|
|
33
38
|
const flag = (n) => args.includes(`--${n}`);
|
|
34
39
|
const opt = (n) => args.find((a) => a.startsWith(`--${n}=`))?.split("=")[1];
|
|
35
40
|
const command = args.find((a) => !a.startsWith("-")) || "install";
|
|
36
|
-
const YES = flag("yes") || flag("y") || !process.stdin.isTTY || ["doctor", "status"].includes(command);
|
|
41
|
+
const YES = flag("yes") || flag("y") || !process.stdin.isTTY || ["doctor", "status", "context", "risk", "risk-eval"].includes(command);
|
|
37
42
|
const DIR = path.resolve(opt("dir") || process.cwd());
|
|
38
43
|
|
|
39
44
|
if (flag("version") || flag("v")) {
|
|
@@ -42,22 +47,23 @@ if (flag("version") || flag("v")) {
|
|
|
42
47
|
}
|
|
43
48
|
// No terminal to answer in (e.g. `!` in Claude Code, an IDE task, a pipe) and nothing passed explicitly:
|
|
44
49
|
// stop rather than write a config from guesses.
|
|
45
|
-
const ANSWER_FLAGS = ["yes", "y", "layout", "repos", "packages", "tools", "workspace", "after-approval", "skills", "global", "no-setup"];
|
|
46
|
-
if (["install", "setup"].includes(command) && !process.stdin.isTTY && !args.some((a) => ANSWER_FLAGS.some((f) => a === `--${f}` || a.startsWith(`--${f}=`)))) {
|
|
47
|
-
console.log(`✗ No interactive terminal, so setup can't ask its questions. Nothing was changed.
|
|
50
|
+
const ANSWER_FLAGS = ["risk", "yes", "y", "layout", "repos", "packages", "tools", "workspace", "after-approval", "skills", "global", "no-setup", "project"];
|
|
51
|
+
if (["install", "setup", "settings"].includes(command) && !process.stdin.isTTY && !args.some((a) => ANSWER_FLAGS.some((f) => a === `--${f}` || a.startsWith(`--${f}=`)))) {
|
|
52
|
+
console.log(`✗ No interactive terminal, so ${command === "settings" ? "the settings menu can't open" : "setup can't ask its questions"}. Nothing was changed.
|
|
48
53
|
|
|
49
54
|
This happens when it runs through another program: a "!" command in Claude Code, an editor task,
|
|
50
55
|
or piped input. Either:
|
|
51
56
|
• run it yourself in a terminal window (Terminal, iTerm, or your editor's terminal panel):
|
|
52
|
-
npx @benjaminthomas/spec-driven-dev@latest ${command === "
|
|
57
|
+
npx @benjaminthomas/spec-driven-dev@latest ${command === "install" ? "" : command}
|
|
53
58
|
• or give the answers as flags, for example:
|
|
54
|
-
npx @benjaminthomas/spec-driven-dev@latest setup --layout=multi-repo --repos=~/Projects/frontend,~/Projects/backend
|
|
59
|
+
npx @benjaminthomas/spec-driven-dev@latest ${command === "settings" ? "settings --risk=off" : "setup --layout=multi-repo --repos=~/Projects/frontend,~/Projects/backend"}
|
|
55
60
|
(add --yes to accept everything it detects)`);
|
|
56
61
|
process.exit(1);
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
if (flag("help") || flag("h")) {
|
|
60
|
-
|
|
65
|
+
const lines = fs.readFileSync(fileURLToPath(import.meta.url), "utf8").split("\n").slice(1);
|
|
66
|
+
console.log(lines.slice(0, lines.findIndex((l) => !l.startsWith("//"))).join("\n").replace(/^\/\/ ?/gm, "")); // the whole header comment
|
|
61
67
|
process.exit(0);
|
|
62
68
|
}
|
|
63
69
|
|
|
@@ -109,7 +115,7 @@ async function install() {
|
|
|
109
115
|
|
|
110
116
|
// Claude Code users who already have the plugin would get every skill twice.
|
|
111
117
|
const plugins = path.join(HOME, ".claude/plugins/installed_plugins.json");
|
|
112
|
-
if (tools.includes("claude") && !opt("tools") && fs.existsSync(plugins) && fs.readFileSync(plugins, "utf8")
|
|
118
|
+
if (tools.includes("claude") && !opt("tools") && fs.existsSync(plugins) && /"(spec-driven-dev|ticket-to-pr)@/.test(fs.readFileSync(plugins, "utf8"))) {
|
|
113
119
|
console.log("• Claude Code already has the spec-driven-dev plugin, so it's skipped here (update it with /plugin).");
|
|
114
120
|
tools = tools.filter((t) => t !== "claude");
|
|
115
121
|
}
|
|
@@ -239,7 +245,9 @@ function toYaml(c) {
|
|
|
239
245
|
.map(([tool, m]) => ` ${tool}:\n` + MODEL_KEYS.map((t) => ` ${t}: ${m[t]}\n`).join("")).join("");
|
|
240
246
|
out += "effort: # effort / reasoning level per tier (Claude Code, Codex): low | medium | high | xhigh | max | default\n"
|
|
241
247
|
+ MODEL_KEYS.map((t) => ` ${t}: ${EFFORT_DEFAULTS[t]}\n`).join("");
|
|
242
|
-
out += `
|
|
248
|
+
out += `context: # project memory: {specs_dir}/CONTEXT.md (index) + context/<area>.md\n project: ${c.project || "existing"} # existing | new\n graph: auto # auto = use graphify if installed | off\n`;
|
|
249
|
+
out += "risk: # GitRisk: historical risk per file from git (spec-driven-dev risk). Weights are a hypothesis to calibrate\n enabled: on # on | off (off = skills ignore risk; risk-eval compares the two)\n since: \"2 years ago\"\n weights:\n churn: 0.30\n bug_density: 0.35\n fan_out: 0.20\n security: 0.15\n";
|
|
250
|
+
out += `pipeline:\n after_approval: ${c.afterApproval || "stop"} # stop | run-through (implement → verify → review → checkpoint; ship always asks)\n`;
|
|
243
251
|
return out + `specs_dir: ${y(c.specsDir)}\n`;
|
|
244
252
|
}
|
|
245
253
|
|
|
@@ -381,7 +389,16 @@ async function choosePackages(d) {
|
|
|
381
389
|
return { root, repos: pkgs };
|
|
382
390
|
}
|
|
383
391
|
|
|
392
|
+
// shortcut: counts only (no model here), so a big template repo with 1 commit reads as existing; the spec-setup skill confirms.
|
|
393
|
+
function detectProject(dirs) {
|
|
394
|
+
const n = (d, a) => Number(run("git", a, d).split("\n").filter(Boolean).length);
|
|
395
|
+
const commits = dirs.reduce((t, d) => t + (Number(run("git", ["rev-list", "--count", "HEAD"], d)) || 0), 0);
|
|
396
|
+
const files = dirs.reduce((t, d) => t + n(d, ["ls-files"]), 0);
|
|
397
|
+
return { commits, files, kind: commits >= 20 || files >= 50 ? "existing" : "new" };
|
|
398
|
+
}
|
|
399
|
+
|
|
384
400
|
async function setup() {
|
|
401
|
+
if (opt("project") && !["new", "existing"].includes(opt("project"))) throw new Error("--project must be new or existing");
|
|
385
402
|
if (opt("after-approval") && !["stop", "run-through"].includes(opt("after-approval"))) throw new Error("--after-approval must be stop or run-through");
|
|
386
403
|
console.log("\nProject setup — detecting how this project works…");
|
|
387
404
|
const d = detectLayout(DIR);
|
|
@@ -439,11 +456,17 @@ async function setup() {
|
|
|
439
456
|
const specsDir = await askText("Folder for specs (one sub-folder per feature)", "specs");
|
|
440
457
|
const afterApproval = opt("after-approval") || await askSelect("After you approve a spec…", [
|
|
441
458
|
{ title: "Stop after each stage", value: "stop", description: "you start implement, verify and checkpoint yourself" },
|
|
442
|
-
{ title: "Run straight through", value: "run-through", description: "implement → verify → commit in one go; stops at any failure, never pushes without asking" },
|
|
459
|
+
{ title: "Run straight through", value: "run-through", description: "implement → verify → review → commit in one go; stops at any failure or review blocker, never pushes without asking" },
|
|
443
460
|
], "stop");
|
|
444
461
|
|
|
462
|
+
const pd = detectProject(repoDirs.filter(isRepo));
|
|
463
|
+
const project = opt("project") || await askSelect("Is this a new project or an existing one?", [
|
|
464
|
+
{ title: `Existing project${pd.kind === "existing" ? " (detected)" : ""}`, value: "existing", description: `${pd.commits} commits, ${pd.files} files — your AI tool audits it once and saves what it learns` },
|
|
465
|
+
{ title: `New project${pd.kind === "new" ? " (detected)" : ""}`, value: "new", description: "nothing to audit — your AI tool asks about the stack and conventions you want" },
|
|
466
|
+
], pd.kind);
|
|
467
|
+
|
|
445
468
|
const config = {
|
|
446
|
-
layout, tracker: { type: trackerType, key, url }, specsDir, devUrl: "", afterApproval,
|
|
469
|
+
project, layout, tracker: { type: trackerType, key, url }, specsDir, devUrl: "", afterApproval,
|
|
447
470
|
git: { remote: "origin", base_branch: base, pr_target: pr, branch_name: branchName, commit_style: commitStyle },
|
|
448
471
|
commands: layout === "single" ? commandsFor(repoDirs[0]) : commandsFor(root),
|
|
449
472
|
repos: repos.map((r) => {
|
|
@@ -456,7 +479,9 @@ async function setup() {
|
|
|
456
479
|
if (layout === "multi-repo") register(real(file), repos.map((r) => real(path.resolve(root, r))));
|
|
457
480
|
console.log(`✓ Wrote ${file}`);
|
|
458
481
|
console.log(isRepo(root) ? " Commit it so your team shares the same rules." : " This folder isn't a git repo, so the config is personal to this machine.");
|
|
459
|
-
console.log(
|
|
482
|
+
console.log(project === "existing"
|
|
483
|
+
? ` Next: ask your AI tool to "run spec-setup". It audits the ${layout === "multi-repo" ? "repos" : "repo"}, asks a few questions, and saves ${specsDir}/CONTEXT.md.`
|
|
484
|
+
: ` Next: ask your AI tool to "run spec-setup". It asks about your stack and conventions and saves ${specsDir}/CONTEXT.md.`);
|
|
460
485
|
}
|
|
461
486
|
|
|
462
487
|
// ---------- reading the config ----------
|
|
@@ -496,10 +521,20 @@ function parseYaml(text) {
|
|
|
496
521
|
return root;
|
|
497
522
|
}
|
|
498
523
|
|
|
524
|
+
// A multi-repo config applies only in its own folder and inside the repos it lists, so a repo that
|
|
525
|
+
// merely sits under that folder (e.g. ~/Projects/other-project) doesn't pick up the workspace's rules.
|
|
526
|
+
function configApplies(f, dir) {
|
|
527
|
+
let c;
|
|
528
|
+
try { c = parseYaml(fs.readFileSync(f, "utf8")); } catch { return true; }
|
|
529
|
+
if (c.layout !== "multi-repo" || !Array.isArray(c.repos)) return true;
|
|
530
|
+
const here = real(dir), base = path.dirname(f);
|
|
531
|
+
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); });
|
|
532
|
+
}
|
|
533
|
+
|
|
499
534
|
function findConfig(dir) {
|
|
500
535
|
for (let d = dir; ; d = path.dirname(d)) {
|
|
501
536
|
const f = path.join(d, ".spec-driven.yml");
|
|
502
|
-
if (fs.existsSync(f)) return f;
|
|
537
|
+
if (fs.existsSync(f) && configApplies(f, dir)) return f;
|
|
503
538
|
if (d === path.dirname(d) || d === HOME) break;
|
|
504
539
|
}
|
|
505
540
|
// Not above us: a multi-repo workspace elsewhere may list this repo.
|
|
@@ -554,6 +589,15 @@ function doctor() {
|
|
|
554
589
|
}
|
|
555
590
|
const specs = path.join(root, c.specs_dir || "specs");
|
|
556
591
|
if (!fs.existsSync(specs)) notes.push(`${c.specs_dir || "specs"}/ doesn't exist yet — created on first spec`);
|
|
592
|
+
if (c.context?.project && !["new", "existing"].includes(c.context.project)) problems.push(`context.project "${c.context.project}" must be new or existing`);
|
|
593
|
+
if (c.context?.graph && !["auto", "off"].includes(c.context.graph)) problems.push(`context.graph "${c.context.graph}" must be auto or off`);
|
|
594
|
+
const repoRoots = c.layout === "multi-repo" ? (c.repos || []).map((r) => path.resolve(root, expandHome(r.path))) : [root];
|
|
595
|
+
checkContext(specs, c.specs_dir || "specs", repoRoots, problems, notes);
|
|
596
|
+
if (c.risk) {
|
|
597
|
+
if (c.risk.enabled && !["on", "off"].includes(c.risk.enabled)) problems.push(`risk.enabled "${c.risk.enabled}" must be on or off`);
|
|
598
|
+
problems.push(...weightProblems(c.risk.weights));
|
|
599
|
+
if (c.risk.enabled !== "off" && !fs.existsSync(path.join(specs, "context", "risk.md"))) notes.push(`risk is on but ${c.specs_dir || "specs"}/context/risk.md doesn't exist — run: spec-driven-dev risk`);
|
|
600
|
+
}
|
|
557
601
|
console.log(`Checked ${file}`);
|
|
558
602
|
for (const n of notes) console.log(` • ${n}`);
|
|
559
603
|
for (const p of problems) console.log(` ✗ ${p}`);
|
|
@@ -561,7 +605,140 @@ function doctor() {
|
|
|
561
605
|
else console.log("✓ Config matches the repos.");
|
|
562
606
|
}
|
|
563
607
|
|
|
564
|
-
|
|
608
|
+
const LIMITS = { "CONTEXT.md": 60, area: 150 };
|
|
609
|
+
// Context lines point at code as `path` or `path:line`; flag the ones that no longer exist anywhere in the repos.
|
|
610
|
+
function checkContext(specs, specsName, repoRoots, problems, notes) {
|
|
611
|
+
const index = path.join(specs, "CONTEXT.md");
|
|
612
|
+
if (!fs.existsSync(index)) { notes.push(`no ${specsName}/CONTEXT.md yet — ask your AI tool to "run spec-setup" to audit the project`); return; }
|
|
613
|
+
const areaDir = path.join(specs, "context");
|
|
614
|
+
const files = [index, ...(fs.existsSync(areaDir) ? fs.readdirSync(areaDir).filter((f) => f.endsWith(".md") && f !== "archive.md").map((f) => path.join(areaDir, f)) : [])];
|
|
615
|
+
for (const f of files) {
|
|
616
|
+
const text = fs.readFileSync(f, "utf8");
|
|
617
|
+
const rel = path.relative(specs, f), max = f === index ? LIMITS["CONTEXT.md"] : LIMITS.area, lines = text.split("\n").length;
|
|
618
|
+
if (lines > max) problems.push(`${specsName}/${rel} is ${lines} lines (limit ${max}) — merge duplicates or move old entries to context/archive.md`);
|
|
619
|
+
for (const [, p] of text.matchAll(/`([\w.@-]+(?:\/[\w.@-]+)+\/?)(?::\d+)?`/g)) {
|
|
620
|
+
if (p.startsWith("context/") || /^(?:https?|www|@)/.test(p)) continue; // @scope/pkg is a package name, not a path
|
|
621
|
+
if (!repoRoots.some((r) => fs.existsSync(path.join(r, p)))) notes.push(`${specsName}/${rel} mentions \`${p}\`, which no longer exists — stale context?`);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function context() {
|
|
627
|
+
const file = findConfig(DIR);
|
|
628
|
+
const root = file ? path.dirname(file) : DIR;
|
|
629
|
+
const specsName = (file && parseYaml(fs.readFileSync(file, "utf8")).specs_dir) || "specs";
|
|
630
|
+
const index = path.join(root, specsName, "CONTEXT.md");
|
|
631
|
+
const areas = splitList(opt("areas"));
|
|
632
|
+
if (!areas.length) {
|
|
633
|
+
if (!fs.existsSync(index)) { console.log(`No ${specsName}/CONTEXT.md yet. Ask your AI tool to "run spec-setup" to create it.`); return; }
|
|
634
|
+
process.stdout.write(fs.readFileSync(index, "utf8"));
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
for (const a of areas) {
|
|
638
|
+
const f = path.join(root, specsName, "context", `${a.replace(/[^\w-]/g, "")}.md`);
|
|
639
|
+
if (fs.existsSync(f)) process.stdout.write(fs.readFileSync(f, "utf8").replace(/\n*$/, "\n\n"));
|
|
640
|
+
else console.log(`(no ${specsName}/context/${a}.md — nothing recorded for this area yet)\n`);
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function loadConfig() {
|
|
645
|
+
const file = findConfig(DIR);
|
|
646
|
+
return { file, root: file ? path.dirname(file) : gitTop(DIR), c: file ? parseYaml(fs.readFileSync(file, "utf8")) : {} };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function risk() {
|
|
650
|
+
const { root, c } = loadConfig();
|
|
651
|
+
const bad = weightProblems(c.risk?.weights);
|
|
652
|
+
if (bad.length) throw new Error(bad.join("; "));
|
|
653
|
+
const weights = normWeights(c.risk?.weights), since = opt("since") || c.risk?.since || "2 years ago";
|
|
654
|
+
const repoDirs = c.layout === "multi-repo" ? (c.repos || []).map((r) => ({ dir: path.resolve(root, expandHome(r.path)), name: r.path })) : [{ dir: root, name: path.basename(root) }];
|
|
655
|
+
const scored = repoDirs.filter((r) => isRepo(r.dir)).map((r) => ({ ...r, ...scoreRepo(r.dir, { since, weights, prefix: c.layout === "multi-repo" ? r.name : "" }) }));
|
|
656
|
+
if (!scored.length) throw new Error(`No git repo found at ${root}`);
|
|
657
|
+
const rows = scored.flatMap((s) => s.rows).sort((a, b) => b.score - a.score || b.commits - a.commits);
|
|
658
|
+
const wanted = splitList(opt("files"));
|
|
659
|
+
if (wanted.length) {
|
|
660
|
+
const norm = (f) => f.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
661
|
+
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
|
+
if (flag("json")) console.log(JSON.stringify(hits, null, 2));
|
|
663
|
+
else for (const h of hits) console.log(h.score === null ? `${h.file}: no history (new, untracked or not code)` : `${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
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
if (flag("json")) { console.log(JSON.stringify({ weights, since, rows }, null, 2)); return; }
|
|
667
|
+
const specs = path.join(root, c.specs_dir || "specs");
|
|
668
|
+
fs.mkdirSync(path.join(specs, "context"), { recursive: true });
|
|
669
|
+
const out = path.join(specs, "context", "risk.md");
|
|
670
|
+
fs.writeFileSync(out, riskMarkdown({ rows, repos: scored, weights, since, top: Number(opt("top")) || 40 }));
|
|
671
|
+
const count = (t) => rows.filter((r) => r.tier === t).length;
|
|
672
|
+
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}`);
|
|
675
|
+
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
|
+
}
|
|
677
|
+
|
|
678
|
+
async function riskEvalCmd() {
|
|
679
|
+
const { file, root, c } = loadConfig();
|
|
680
|
+
const repo = gitTop(DIR);
|
|
681
|
+
if (c.layout === "multi-repo" && real(repo) === real(root)) throw new Error("risk-eval runs on one repo: run it inside one of the workspace's repos");
|
|
682
|
+
const ticketsFile = opt("tickets");
|
|
683
|
+
if (!ticketsFile) throw new Error("Pass --tickets=<file> with one ticket per line");
|
|
684
|
+
const tickets = fs.readFileSync(path.resolve(DIR, ticketsFile), "utf8").split("\n").map((l) => l.replace(/^\s*[-*]\s+/, "").trim()).filter((l) => l && !l.startsWith("#"));
|
|
685
|
+
const claude = opt("claude") || "claude";
|
|
686
|
+
if (!opt("claude") && !onPath("claude")) throw new Error("risk-eval drives Claude Code headlessly; install it first (https://code.claude.com)");
|
|
687
|
+
const { riskEval } = await import("./risk-eval.mjs");
|
|
688
|
+
const runs = Number(opt("runs")) || 1, stages = splitList(opt("stages") || "investigate,create");
|
|
689
|
+
console.log(`GitRisk U8 experiment: ${tickets.length} ticket(s) × ${runs} run(s) × 2 arms (risk on / off) on ${root}`);
|
|
690
|
+
// A workspace config's repo paths don't resolve inside a lone clone, so a multi-repo member gets a single-repo config.
|
|
691
|
+
const configText = c.layout === "multi-repo" ? "version: 1\nlayout: single\n" : file ? fs.readFileSync(file, "utf8") : "";
|
|
692
|
+
const arms = splitList(opt("arms") || "on,off");
|
|
693
|
+
if (!arms.length || arms.some((a) => !["on", "off"].includes(a))) throw new Error("--arms must be on, off, or on,off");
|
|
694
|
+
const { dir } = await riskEval({ repo, tickets, arms, timeoutMin: Number(opt("timeout")) || 20, runs, model: opt("model") || "sonnet", stages, claude, out: path.resolve(DIR, opt("out") || "experiments"),
|
|
695
|
+
pluginDir: path.join(ROOT, "plugin"), since: c.risk?.since || "2 years ago", weights: c.risk?.weights, configText });
|
|
696
|
+
console.log(`✓ Report: ${path.join(dir, "report.md")}`);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Plain-language switches, so anyone can change them without editing YAML.
|
|
700
|
+
const SETTINGS = [
|
|
701
|
+
{ block: "risk", key: "enabled", flag: "risk", name: "GitRisk", about: "extra care on files with a history of bugs",
|
|
702
|
+
get: (c) => c.risk?.enabled || "on", options: [
|
|
703
|
+
{ value: "on", title: "On", description: "investigations, plans and reviews look harder at historically risky files" },
|
|
704
|
+
{ value: "off", title: "Off", description: "the skills ignore risk completely" }] },
|
|
705
|
+
{ block: "pipeline", key: "after_approval", flag: "after-approval", name: "After you approve a spec", about: "how far it goes on its own",
|
|
706
|
+
get: (c) => c.pipeline?.after_approval || "stop", options: [
|
|
707
|
+
{ value: "stop", title: "Stop after each stage", description: "you start implement, verify, review and commit yourself" },
|
|
708
|
+
{ value: "run-through", title: "Run straight through", description: "implement → verify → review → commit; stops at any failure, never pushes" }] },
|
|
709
|
+
];
|
|
710
|
+
|
|
711
|
+
async function settings() {
|
|
712
|
+
const file = findConfig(DIR);
|
|
713
|
+
if (!file) throw new Error(`No .spec-driven.yml in ${DIR} or above. Run: spec-driven-dev setup`);
|
|
714
|
+
let text = fs.readFileSync(file, "utf8");
|
|
715
|
+
const label = (s, v) => s.options.find((o) => o.value === v)?.title || v;
|
|
716
|
+
const save = (s, v) => {
|
|
717
|
+
if (!s.options.some((o) => o.value === v)) throw new Error(`--${s.flag} must be ${s.options.map((o) => o.value).join(" or ")}`);
|
|
718
|
+
text = setConfigValue(text, s.block, s.key, v);
|
|
719
|
+
fs.writeFileSync(file, text);
|
|
720
|
+
console.log(`✓ ${s.name}: ${label(s, v)} (saved in ${file})`);
|
|
721
|
+
};
|
|
722
|
+
const given = SETTINGS.filter((s) => opt(s.flag));
|
|
723
|
+
if (given.length) given.forEach((s) => save(s, opt(s.flag)));
|
|
724
|
+
else for (;;) {
|
|
725
|
+
const c = parseYaml(text);
|
|
726
|
+
const pick = await askSelect("Settings: pick one to change", [
|
|
727
|
+
...SETTINGS.map((s) => ({ title: `${s.name}: ${label(s, s.get(c))}`, description: s.about, value: s })),
|
|
728
|
+
{ title: "Done", value: null }], null);
|
|
729
|
+
if (!pick) break;
|
|
730
|
+
const v = await askSelect(`${pick.name}`, pick.options, pick.get(c));
|
|
731
|
+
if (v !== pick.get(c)) save(pick, v);
|
|
732
|
+
}
|
|
733
|
+
const c = parseYaml(text);
|
|
734
|
+
if (c.risk?.enabled !== "off" && !fs.existsSync(path.join(path.dirname(file), c.specs_dir || "specs", "context", "risk.md"))) {
|
|
735
|
+
if (given.length || !(await askConfirm("GitRisk is on but files haven't been scored yet. Score them now? (uses git only, takes seconds)", true)))
|
|
736
|
+
console.log(" GitRisk is on, but nothing is scored yet. Run: spec-driven-dev risk");
|
|
737
|
+
else risk();
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
// Silent unless something is unfinished, so it's cheap to run at the start of a session.
|
|
565
742
|
function status() {
|
|
566
743
|
const file = findConfig(DIR);
|
|
567
744
|
const root = file ? path.dirname(file) : DIR;
|
|
@@ -575,9 +752,12 @@ function status() {
|
|
|
575
752
|
continue;
|
|
576
753
|
}
|
|
577
754
|
const t = fs.readFileSync(readme, "utf8");
|
|
755
|
+
const next = t.match(/^## Resume here[\s\S]*?^- \*\*Next:\*\*\s*(.+)$/m)?.[1]?.trim();
|
|
578
756
|
const done = (t.match(/^\s*- \[x\]/gim) || []).length, todo = (t.match(/^\s*- \[ \]/gm) || []).length;
|
|
579
|
-
if (todo) lines.push(`- ${specsName}/${name}: ${done}/${done + todo} tasks done → next: spec-implement`);
|
|
757
|
+
if (todo) lines.push(`- ${specsName}/${name}: ${done}/${done + todo} tasks done → next: ${next || "spec-implement"}`);
|
|
580
758
|
else if (done && !fs.existsSync(path.join(specs, name, "verification-report.md"))) lines.push(`- ${specsName}/${name}: all tasks done, not verified → next: spec-verify`);
|
|
759
|
+
else if (done && !fs.existsSync(path.join(specs, name, "review.md"))) lines.push(`- ${specsName}/${name}: verified, not reviewed → next: spec-review`);
|
|
760
|
+
else if (done && /\[blocker\][^\n]*\n(?:(?!###)[^\n]*\n)*?- \*\*Status:\*\* open/.test(fs.readFileSync(path.join(specs, name, "review.md"), "utf8"))) lines.push(`- ${specsName}/${name}: review has open blockers → next: fix them (spec-review)`);
|
|
581
761
|
}
|
|
582
762
|
if (lines.length) console.log(`spec-driven-dev: unfinished work in ${root}\n${lines.join("\n")}`);
|
|
583
763
|
}
|
|
@@ -585,11 +765,15 @@ function status() {
|
|
|
585
765
|
try {
|
|
586
766
|
if (command === "doctor") doctor();
|
|
587
767
|
else if (command === "status") status();
|
|
768
|
+
else if (command === "context") context();
|
|
769
|
+
else if (command === "risk") risk();
|
|
770
|
+
else if (command === "settings") await settings();
|
|
771
|
+
else if (command === "risk-eval") await riskEvalCmd();
|
|
588
772
|
else if (command === "install") {
|
|
589
773
|
await install();
|
|
590
774
|
if (!flag("no-setup") && (await askConfirm("Set up this project now?", true))) await setup();
|
|
591
775
|
} else if (command === "setup") await setup();
|
|
592
|
-
else throw new Error(`Unknown command "${command}" — use install (default), setup, doctor, or
|
|
776
|
+
else throw new Error(`Unknown command "${command}" — use install (default), setup, settings, doctor, status, context, risk, or risk-eval`);
|
|
593
777
|
if (["install", "setup"].includes(command)) console.log('\nNext: in your AI tool, say "investigate <ticket>" or "create a spec".');
|
|
594
778
|
} catch (e) {
|
|
595
779
|
console.error(`✗ ${e.message}`);
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// GitRisk U8 experiment: does giving the agent the risk signal change what it does?
|
|
2
|
+
// Each ticket runs twice in fresh clones of the repo, risk ON and risk OFF, with the same model and
|
|
3
|
+
// prompt. We then measure behaviour from what the agent read and wrote, and keep a dated log.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import os from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
8
|
+
import { scoreRepo, riskMarkdown, normWeights, setConfigValue } from "./risk.mjs";
|
|
9
|
+
|
|
10
|
+
const TIER_RANK = { light: 1, standard: 2, deep: 3 };
|
|
11
|
+
const sh = (cwd, cmd, args) => execFileSync(cmd, args, { cwd, stdio: ["ignore", "pipe", "pipe"], maxBuffer: 512 * 1024 * 1024 }).toString();
|
|
12
|
+
|
|
13
|
+
export const setRiskEnabled = (text, on) => setConfigValue(text, "risk", "enabled", on ? "on" : "off");
|
|
14
|
+
|
|
15
|
+
// Behaviour metrics from the agent's transcript (stream-json) and the spec it wrote.
|
|
16
|
+
export function measure({ transcript, clone, risky, tracked = [] }) {
|
|
17
|
+
const reads = new Set();
|
|
18
|
+
let cost = 0, turns = 0, inTok = 0, outTok = 0, error = "";
|
|
19
|
+
for (const line of transcript.split("\n")) {
|
|
20
|
+
let e;
|
|
21
|
+
try { e = JSON.parse(line); } catch { continue; }
|
|
22
|
+
if (e.type === "assistant") for (const c of e.message?.content || []) {
|
|
23
|
+
// A file counts as looked at when a Read, Grep or Bash call names it (agents often use grep/sed/cat, not Read).
|
|
24
|
+
if (c.type !== "tool_use" || !["Read", "Grep", "Bash", "Glob"].includes(c.name)) continue;
|
|
25
|
+
const text = JSON.stringify(c.input || {}).split(clone + "/").join("").split(clone.replace(/^\/private/, "") + "/").join("");
|
|
26
|
+
for (const f of tracked) if (text.includes(f)) reads.add(f);
|
|
27
|
+
}
|
|
28
|
+
if (e.type === "result") {
|
|
29
|
+
cost = e.total_cost_usd || 0; turns = e.num_turns || 0;
|
|
30
|
+
inTok = (e.usage?.input_tokens || 0) + (e.usage?.cache_read_input_tokens || 0) + (e.usage?.cache_creation_input_tokens || 0);
|
|
31
|
+
outTok = e.usage?.output_tokens || 0;
|
|
32
|
+
if (e.is_error) error = String(e.result || "error").slice(0, 200);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const specsDir = path.join(clone, "specs");
|
|
36
|
+
const feature = (fs.existsSync(specsDir) ? fs.readdirSync(specsDir) : []).find((d) => fs.existsSync(path.join(specsDir, d, "investigation.md")) || fs.existsSync(path.join(specsDir, d, "tasks")));
|
|
37
|
+
const read = (f) => { try { return fs.readFileSync(f, "utf8"); } catch { return ""; } };
|
|
38
|
+
const inv = feature ? read(path.join(specsDir, feature, "investigation.md")) : "";
|
|
39
|
+
const tasks = [];
|
|
40
|
+
const tdir = feature && path.join(specsDir, feature, "tasks");
|
|
41
|
+
if (tdir && fs.existsSync(tdir)) for (const t of fs.readdirSync(tdir).filter((f) => f.endsWith(".md"))) {
|
|
42
|
+
const text = read(path.join(tdir, t));
|
|
43
|
+
const section = (name) => (text.match(new RegExp(`^## ${name}\\s*\\n([\\s\\S]*?)(?=^## |$(?![\\s\\S]))`, "m")) || [])[1] || "";
|
|
44
|
+
const files = [...(section("Files to Create") + section("Files to Modify")).matchAll(/`([^`\s]+\.[\w]+)`/g)].map((m) => m[1]);
|
|
45
|
+
const tests = section("Tests").trim();
|
|
46
|
+
tasks.push({ name: t, files, tier: (text.match(/\*\*Tier:\*\*\s*(light|standard|deep)/i) || [])[1]?.toLowerCase(), hasTests: !!tests && !/^none\b/i.test(tests) });
|
|
47
|
+
}
|
|
48
|
+
const isRisky = (f) => risky.has(f);
|
|
49
|
+
const planFiles = new Set(tasks.flatMap((t) => t.files));
|
|
50
|
+
const riskyPlan = [...planFiles].filter(isRisky);
|
|
51
|
+
const riskyTasks = tasks.filter((t) => t.files.some(isRisky));
|
|
52
|
+
const avg = (xs) => (xs.length ? Math.round((xs.reduce((a, b) => a + b, 0) / xs.length) * 100) / 100 : null);
|
|
53
|
+
return {
|
|
54
|
+
feature: feature || null,
|
|
55
|
+
files_read: reads.size,
|
|
56
|
+
risky_files_read: [...reads].filter(isRisky).length,
|
|
57
|
+
investigation_risky_mentions: [...risky].filter((f) => inv.includes(f)).length,
|
|
58
|
+
plan_files: planFiles.size,
|
|
59
|
+
risky_plan_files: riskyPlan.length,
|
|
60
|
+
risky_files_with_tests: riskyPlan.filter((f) => tasks.some((t) => t.files.includes(f) && t.hasTests)).length,
|
|
61
|
+
tier_on_risky_tasks: avg(riskyTasks.map((t) => TIER_RANK[t.tier]).filter(Boolean)),
|
|
62
|
+
tier_all_tasks: avg(tasks.map((t) => TIER_RANK[t.tier]).filter(Boolean)),
|
|
63
|
+
tasks: tasks.length,
|
|
64
|
+
cost_usd: Math.round(cost * 10000) / 10000, turns, input_tokens: inTok, output_tokens: outTok, error,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const METRICS = [
|
|
69
|
+
["risky_files_read", "Risky files looked at"], ["investigation_risky_mentions", "Risky files named in investigation"],
|
|
70
|
+
["risky_plan_files", "Risky files in the plan"], ["risky_files_with_tests", "…of those, with tests planned"],
|
|
71
|
+
["tier_on_risky_tasks", "Avg tier of tasks on risky files (1–3)"], ["files_read", "Repo files looked at (Read/Grep/Bash)"],
|
|
72
|
+
["tasks", "Tasks"], ["cost_usd", "Cost (USD)"], ["output_tokens", "Output tokens"],
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
function report({ meta, results }) {
|
|
76
|
+
const lines = [`# GitRisk U8 experiment — ${meta.started}`, "",
|
|
77
|
+
"Question: does feeding GitRisk's historical-risk signal into the spec-driven-dev workflow change what the AI agent does?", "",
|
|
78
|
+
`- Repo: \`${meta.repo}\` at ${meta.head}; risky set = ${meta.risky} files scored high or critical (weights ${JSON.stringify(meta.weights)})`,
|
|
79
|
+
`- Model: ${meta.model} · stages: ${meta.stages.join(" → ")} · runs per arm: ${meta.runs} · tickets: ${meta.tickets.length}`,
|
|
80
|
+
"- Arms: **on** = risk.enabled on + specs/context/risk.md present; **off** = risk.enabled off, no risk file. Fresh clone per run, same prompt.",
|
|
81
|
+
"", "## Results (mean per arm)", "", "| Ticket | Metric | Off | On | Δ (on − off) |", "|---|---|---|---|---|"];
|
|
82
|
+
const mean = (rs, k) => { const v = rs.map((r) => r.metrics?.[k]).filter((x) => typeof x === "number"); return v.length ? Math.round((v.reduce((a, b) => a + b, 0) / v.length) * 100) / 100 : null; };
|
|
83
|
+
meta.tickets.forEach((t, i) => {
|
|
84
|
+
const off = results.filter((r) => r.ticket === i && r.arm === "off" && !r.metrics?.error), on = results.filter((r) => r.ticket === i && r.arm === "on" && !r.metrics?.error);
|
|
85
|
+
for (const [k, label] of METRICS) {
|
|
86
|
+
const a = mean(off, k), b = mean(on, k);
|
|
87
|
+
lines.push(`| ${i + 1}. ${t.slice(0, 40).replace(/\|/g, "/")} | ${label} | ${a ?? "—"} | ${b ?? "—"} | ${a !== null && b !== null ? Math.round((b - a) * 100) / 100 : "—"} |`);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
const failed = results.filter((r) => r.metrics?.error || r.exit !== 0);
|
|
91
|
+
lines.push("", "## Runs", "", "| Ticket | Arm | Run | Exit | Feature folder | Error |", "|---|---|---|---|---|---|",
|
|
92
|
+
...results.map((r) => `| ${r.ticket + 1} | ${r.arm} | ${r.run} | ${r.exit} | ${r.metrics?.feature || "—"} | ${(r.metrics?.error || "").replace(/\|/g, "/")} |`));
|
|
93
|
+
lines.push("", "## Caveats", "",
|
|
94
|
+
`- ${meta.runs} run(s) per arm is a pilot, not a significance test. Agent behaviour varies run to run; increase --runs before drawing conclusions.`,
|
|
95
|
+
"- Metrics are proxies read from the transcript and the written spec (files the agent opened, files and tests it planned, task tiers). They don't measure whether the eventual code has fewer bugs.",
|
|
96
|
+
"- The risky set comes from the same repo history the ON arm sees, so a higher \"risky files read\" in ON is expected; the interesting signal is whether that changes the plan (tests, tiers) without raising cost much.",
|
|
97
|
+
`- Failed runs: ${failed.length}. They are excluded from the means but kept in results.json.`);
|
|
98
|
+
return lines.join("\n") + "\n";
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function riskEval(opts) {
|
|
102
|
+
const { repo, tickets, runs = 1, arms = ["on", "off"], timeoutMin = 20, model = "sonnet", stages = ["investigate", "create"], claude = "claude", out, pluginDir, since, weights, configText = "" } = opts;
|
|
103
|
+
if (!tickets.length) throw new Error("No tickets: pass --tickets=<file> with one ticket per line");
|
|
104
|
+
const started = new Date().toISOString().replace(/\.\d+Z$/, "Z");
|
|
105
|
+
const dir = path.join(out, `risk-eval-${started.replace(/[:]/g, "-")}`);
|
|
106
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
107
|
+
const w = normWeights(weights);
|
|
108
|
+
const scored = scoreRepo(repo, { since, weights: w });
|
|
109
|
+
scored.rows.sort((a, b) => b.score - a.score);
|
|
110
|
+
const risky = new Set(scored.rows.filter((r) => r.tier === "high" || r.tier === "critical").map((r) => r.file));
|
|
111
|
+
const riskMd = riskMarkdown({ rows: scored.rows, repos: [{ name: path.basename(repo), head: scored.head, commits: scored.commits }], weights: w, since, top: 60 });
|
|
112
|
+
fs.writeFileSync(path.join(dir, "risk.md"), riskMd);
|
|
113
|
+
const meta = { started, repo, head: scored.head, risky: risky.size, weights: w, model, stages, runs, tickets };
|
|
114
|
+
const stageText = stages.map((s) => `spec-${s}`).join(", then ");
|
|
115
|
+
const results = [];
|
|
116
|
+
for (let t = 0; t < tickets.length; t++) for (let r = 1; r <= runs; r++) for (const arm of ((t + r) % 2 ? ["off", "on"] : ["on", "off"]).filter((a) => arms.includes(a))) {
|
|
117
|
+
const clone = fs.mkdtempSync(path.join(os.tmpdir(), `risk-eval-${arm}-`));
|
|
118
|
+
sh(os.tmpdir(), "git", ["clone", "-q", "--no-hardlinks", repo, clone]);
|
|
119
|
+
for (const extra of ["specs/CONTEXT.md", "specs/context"]) {
|
|
120
|
+
const src = path.join(repo, extra);
|
|
121
|
+
if (fs.existsSync(src) && !fs.existsSync(path.join(clone, extra))) fs.cpSync ? fs.cpSync(src, path.join(clone, extra), { recursive: true }) : sh(repo, "cp", ["-R", src, path.join(clone, extra)]);
|
|
122
|
+
}
|
|
123
|
+
fs.rmSync(path.join(clone, "specs/context/risk.md"), { force: true });
|
|
124
|
+
fs.writeFileSync(path.join(clone, ".spec-driven.yml"), setRiskEnabled(configText || "version: 1\nlayout: single\n", arm === "on"));
|
|
125
|
+
if (arm === "on") { fs.mkdirSync(path.join(clone, "specs/context"), { recursive: true }); fs.writeFileSync(path.join(clone, "specs/context/risk.md"), riskMd); }
|
|
126
|
+
const prompt = `Use the ticket-to-pr plugin skills. This is a non-interactive run: don't ask questions, make reasonable assumptions and record them. Ticket (plain text, no tracker): ${tickets[t]}\nRun ${stageText}. Work on the current branch; don't create branches, commit, push, or implement anything.`;
|
|
127
|
+
const res = spawnSync(claude, ["-p", "--output-format", "stream-json", "--verbose", "--model", model, "--plugin-dir", pluginDir, "--permission-mode", "acceptEdits",
|
|
128
|
+
"--allowedTools", "Read Write Edit Glob Grep Skill Bash(git:*) Bash(ls:*) Bash(cat:*) Bash(head:*) Bash(tail:*) Bash(wc:*) Bash(find:*) Bash(grep:*) Bash(node:*) Bash(npx:*) Bash(spec-driven-dev:*)"],
|
|
129
|
+
{ cwd: clone, input: prompt, encoding: "utf8", maxBuffer: 512 * 1024 * 1024, timeout: timeoutMin * 60 * 1000 });
|
|
130
|
+
const transcript = res.stdout || "";
|
|
131
|
+
const tag = `t${t + 1}-${arm}-r${r}`;
|
|
132
|
+
fs.writeFileSync(path.join(dir, `${tag}.jsonl`), transcript);
|
|
133
|
+
const tracked = sh(clone, "git", ["ls-files"]).split("\n").filter((f) => f.includes("/") || f.includes("."));
|
|
134
|
+
const metrics = measure({ transcript, clone, risky, tracked });
|
|
135
|
+
if (res.status !== 0 && !metrics.error) {
|
|
136
|
+
// Say where it stopped, so a stall (usage limit, API wait) is told apart from a skill problem.
|
|
137
|
+
const events = transcript.split("\n").filter(Boolean).length;
|
|
138
|
+
const lastTool = [...transcript.matchAll(/"type":"tool_use","id":"[^"]+","name":"(\w+)"/g)].pop()?.[1] || "none";
|
|
139
|
+
metrics.error = `${res.error?.code === "ETIMEDOUT" ? `timed out after ${timeoutMin} min` : `exit ${res.status}`} · ${events} events · last tool ${lastTool} ${(res.stderr || "").slice(0, 120)}`.trim();
|
|
140
|
+
}
|
|
141
|
+
if (metrics.feature) fs.cpSync ? fs.cpSync(path.join(clone, "specs", metrics.feature), path.join(dir, tag), { recursive: true }) : sh(clone, "cp", ["-R", path.join("specs", metrics.feature), path.join(dir, tag)]);
|
|
142
|
+
results.push({ ticket: t, arm, run: r, exit: res.status, metrics });
|
|
143
|
+
fs.rmSync(clone, { recursive: true, force: true });
|
|
144
|
+
console.log(` ${tag}: ${metrics.error ? `failed (${metrics.error.slice(0, 80)})` : `read ${metrics.risky_files_read} risky / ${metrics.files_read} files, ${metrics.risky_plan_files} risky in plan, $${metrics.cost_usd}`}`);
|
|
145
|
+
}
|
|
146
|
+
fs.writeFileSync(path.join(dir, "results.json"), JSON.stringify({ meta, results }, null, 2));
|
|
147
|
+
const md = report({ meta, results });
|
|
148
|
+
fs.writeFileSync(path.join(dir, "report.md"), md);
|
|
149
|
+
const failed = results.filter((x) => x.metrics.error).length;
|
|
150
|
+
fs.appendFileSync(path.join(out, "LOG.md"), `${fs.existsSync(path.join(out, "LOG.md")) ? "" : "# GitRisk experiment log\n\nAppend-only record of every run, including failures.\n\n"}- ${started} · ${tickets.length} ticket(s) × ${runs} run(s) × 2 arms on \`${path.basename(repo)}\` at ${scored.head}, ${model}, ${stages.join("+")} · ${results.length - failed} ok, ${failed} failed · [report](${path.basename(dir)}/report.md)\n`);
|
|
151
|
+
return { dir, report: md, results };
|
|
152
|
+
}
|