dsh-issue2pr 0.1.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.
@@ -0,0 +1,226 @@
1
+ // lib/stages/p6-coder.js — 需求 5:多智能体协同代码优化
2
+ // builtin:Planner 派单 → 并行 Coder(superpowers 纪律:TDD/最小 diff/verify-before-claim)→ Reviewer 门控
3
+ // session:生成任务包,交给 DSH 会话(真 workflow + superpowers skills)
4
+ // claude:生成任务包后自动委托 claude code CLI 无人值守执行;产物就绪进正常复核门,失败回退"等人工会话"
5
+ import { spawn } from "node:child_process";
6
+ import { existsSync, readdirSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { writeArtifact, readArtifact } from "../store.js";
10
+ import { requireArtifact, readRepoFile, logEvent } from "./helpers.js";
11
+ import { slugify, timestamp } from "../store.js";
12
+ import { saveRun, sessionPatchesReady } from "../pipeline.js";
13
+ import { sysOf, paramsOf } from "../stageConfig.js";
14
+
15
+ // —— claude CLI 进程管理(stop/删除 Run 时杀进程树,避免孤儿 claude 继续写仓库) ——
16
+ const activeExternals = new Map(); // runDir → ChildProcess
17
+
18
+ export function killExternal(runDir) {
19
+ const child = activeExternals.get(runDir);
20
+ if (!child || child.exitCode != null) return false;
21
+ if (process.platform === "win32") {
22
+ // shell:true 的 child 是 cmd.exe,须按进程树杀,否则 claude(node 子进程)存活
23
+ spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { windowsHide: true });
24
+ } else {
25
+ try { child.kill("SIGTERM"); } catch { /* 已退出 */ }
26
+ }
27
+ return true;
28
+ }
29
+
30
+ // claude 可执行文件解析:阶段配置(params.claudeBin)> 环境变量 ISSUE2PR_CLAUDE_BIN > 常见安装位置探测。
31
+ // 开源环境安装路径各异,UI「配置」可显式指定;PATH 里未必有 claude(如 npm 全局目录不在系统 PATH)。
32
+ // preflight 端点复用本函数做健康探测(项目页显示 claude CLI 是否就绪)。
33
+ export function resolveClaudeBin(cfgBin) {
34
+ if (cfgBin) return cfgBin;
35
+ if (process.env.ISSUE2PR_CLAUDE_BIN) return process.env.ISSUE2PR_CLAUDE_BIN;
36
+ const home = homedir();
37
+ const cands = process.platform === "win32"
38
+ ? [join(home, ".npm-global", "claude.cmd"), join(home, ".npm_global", "claude.cmd"), join(home, "AppData", "Roaming", "npm", "claude.cmd"), join(home, ".local", "bin", "claude.exe")]
39
+ : [join(home, ".local", "bin", "claude"), "/usr/local/bin/claude", "/opt/homebrew/bin/claude"];
40
+ return cands.find((p) => existsSync(p)) || "claude";
41
+ }
42
+
43
+ // 无人值守跑 claude:-p headless;skip-permissions 无交互放行;--add-dir 允许写 run 产物目录(cwd 是仓库)。
44
+ // prompt 走 stdin(规避 shell:true 的参数转义);输出各留档 2MB 上限。
45
+ async function runClaude({ bin, repoDir, addDir, prompt, timeoutMs, onChild }) {
46
+ const args = ["-p", "--output-format", "json", "--dangerously-skip-permissions", "--add-dir", '"' + addDir + '"'];
47
+ return await new Promise((resolve) => {
48
+ let child;
49
+ try {
50
+ child = spawn(bin, args, { cwd: repoDir, shell: true, windowsHide: true });
51
+ } catch (e) { return resolve({ code: -1, error: String((e && e.message) || e) }); }
52
+ if (onChild) onChild(child);
53
+ let stdout = "", stderr = "", done = false;
54
+ const finish = (r) => { if (done) return; done = true; clearTimeout(timer); resolve(r); };
55
+ const timer = setTimeout(() => finish({ code: -2, timeout: true, stdout, stderr }), timeoutMs);
56
+ child.on("error", (e) => finish({ code: -1, error: String((e && e.message) || e), stdout, stderr }));
57
+ child.stdout?.on("data", (d) => { if (stdout.length < 2e6) stdout += d; });
58
+ child.stderr?.on("data", (d) => { if (stderr.length < 2e6) stderr += d; });
59
+ child.on("close", (code) => finish({ code: code == null ? -1 : code, stdout, stderr }));
60
+ try { child.stdin.write(prompt); child.stdin.end(); }
61
+ catch (e) { finish({ code: -1, error: "stdin 写入失败: " + String((e && e.message) || e) }); }
62
+ });
63
+ }
64
+
65
+ function countPatches(runDir) {
66
+ const dir = join(runDir, "06-implementation", "patches");
67
+ return existsSync(dir) ? readdirSync(dir).filter((f) => f.endsWith(".diff")).length : 0;
68
+ }
69
+
70
+ function buildSessionTask(graph) {
71
+ return [
72
+ "# P6 会话任务包(交外部编码会话执行:workflow + superpowers)", "",
73
+ "## TaskGraph", "```json", graph, "```", "",
74
+ "## 要求", "- 用 workflow 工具 fan-out:planner → 并行 coder → reviewer;",
75
+ "- 每节点产出 unified diff 到本目录 patches/;", "- 完成后写 coder-report.json 并通过人工复核门。",
76
+ ].join("\n");
77
+ }
78
+
79
+ // claude headless(--output-format json)统计摘要:耗时/轮次/费用(无统计返回空串)
80
+ function statsSummary(s) {
81
+ if (!s) return "";
82
+ const parts = [];
83
+ if (s.durationMs != null) parts.push("耗时 " + Math.round(s.durationMs / 60000) + " 分钟");
84
+ if (s.turns != null) parts.push(s.turns + " 轮");
85
+ if (s.costUsd != null) parts.push("$" + Number(s.costUsd).toFixed(2));
86
+ return parts.length ? "(" + parts.join(" · ") + ")" : "";
87
+ }
88
+
89
+ // claude 模式主流程:委托 → 等待 → 按产物验收(patches/report 是硬标准,退出码仅参考)
90
+ async function delegateClaude(rcx, graph) {
91
+ const { runDir, repoDir } = rcx;
92
+ const patchesAbs = join(runDir, "06-implementation", "patches");
93
+ const taskAbs = join(runDir, "06-implementation", "session-task.md");
94
+ const reportAbs = join(runDir, "06-implementation", "coder-report.json");
95
+ let total = null;
96
+ try { total = JSON.parse(graph).nodes.length; } catch { /* 任务总数仅用于进度展示 */ }
97
+ const prompt = [
98
+ "# 角色", "你是 issue2pr 流水线在 P6 阶段委托的外部编码会话(Claude Code)。按任务包完成代码修复并产出标准补丁。", "",
99
+ "# 输入", "- 目标仓库(当前工作目录):" + repoDir,
100
+ "- 任务包:" + taskAbs + " —— 先完整阅读,严格按其中 TaskGraph 的节点与依赖执行", "",
101
+ "# 产物契约(流水线据此验收,缺一不可)",
102
+ "1. 按依赖顺序逐节点实施,直接修改目标仓库文件(最小 diff、不越权重构、不新增无关文件、禁止 git commit);",
103
+ "2. 每完成一个节点,导出该节点补丁并恢复工作区:",
104
+ " git add -A",
105
+ ' git diff --cached --binary > "' + patchesAbs + '\\0001-T1.diff" ← 依次 0001/0002… 按节点编号',
106
+ " git reset -q && git checkout -- . && git clean -fdq ← 必须保持工作区干净(P7 会重新应用这些 diff)",
107
+ "3. 全部节点完成后写报告 " + reportAbs + "(patch 路径必须是相对 run 目录的全路径,与下方一致):",
108
+ ' {"mode":"claude-code","patches":[{"node":"T1","patch":"06-implementation/patches/0001-T1.diff","note":"一句话"}],"summary":"总体说明"}', "",
109
+ "完成后只输出一行总结。",
110
+ ].join("\n");
111
+
112
+ const pm = paramsOf(rcx, "P6");
113
+ const bin = resolveClaudeBin(pm.claudeBin);
114
+ // 超时优先级:阶段配置(params.claudeTimeoutMin)> 环境变量 ISSUE2PR_CLAUDE_TIMEOUT_MS > 默认 2 小时
115
+ const claudeTimeoutMs = (Number(pm.claudeTimeoutMin) > 0 ? Number(pm.claudeTimeoutMin) * 60000 : 0)
116
+ || Number(process.env.ISSUE2PR_CLAUDE_TIMEOUT_MS) || 2 * 60 * 60 * 1000;
117
+ // 执行状态落盘 run.json(UI 3s 轮询可见;rcx.run 是驱动循环持有的活引用,advance 后续 saveRun 会保留该字段)
118
+ const setExec = (patch) => {
119
+ if (!rcx.run) return;
120
+ rcx.run.externalExec = Object.assign({ executor: "claude-code" }, rcx.run.externalExec, patch);
121
+ saveRun(runDir, rcx.run);
122
+ };
123
+ setExec({ status: "running", bin, startedAt: new Date().toISOString() });
124
+ logEvent(rcx, { kind: "tool", name: "委托 Claude Code 执行任务包", detail: "cwd=" + repoDir + " · bin=" + bin });
125
+
126
+ const runner = rcx.spawnExternal || runClaude; // 测试注入点
127
+ // 过程可见:等待期间周期采样 patch 数,变化即记过程事件(UI 阶段详情"过程"面板实时可看)
128
+ let lastN = 0;
129
+ const progressTimer = setInterval(() => {
130
+ const n = countPatches(runDir);
131
+ if (n !== lastN) {
132
+ lastN = n;
133
+ const latest = existsSync(patchesAbs) ? readdirSync(patchesAbs).filter((f) => f.endsWith(".diff")).sort().slice(-1)[0] : "";
134
+ logEvent(rcx, { kind: "tool", name: "Claude Code 进度 " + n + (total ? "/" + total : ""),
135
+ detail: latest ? "最新产出 " + latest : "" });
136
+ }
137
+ }, rcx.externalProgressIntervalMs || 30000);
138
+ let r;
139
+ try {
140
+ r = await runner({
141
+ bin, repoDir, addDir: runDir, prompt,
142
+ timeoutMs: claudeTimeoutMs,
143
+ onChild: (c) => activeExternals.set(runDir, c),
144
+ });
145
+ } finally {
146
+ clearInterval(progressTimer);
147
+ activeExternals.delete(runDir);
148
+ }
149
+
150
+ // stdout(--output-format json 的 result 对象)与 stderr 留档,供失败诊断
151
+ writeArtifact(runDir, "06-implementation/external-exec.log",
152
+ "bin=" + bin + "\nexit=" + r.code + (r.error ? "\nerror=" + r.error : "") + (r.timeout ? "\ntimeout=true" : "")
153
+ + "\n--- stdout ---\n" + (r.stdout || "") + "\n--- stderr ---\n" + (r.stderr || ""));
154
+
155
+ // 从 claude headless 的 JSON 输出提取统计(耗时/轮次/费用/总结),落盘 externalExec 供 UI 展示
156
+ let stats = null;
157
+ try {
158
+ const j = JSON.parse((r.stdout || "").trim());
159
+ if (j && typeof j === "object") {
160
+ stats = { turns: j.num_turns, costUsd: j.total_cost_usd, durationMs: j.duration_ms, result: String(j.result || "").slice(0, 1000) };
161
+ }
162
+ } catch { /* stdout 非 JSON(版本差异/异常输出)时无统计 */ }
163
+
164
+ if (sessionPatchesReady(runDir)) {
165
+ setExec({ status: "done", exitCode: r.code, stats, finishedAt: new Date().toISOString() });
166
+ logEvent(rcx, { kind: "tool", name: "Claude Code 执行完成",
167
+ detail: countPatches(runDir) + " 份 patch 已就绪(exit=" + r.code + ")" + statsSummary(stats) });
168
+ return { artifact: "06-implementation/", summary: "Claude Code 已执行任务包:" + countPatches(runDir) + " 份 patch + report,等待人工复核" + statsSummary(stats) };
169
+ }
170
+
171
+ const reason = r.error || (r.timeout ? "超时被终止" : "退出码 " + r.code + (r.stderr ? ":" + String(r.stderr).slice(0, 300) : ""));
172
+ setExec({ status: r.error ? "skipped" : "failed", exitCode: r.code, stats, error: String(reason).slice(0, 500), finishedAt: new Date().toISOString() });
173
+ logEvent(rcx, { kind: "tool", name: "Claude Code 执行未产出补丁", detail: String(reason).slice(0, 500), ok: false });
174
+ // 回退等人工:保持 external 语义(UI 显示"等外部执行"并拦截空产物 approve),人可接管 session-task.md
175
+ return { artifact: "06-implementation/session-task.md", summary: "Claude Code 执行未成功(" + String(reason).slice(0, 200) + "),回退等待人工会话", external: true };
176
+ }
177
+
178
+ export default async function execute(rcx) {
179
+ const graph = requireArtifact(rcx.runDir, "05-task-graph.json", readArtifact);
180
+
181
+ if (rcx.p6Mode === "session" || rcx.p6Mode === "claude") {
182
+ writeArtifact(rcx.runDir, "06-implementation/session-task.md", buildSessionTask(graph));
183
+ if (rcx.p6Mode === "session") {
184
+ logEvent(rcx, { kind: "info", name: "session 模式:任务包已生成",
185
+ detail: "06-implementation/session-task.md 已写入;等 DSH 会话产出 patches/ 后,在复核门通过再进 P7" });
186
+ // external:本阶段只产出任务包,实施由外部 DSH 会话完成(advance 据此显示"等外部执行"并拦截空 patches 的 approve)
187
+ return { artifact: "06-implementation/session-task.md", summary: "任务包已生成,等待外部 DSH 会话执行", external: true };
188
+ }
189
+ return await delegateClaude(rcx, graph);
190
+ }
191
+
192
+ // 1) Planner 派单
193
+ const plan = await rcx.llm.completeJson({
194
+ system: sysOf(rcx, "P6", "planner"),
195
+ user: `【TaskGraph】\n${graph}\n\n【输出契约】{"assignments":[{"node":"T1","file":"相对路径","note":"实现要点"}]}`,
196
+ required: ["assignments"],
197
+ });
198
+ logEvent(rcx, { kind: "info", name: "Planner 派单 " + plan.assignments.length + " 个任务",
199
+ detail: plan.assignments.map((a) => a.node + " → " + a.file).join(";") });
200
+
201
+ // 2) 并行 Coder
202
+ const patches = await Promise.all(plan.assignments.map(async (a, i) => {
203
+ const current = readRepoFile(rcx.repoDir, a.file);
204
+ const diff = await rcx.llm.complete({
205
+ system: sysOf(rcx, "P6", "coder"),
206
+ user: `【任务 ${a.node}】${a.note || ""}\n【当前文件 ${a.file}】\n\`\`\`\n${current}\n\`\`\`\n【打回意见】${rcx.reviewComment || "无"}\n只输出 unified diff。`,
207
+ });
208
+ const rel = `06-implementation/patches/${String(i + 1).padStart(4, "0")}-${slugify(a.node + "-" + a.file).slice(0, 40)}.diff`;
209
+ writeArtifact(rcx.runDir, rel, diff);
210
+ logEvent(rcx, { kind: "tool", name: "Coder " + a.node + " 产出 diff", detail: rel + "(" + diff.length + " 字)" });
211
+ return { node: a.node, file: a.file, patch: rel, content: diff };
212
+ }));
213
+
214
+ // 3) Reviewer 门控(附带 diff 文本,杜绝盲审——Task 7 修复)
215
+ const review = await rcx.llm.completeJson({
216
+ system: sysOf(rcx, "P6", "reviewer"),
217
+ user: `【diff 清单】\n${patches.map((p) => `### ${p.patch}\n\n${(p.content || "").slice(0, 4000)}\n`).join("\n")}\n【输出契约】{"verdict":"pass|fail","notes":"理由"}`,
218
+ required: ["verdict"],
219
+ });
220
+ logEvent(rcx, { kind: "info", name: "Reviewer 门控 " + review.verdict, detail: review.notes || "", ok: review.verdict === "pass" });
221
+ if (review.verdict !== "pass") throw new Error("P6 Reviewer 拒绝: " + (review.notes || "无理由"));
222
+
223
+ const report = { mode: "builtin", planner: plan, patches, reviewer: review, discipline: ["tdd", "minimal-diff", "verify-before-claim"], at: timestamp() };
224
+ writeArtifact(rcx.runDir, "06-implementation/coder-report.json", JSON.stringify(report, null, 2));
225
+ return { artifact: "06-implementation/", summary: `${patches.length} 份 diff,reviewer pass` };
226
+ }
@@ -0,0 +1,73 @@
1
+ // lib/stages/p7-patch.js — 调研 §8/§11:diff 为原子单位;回滚只反应用 Agent patch
2
+ // git 一律走异步子进程:execFileSync 会阻塞 Node 事件循环,期间 stop/删除/轮询请求全部排队。
3
+ import { readFileSync, existsSync, readdirSync } from "node:fs";
4
+ import { execFile } from "node:child_process";
5
+ import { join } from "node:path";
6
+ import { appendArtifactLine } from "../store.js";
7
+ import { logEvent } from "./helpers.js";
8
+
9
+ // 带stdin输入的异步执行(git apply - 用 stdin 收 diff)
10
+ function execStdin(cmd, args, opts, input) {
11
+ return new Promise((resolve, reject) => {
12
+ const child = execFile(cmd, args, { ...opts, stdio: ["pipe", "pipe", "pipe"], maxBuffer: 16 * 1024 * 1024 }, (err, stdout, stderr) => {
13
+ if (err) { err.stderr = String(stderr || err.message); reject(err); }
14
+ else resolve(String(stdout || ""));
15
+ });
16
+ child.stdin.on("error", () => {}); // 子进程异常退出时的 EPIPE 不掩盖主错误
17
+ child.stdin.end(input);
18
+ });
19
+ }
20
+
21
+ async function hashRepo(repoDir) {
22
+ try {
23
+ return (await execStdin("git", ["rev-parse", "HEAD"], { cwd: repoDir }, "")).trim();
24
+ } catch { return "nogit"; }
25
+ }
26
+
27
+ // patch 清单:优先 coder-report.json(builtin 写 patches 字段;claude 委托写 tasks 字段且
28
+ // patch 路径相对 06-implementation/,这里统一归一化为相对 runDir 全路径);
29
+ // report 缺失时退化为扫描目录(文件名序 = 应用序)。
30
+ function collectPatches(runDir) {
31
+ const normalize = (p) => !p ? null
32
+ : { patch: p.startsWith("06-implementation/") ? p : "06-implementation/" + p.replace(/^\/+/, "") };
33
+ const reportPath = join(runDir, "06-implementation", "coder-report.json");
34
+ if (existsSync(reportPath)) {
35
+ const report = JSON.parse(readFileSync(reportPath, "utf8"));
36
+ const list = Array.isArray(report.patches) ? report.patches
37
+ : Array.isArray(report.tasks) ? report.tasks.map((t) => ({ patch: t.patch }))
38
+ : [];
39
+ return list.map((p) => normalize(p && p.patch)).filter(Boolean);
40
+ }
41
+ const dir = join(runDir, "06-implementation", "patches");
42
+ if (!existsSync(dir)) return [];
43
+ return readdirSync(dir).filter((f) => f.endsWith(".diff")).sort()
44
+ .map((f) => ({ patch: "06-implementation/patches/" + f }));
45
+ }
46
+
47
+ export default async function execute(rcx) {
48
+ const patches = collectPatches(rcx.runDir);
49
+ if (!patches.length) throw new Error("P7 无 patch 可应用(06-implementation/patches/ 为空且无 coder-report.json)");
50
+ for (const p of patches) {
51
+ const diff = readFileSync(join(rcx.runDir, p.patch), "utf8");
52
+ const hashBefore = await hashRepo(rcx.repoDir);
53
+ const t0 = Date.now();
54
+ try {
55
+ await execStdin("git", ["apply", "--check", "-"], { cwd: rcx.repoDir }, diff);
56
+ await execStdin("git", ["apply", "-"], { cwd: rcx.repoDir }, diff);
57
+ } catch (e) {
58
+ logEvent(rcx, { kind: "git", name: "git apply " + p.patch, detail: String(e.stderr || e.message), ms: Date.now() - t0, ok: false });
59
+ throw new Error("Patch 应用失败(" + p.patch + "): " + String(e.stderr || e.message));
60
+ }
61
+ logEvent(rcx, { kind: "git", name: "git apply " + p.patch, detail: diff.length + " 字 diff · 基线 " + hashBefore.slice(0, 8), ms: Date.now() - t0 });
62
+ appendArtifactLine(rcx.runDir, "ledger/patch-ledger.jsonl", { patch: p.patch, hashBefore, appliedAt: new Date().toISOString() });
63
+ }
64
+ return { artifact: "ledger/patch-ledger.jsonl", summary: `应用 ${patches.length} 份 patch` };
65
+ }
66
+
67
+ export async function rollbackLedger(runDir, repoDir, lineNo) {
68
+ const lines = readFileSync(join(runDir, "ledger", "patch-ledger.jsonl"), "utf8").trim().split("\n");
69
+ const entry = JSON.parse(lines[lineNo]);
70
+ const diff = readFileSync(join(runDir, entry.patch), "utf8");
71
+ await execStdin("git", ["apply", "-R", "-"], { cwd: repoDir }, diff);
72
+ appendArtifactLine(runDir, "ledger/patch-ledger.jsonl", { rollbackOf: lineNo, at: new Date().toISOString() });
73
+ }
@@ -0,0 +1,44 @@
1
+ // lib/stages/p8-test-runner.js — 调研 §9 铁律:结果必须来自真实工具执行
2
+ // exec 用异步版本:execSync 最长可阻塞事件循环 5 分钟,期间 stop/删除/轮询请求全部排队无响应。
3
+ import { exec } from "node:child_process";
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ import { writeArtifact } from "../store.js";
7
+ import { logEvent } from "./helpers.js";
8
+ import { DEFAULT_TEST_TIMEOUT_MS } from "../stageConfig.js";
9
+
10
+ function detectCommand(repoDir) {
11
+ const pkg = join(repoDir, "package.json");
12
+ if (existsSync(pkg) && JSON.parse(readFileSync(pkg, "utf8")).scripts?.test) return "npm test";
13
+ throw new Error("未配置测试命令(project.testCommand 为空且无法自动探测)");
14
+ }
15
+
16
+ export default async function execute(rcx) {
17
+ // 超时可配(配置页 P8;0/未配置 = 默认 5 分钟)
18
+ const p8cfg = typeof rcx.stageCfgOf === "function" ? rcx.stageCfgOf("P8") : null; // 裸 rcx(单测)回落默认
19
+ const timeoutMs = (p8cfg && p8cfg.timeoutMs) || DEFAULT_TEST_TIMEOUT_MS;
20
+ const runCommand = (command, cwd) => new Promise((resolve) => {
21
+ const t0 = Date.now();
22
+ exec(command, { cwd, encoding: "utf8", timeout: timeoutMs, maxBuffer: 32 * 1024 * 1024,
23
+ windowsHide: true }, (err, stdout, stderr) => {
24
+ if (err) {
25
+ const killed = err.killed || err.signal === "SIGTERM";
26
+ resolve({ code: err.code ?? 1, ms: Date.now() - t0,
27
+ out: String(stdout || "") + String(stderr || "") + (killed ? "\n(超时被终止)" : "") });
28
+ } else {
29
+ // 与旧 execSync 语义一致:成功路径只保留 stdout
30
+ resolve({ code: 0, ms: Date.now() - t0, out: String(stdout || "") });
31
+ }
32
+ });
33
+ });
34
+ const command = rcx.project?.testCommand || detectCommand(rcx.repoDir);
35
+ logEvent(rcx, { kind: "test", name: command, detail: `开始执行(最长 ${Math.round(timeoutMs / 60000)} 分钟)` });
36
+ const { code, ms, out } = await runCommand(command, rcx.repoDir);
37
+ // 完整输出持久化(成功与失败路径均落盘、不截断),排障可回溯;07-test-report.json 只留末尾 4000 字
38
+ writeArtifact(rcx.runDir, "08-test-output.txt", out);
39
+ const report = { command, exitCode: code, tail: out.slice(-4000), passed: code === 0, ranAt: new Date().toISOString() };
40
+ writeArtifact(rcx.runDir, "07-test-report.json", JSON.stringify(report, null, 2));
41
+ logEvent(rcx, { kind: "test", name: command, detail: out.slice(-1200) || "(无输出)", ms, ok: code === 0 });
42
+ if (!report.passed) throw new Error("测试失败(exitCode=" + code + "),详见 07-test-report.json");
43
+ return { artifact: "07-test-report.json", summary: `exitCode=0` };
44
+ }
@@ -0,0 +1,41 @@
1
+ // lib/stages/p9-reviewer.js — 调研 §9:Review 是 PRBuilder 的前置门控
2
+ import { writeArtifact, readArtifact } from "../store.js";
3
+ import { requireArtifact, maybeDelegate } from "./helpers.js";
4
+ import { sysOf, paramsOf } from "../stageConfig.js";
5
+
6
+ export default async function execute(rcx) {
7
+ const delegated = await maybeDelegate(rcx, "P9");
8
+ if (delegated) return delegated;
9
+ const coderReportRaw = readArtifact(rcx.runDir, "06-implementation/coder-report.json") || "{}";
10
+ const tests = requireArtifact(rcx.runDir, "07-test-report.json", readArtifact);
11
+ const diffChars = paramsOf(rcx, "P9").diffChars;
12
+
13
+ // 读盘取 diff 全文(修复轮:Diff 范围裁决必须看到真实 patch,而非 coder 自评)
14
+ // coder-report 无可用清单或 diff 文件读不到 → 降级为空清单,不抛错
15
+ let coderReport = {};
16
+ try { coderReport = JSON.parse(coderReportRaw); } catch { coderReport = {}; }
17
+ // builtin 写 patches 字段;claude 委托写 tasks 字段——两者都认,取到清单为止
18
+ const patchList = Array.isArray(coderReport.patches) ? coderReport.patches
19
+ : Array.isArray(coderReport.tasks) ? coderReport.tasks : [];
20
+ const diffs = patchList
21
+ .map((p) => {
22
+ const name = p && typeof p.patch === "string" ? p.patch.split(/[\\/]+/).pop() : "";
23
+ if (!name) return null;
24
+ const rel = `06-implementation/patches/${name}`;
25
+ const text = readArtifact(rcx.runDir, rel);
26
+ // 每份只取头部 diffChars 字(文件路径 + hunk 概览足够范围裁决):
27
+ // 15 份 × 4000 字曾让 prompt 达 45K 字,实测 LLM 直接空响应(上限可在「配置」调整)
28
+ return text == null ? null : `### ${rel}\n\n${text.slice(0, diffChars)}\n`;
29
+ })
30
+ .filter((s) => s != null)
31
+ .join("\n");
32
+
33
+ const out = await rcx.llm.completeJson({
34
+ system: sysOf(rcx, "P9"),
35
+ user: `【coder-report】\n${coderReportRaw}\n【Diff 全文】\n${diffs || "(无 diff 文件)"}\n【test-report】\n${tests}\n【输出契约】{"diff_scope":"结论","api_security":"结论","tests":"结论","verdict":"pass|fail"}\n【打回意见】${rcx.reviewComment || "无"}`,
36
+ required: ["diff_scope", "api_security", "tests", "verdict"],
37
+ });
38
+ writeArtifact(rcx.runDir, "08-review-report.json", JSON.stringify(out, null, 2));
39
+ if (out.verdict !== "pass") throw new Error("Reviewer 门控未过: " + (out.diff_scope || ""));
40
+ return { artifact: "08-review-report.json", summary: "三维门控 pass" };
41
+ }
package/lib/store.js ADDED
@@ -0,0 +1,153 @@
1
+ // lib/store.js — 目录与命名规则(唯一允许写盘的地方)
2
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, renameSync, rmSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join, normalize, sep } from "node:path";
5
+ import { validateStageConfig } from "./stageConfig.js";
6
+
7
+ export function defaultDataRoot() { return join(homedir(), ".dsh", "issue2pr"); }
8
+
9
+ export function slugify(text) {
10
+ const s = String(text).toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
11
+ return s || "run";
12
+ }
13
+
14
+ export function timestamp(d = new Date()) {
15
+ const p = (n) => String(n).padStart(2, "0");
16
+ return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`;
17
+ }
18
+
19
+ export function validateProject(p) {
20
+ if (!p || typeof p.name !== "string" || !p.name.trim()) return [false, "缺少项目名称"];
21
+ if (!/^[a-z0-9-]+$/.test(p.slug || "")) return [false, "slug 只允许小写字母/数字/连字符"];
22
+ if (!Array.isArray(p.repos) || p.repos.length === 0) return [false, "至少一个 git 仓库"];
23
+ for (const r of p.repos) {
24
+ const uri = typeof r === "string" ? r : r?.uri;
25
+ if (typeof uri !== "string" || !uri.trim()) return [false, "仓库条目必须是 uri 字符串或 { uri }"];
26
+ }
27
+ if (!Array.isArray(p.triggers)) return [false, "triggers 必须是数组"];
28
+ for (const t of p.triggers) {
29
+ if (!t || (t.kind !== "requirement" && t.kind !== "issue") || typeof t.uri !== "string" || !t.uri.trim())
30
+ return [false, "触发源 kind 仅允许 requirement|issue 且 uri 必填"];
31
+ }
32
+ if (!["every", "key-only", "auto"].includes(p.reviewMode)) return [false, "reviewMode 仅允许 every|key-only|auto"];
33
+ if (!["builtin", "session", "claude"].includes(p.p6Mode)) return [false, "p6Mode 仅允许 builtin|session|claude"];
34
+ const [scOk, scMsg] = validateStageConfig(p.stageConfig);
35
+ if (!scOk) return [false, scMsg];
36
+ return [true, "ok"];
37
+ }
38
+
39
+ const projectDir = (root, slug) => join(root, "projects", slug);
40
+ const projectFile = (root, slug) => join(projectDir(root, slug), "project.json");
41
+
42
+ export function saveProject(root, p) {
43
+ const [ok, msg] = validateProject(p);
44
+ if (!ok) throw new Error(msg);
45
+ // 规范化:repos 允许传字符串或 { uri },落盘统一为 { uri },下游(clone 等)不用再做双形态判断
46
+ const norm = { ...p, repos: p.repos.map((r) => (typeof r === "string" ? { uri: r.trim() } : { uri: String(r.uri).trim() })) };
47
+ mkdirSync(projectDir(root, norm.slug), { recursive: true });
48
+ const tmp = projectFile(root, norm.slug) + ".tmp";
49
+ writeFileSync(tmp, JSON.stringify({ ...norm, updatedAt: new Date().toISOString() }, null, 2));
50
+ renameSync(tmp, projectFile(root, norm.slug));
51
+ }
52
+
53
+ export function loadProject(root, slug) {
54
+ if (!existsSync(projectFile(root, slug))) return null;
55
+ return JSON.parse(readFileSync(projectFile(root, slug), "utf8"));
56
+ }
57
+
58
+ export function listProjects(root) {
59
+ const dir = join(root, "projects");
60
+ if (!existsSync(dir)) return [];
61
+ return readdirSync(dir).flatMap((slug) => {
62
+ const p = loadProject(root, slug);
63
+ return p ? [p] : [];
64
+ });
65
+ }
66
+
67
+ // —— UI 偏好(当前仅 lastProject):宿主 webview 的 localStorage 不跨软件重启持久,
68
+ // 选中记忆以此文件为兜底;localStorage 命中时零延迟、不请求。
69
+ export function loadUiState(root) {
70
+ const file = join(root, "ui-state.json");
71
+ if (!existsSync(file)) return {};
72
+ try { return JSON.parse(readFileSync(file, "utf8")) || {}; }
73
+ catch { return {}; }
74
+ }
75
+
76
+ export function saveUiState(root, state) {
77
+ const tmp = join(root, "ui-state.json.tmp");
78
+ writeFileSync(tmp, JSON.stringify(state, null, 2));
79
+ renameSync(tmp, join(root, "ui-state.json"));
80
+ }
81
+
82
+ export function newRunId(triggerUri, now = new Date()) {
83
+ const base = String(triggerUri).split(/[\\/]/).pop().slice(0, 24);
84
+ return `${timestamp(now)}-${slugify(base)}`;
85
+ }
86
+
87
+ export function runDirOf(root, slug, runId) {
88
+ if (!/^\d{8}-\d{6}-[a-z0-9-]+$/.test(runId)) throw new Error("非法 runId: " + runId);
89
+ return join(projectDir(root, slug), "runs", runId);
90
+ }
91
+
92
+ export function createRun(root, slug, trigger) {
93
+ const runId = newRunId(trigger.uri);
94
+ const runDir = runDirOf(root, slug, runId);
95
+ if (existsSync(runDir)) throw new Error("Run 已存在: " + runId);
96
+ for (const sub of ["", "ledger", "trace", "reviews", "spec-changes"])
97
+ mkdirSync(join(runDir, sub), { recursive: true });
98
+ return { runId, runDir };
99
+ }
100
+
101
+ function safeJoin(runDir, rel) {
102
+ const full = normalize(join(runDir, rel));
103
+ if (full !== runDir && !full.startsWith(runDir + sep)) throw new Error("非法路径: " + rel);
104
+ return full;
105
+ }
106
+
107
+ export function writeArtifact(runDir, rel, content) {
108
+ const full = safeJoin(runDir, rel);
109
+ mkdirSync(join(full, ".."), { recursive: true });
110
+ writeFileSync(full, content);
111
+ }
112
+
113
+ export function readArtifact(runDir, rel) {
114
+ const full = safeJoin(runDir, rel);
115
+ return existsSync(full) ? readFileSync(full, "utf8") : null;
116
+ }
117
+
118
+ // 追加一行 JSONL(trace 事件流等);目录不存在自动建
119
+ export function appendArtifactLine(runDir, rel, obj) {
120
+ const full = safeJoin(runDir, rel);
121
+ mkdirSync(join(full, ".."), { recursive: true });
122
+ appendFileSync(full, JSON.stringify(obj) + "\n");
123
+ }
124
+
125
+ // 删除整棵目录(项目 / Run)。Windows 下 git 克隆的 .git/objects 文件是只读的,
126
+ // 直接 rmSync 会 EPERM;先递归清只读位,再带重试删除(杀毒/索引器短暂占用)。
127
+ export function rmTree(dir) {
128
+ if (!existsSync(dir)) return;
129
+ try {
130
+ (function clearReadOnly(d) {
131
+ for (const name of readdirSync(d)) {
132
+ const full = join(d, name);
133
+ const st = statSync(full);
134
+ if (st.isDirectory()) clearReadOnly(full);
135
+ else if (!(st.mode & 0o200)) chmodSync(full, 0o666);
136
+ }
137
+ })(dir);
138
+ } catch { /* 清位尽力而为,失败仍尝试删除 */ }
139
+ rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 120 });
140
+ }
141
+
142
+ export function listRunTree(runDir) {
143
+ const out = [];
144
+ (function walk(dir) {
145
+ for (const name of readdirSync(dir)) {
146
+ const full = join(dir, name);
147
+ const st = statSync(full);
148
+ if (st.isDirectory()) walk(full);
149
+ else out.push({ path: full.slice(runDir.length + 1).split(sep).join("/"), size: st.size, mtimeMs: st.mtimeMs });
150
+ }
151
+ })(runDir);
152
+ return out.sort((a, b) => a.path.localeCompare(b.path));
153
+ }
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "dsh-issue2pr",
3
+ "version": "0.1.0",
4
+ "description": "Issue-to-PR 可验证交付链:项目配置 → 11 阶段流水线 → 人工复核 → PR 说明(DSH 全局插件)",
5
+ "type": "module",
6
+ "main": "./index.js",
7
+ "exports": { ".": "./index.js", "./client": "./client.js", "./package.json": "./package.json" },
8
+ "scripts": { "test": "node --test \"tests/**/*.test.js\"" },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/LONGSASASASASA/dsh-issue2pr.git"
12
+ },
13
+ "keywords": ["dsh", "dsh-plugin", "deepseek-harness", "issue", "pull-request", "pipeline", "code-review", "cordis"],
14
+ "author": "LONGSASASASASA",
15
+ "license": "MIT",
16
+ "files": ["index.js", "client.js", "lib/", "cordis.patch.yml", "docs/assets/"],
17
+ "publishConfig": { "registry": "https://registry.npmjs.org/" },
18
+ "dsh": {
19
+ "bundle": { "patch": "./cordis.patch.yml" },
20
+ "client": {
21
+ "platform": "web",
22
+ "inject": ["@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-settings"]
23
+ }
24
+ },
25
+ "peerDependencies": {
26
+ "@deepseek-ai/dsh-client-runtime": ">=0.1.0-rc.2",
27
+ "@deepseek-ai/dsh-client-locale": ">=0.1.0-rc.2",
28
+ "@deepseek-ai/dsh-client-ui-settings": ">=0.1.0-rc.2"
29
+ }
30
+ }