@zhushanwen/pi-subagent-workflow 2.0.1 → 4.0.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/agents/reviewer.md +1 -1
- package/package.json +3 -2
- package/skills/workflow-script-format/SKILL.md +2 -1
- package/src/execution/__tests__/agent-registry.test.ts +1 -1
- package/src/execution/__tests__/channel-registry-handshake.test.ts +18 -8
- package/src/execution/__tests__/execute-and-await-worktree.test.ts +219 -0
- package/src/execution/__tests__/finalize-record.test.ts +19 -6
- package/src/execution/__tests__/stdin-writer.test.ts +18 -5
- package/src/execution/__tests__/ui-request-observability.test.ts +21 -8
- package/src/execution/agent-registry.ts +10 -2
- package/src/execution/best-effort.ts +13 -5
- package/src/execution/channel-registry-access.ts +7 -3
- package/src/execution/execute-options-mapper.ts +2 -0
- package/src/execution/finalize-record.ts +5 -1
- package/src/execution/record-store.ts +10 -4
- package/src/execution/session-runner.ts +8 -2
- package/src/execution/stdin-writer.ts +8 -2
- package/src/execution/subagent-service.ts +56 -5
- package/src/execution/ui-request-handler-factory.ts +10 -10
- package/src/execution/ui-request-observability.ts +6 -2
- package/src/execution/ui-request-queue.ts +7 -1
- package/src/index.ts +21 -8
- package/src/interface/__tests__/detectors.test.ts +14 -0
- package/src/interface/subagent-tool.ts +10 -11
- package/src/interface/tool-workflow.ts +17 -4
- package/src/orchestration/__tests__/error-recovery-postmessage-defense.test.ts +22 -7
- package/src/orchestration/__tests__/error-recovery-serialize-failed-result.test.ts +56 -0
- package/src/orchestration/__tests__/worker-script-builder-runtime.test.ts +105 -1
- package/src/orchestration/__tests__/worker-script-builder.test.ts +126 -0
- package/src/orchestration/error-recovery.ts +23 -15
- package/src/orchestration/lifecycle.ts +6 -2
- package/src/orchestration/models/types.ts +18 -0
- package/src/orchestration/worker-script-builder.ts +34 -5
- package/workflows/README.md +18 -0
- package/workflows/review-fix-loop.js +677 -0
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
// review-fix-loop.js — 通用多批审查-修复循环(内置 workflow)
|
|
2
|
+
//
|
|
3
|
+
// 模式:多批(batch)串行,批内循环(round):并行 review → aggregate → fix → 重审。
|
|
4
|
+
// 批次用于表达前置依赖(fallow 静态分析等前置检查必须先完成,后续审查才有意义)。
|
|
5
|
+
// 批内某 agent 已无 must-fix(critical/major)则后续轮跳过,优化 token 效率。
|
|
6
|
+
//
|
|
7
|
+
// 用法:
|
|
8
|
+
// workflow run review-fix-loop --args targetType=git-diff target=main \
|
|
9
|
+
// batch1=fallow-scan batch2=reviewer autoCommit=true
|
|
10
|
+
// workflow run review-fix-loop --args targetType=file target=/path/to/doc.md \
|
|
11
|
+
// batch1=reviewer autoCommit=false
|
|
12
|
+
//
|
|
13
|
+
// ⚠️ 唯一带写操作的内置 workflow:fix 阶段会修改文件(autoCommit=true 时 commit)。
|
|
14
|
+
// ⚠️ lintScript 约束(本脚本已遵守):含 parallel() 入口,禁止 bare IIFE;
|
|
15
|
+
// agent() 调用顺序确定(批次按配置顺序稳定排序,callId 重放安全)。
|
|
16
|
+
|
|
17
|
+
const meta = {
|
|
18
|
+
name: "review-fix-loop",
|
|
19
|
+
description: "审查-修复循环:多批串行(批内并行 review → aggregate → fix → 重审直到 clean)。必填 targetType(git-diff/file/dir/text)+ target。批次由 batch1..batchN 控制(如 batch1=fallow-scan batch2=reviewer),用于前置检查先行的场景。注意:唯一带写操作/commit 副作用的内置 workflow,autoCommit 默认 false。",
|
|
20
|
+
phases: [
|
|
21
|
+
{ title: "Review", detail: "Batch: parallel review by configured agents" },
|
|
22
|
+
{ title: "Fix", detail: "Fix must-fix issues from aggregated report" },
|
|
23
|
+
],
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// ── 参数解析 + 白名单校验(fail-fast) ────────────────────────────
|
|
27
|
+
|
|
28
|
+
const TARGET_TYPES = ["git-diff", "file", "dir", "text"];
|
|
29
|
+
const VALID_ARG_KEYS = new Set([
|
|
30
|
+
"targetType", "target", "agents", "batchNames", "reviewPrompt", "fixPrompt",
|
|
31
|
+
"autoCommit", "maxRounds", "stuckThreshold", "model", "skipCleanAgents",
|
|
32
|
+
"recheckAfterFix", "_runId",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
function fail(msg) {
|
|
36
|
+
throw new Error("review-fix-loop: " + msg);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizeBool(v, name, def) {
|
|
40
|
+
if (v === undefined || v === null || v === "") return def;
|
|
41
|
+
if (v === true || v === "true") return true;
|
|
42
|
+
if (v === false || v === "false") return false;
|
|
43
|
+
fail("参数 " + name + " 必须是布尔值(true/false),实际: " + JSON.stringify(v));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeInt(v, name, def) {
|
|
47
|
+
if (v === undefined || v === null || v === "") return def;
|
|
48
|
+
const n = typeof v === "number" ? v : Number(String(v).trim());
|
|
49
|
+
if (!Number.isInteger(n) || n <= 0) fail("参数 " + name + " 必须是正整数,实际: " + JSON.stringify(v));
|
|
50
|
+
return n;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 白名单校验:未知参数名(防 batchX 拼错如 batchl)→ 报错
|
|
54
|
+
for (const key of Object.keys($ARGS)) {
|
|
55
|
+
if (VALID_ARG_KEYS.has(key)) continue;
|
|
56
|
+
if (/^batch\d+$/.test(key)) continue;
|
|
57
|
+
fail("未知参数: " + key + "(合法参数: targetType/target/batch1..batchN/agents/batchNames/reviewPrompt/fixPrompt/autoCommit/maxRounds/stuckThreshold/model/skipCleanAgents/recheckAfterFix)");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const targetType = $ARGS.targetType;
|
|
61
|
+
if (!TARGET_TYPES.includes(targetType)) {
|
|
62
|
+
fail("targetType 必填且必须是枚举之一: " + TARGET_TYPES.join("/") + "(实际: " + JSON.stringify(targetType) + ")");
|
|
63
|
+
}
|
|
64
|
+
const target = typeof $ARGS.target === "string" ? $ARGS.target.trim() : "";
|
|
65
|
+
if (!target) fail("target 必填(git-diff 时传 base ref 如 main;file 传路径;dir 传目录;text 传描述)");
|
|
66
|
+
|
|
67
|
+
const reviewPrompt = typeof $ARGS.reviewPrompt === "string" && $ARGS.reviewPrompt.trim()
|
|
68
|
+
? $ARGS.reviewPrompt.trim()
|
|
69
|
+
: "审查变更/目标是否存在:逻辑错误、边界条件、类型不安全、遗漏、回归风险、代码规范问题。发现问题分三级:critical(严重,必须修)/ major(重要,应当修)/ minor(轻微,建议修)。critical+major 计入 must_fix。";
|
|
70
|
+
const fixPrompt = typeof $ARGS.fixPrompt === "string" && $ARGS.fixPrompt.trim()
|
|
71
|
+
? $ARGS.fixPrompt.trim()
|
|
72
|
+
: "修复全部 must-fix 问题(critical/major)。最小正确修复,不做重构、不做风格改动。";
|
|
73
|
+
const autoCommit = normalizeBool($ARGS.autoCommit, "autoCommit", false);
|
|
74
|
+
const maxRounds = normalizeInt($ARGS.maxRounds, "maxRounds", 10);
|
|
75
|
+
const stuckThreshold = normalizeInt($ARGS.stuckThreshold, "stuckThreshold", 3);
|
|
76
|
+
const skipCleanAgents = normalizeBool($ARGS.skipCleanAgents, "skipCleanAgents", true);
|
|
77
|
+
const recheckAfterFix = normalizeBool($ARGS.recheckAfterFix, "recheckAfterFix", false);
|
|
78
|
+
const MODEL = typeof $ARGS.model === "string" && $ARGS.model.trim() ? $ARGS.model.trim() : undefined;
|
|
79
|
+
|
|
80
|
+
// 审查指令模板(按 targetType 生成,注入每个 review agent 的 prompt)
|
|
81
|
+
function buildReviewInstruction() {
|
|
82
|
+
switch (targetType) {
|
|
83
|
+
case "git-diff":
|
|
84
|
+
return "Review `git diff " + target + "...HEAD` for all committed changes against " + target + ".\n" +
|
|
85
|
+
"ALSO run `git status --porcelain` and `git diff` to review uncommitted working-tree changes " +
|
|
86
|
+
"(fixes may be uncommitted when autoCommit=false; uncommitted changes ARE in scope).";
|
|
87
|
+
case "file":
|
|
88
|
+
return "Read and review the file: " + target;
|
|
89
|
+
case "dir":
|
|
90
|
+
return "Explore and review the directory: " + target + " (list files, then read the relevant ones)";
|
|
91
|
+
case "text":
|
|
92
|
+
return "Review target: " + target;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const reviewInstruction = buildReviewInstruction();
|
|
96
|
+
|
|
97
|
+
// 批次解析:batch1..batchN(缺号报错)/ agents 简写 / 默认单批 [reviewer]
|
|
98
|
+
function parseBatches() {
|
|
99
|
+
const batchKeys = Object.keys($ARGS)
|
|
100
|
+
.filter((k) => /^batch\d+$/.test(k))
|
|
101
|
+
.sort((a, b) => parseInt(a.slice(5), 10) - parseInt(b.slice(5), 10));
|
|
102
|
+
const nums = batchKeys.map((k) => parseInt(k.slice(5), 10));
|
|
103
|
+
for (let i = 1; i <= nums.length; i++) {
|
|
104
|
+
if (!nums.includes(i)) fail("批次参数缺号:有 batch" + nums.join("/") + " 但无 batch" + i + "(批次必须连续编号)");
|
|
105
|
+
}
|
|
106
|
+
if ($ARGS.agents !== undefined && $ARGS.batch1 !== undefined) {
|
|
107
|
+
fail("agents 与 batch1 不能同时传(agents 是单批简写)");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let rawBatches;
|
|
111
|
+
if (batchKeys.length > 0) {
|
|
112
|
+
rawBatches = batchKeys.map((k) => $ARGS[k]);
|
|
113
|
+
} else if ($ARGS.agents !== undefined) {
|
|
114
|
+
rawBatches = [$ARGS.agents];
|
|
115
|
+
} else {
|
|
116
|
+
rawBatches = ["reviewer"]; // 默认单批:包内置通用审查 agent
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return rawBatches.map((raw, idx) => {
|
|
120
|
+
if (typeof raw !== "string" || !raw.trim()) fail("batch" + (idx + 1) + " 不能为空");
|
|
121
|
+
const names = raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
122
|
+
if (names.length === 0) fail("batch" + (idx + 1) + " 为空(逗号分隔 agent 名/文件路径)");
|
|
123
|
+
if (new Set(names).size !== names.length) fail("batch" + (idx + 1) + " 内存在重复 agent: " + names);
|
|
124
|
+
return names;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
const BATCHES = parseBatches();
|
|
128
|
+
|
|
129
|
+
// batchNames(数量校验)
|
|
130
|
+
const rawBatchNames = typeof $ARGS.batchNames === "string" && $ARGS.batchNames.trim()
|
|
131
|
+
? $ARGS.batchNames.split(",").map((s) => s.trim()).filter(Boolean)
|
|
132
|
+
: [];
|
|
133
|
+
if (rawBatchNames.length > 0 && rawBatchNames.length !== BATCHES.length) {
|
|
134
|
+
fail("batchNames 数量(" + rawBatchNames.length + ")必须与批数(" + BATCHES.length + ")一致");
|
|
135
|
+
}
|
|
136
|
+
const BATCH_NAMES = rawBatchNames.length ? rawBatchNames : BATCHES.map((_, i) => "batch-" + (i + 1));
|
|
137
|
+
|
|
138
|
+
// fallow-scan 只在 git-diff 类型下有意义
|
|
139
|
+
for (let i = 0; i < BATCHES.length; i++) {
|
|
140
|
+
if (BATCHES[i].includes("fallow-scan") && targetType !== "git-diff") {
|
|
141
|
+
fail("fallow-scan 只支持 targetType=git-diff(它审查 git 变更的静态分析),实际 targetType=" + targetType);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ── Schemas ─────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
const reviewerSchema = {
|
|
148
|
+
type: "object",
|
|
149
|
+
properties: {
|
|
150
|
+
report_file: { type: "string", description: "Absolute path to the written review report (.md)" },
|
|
151
|
+
must_fix: { type: "number", description: "Number of must-fix (critical+major) issues found" },
|
|
152
|
+
suggestion: { type: "number", description: "Number of suggestion-level (minor) issues found" },
|
|
153
|
+
},
|
|
154
|
+
required: ["report_file", "must_fix", "suggestion"],
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const aggregatorSchema = {
|
|
158
|
+
type: "object",
|
|
159
|
+
properties: {
|
|
160
|
+
report_file: { type: "string", description: "Absolute path to aggregated.md" },
|
|
161
|
+
must_fix: { type: "number", description: "Total must-fix after dedup across all dimensions" },
|
|
162
|
+
suggestion: { type: "number", description: "Total suggestions after dedup across all dimensions" },
|
|
163
|
+
},
|
|
164
|
+
required: ["report_file", "must_fix", "suggestion"],
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// ── Per-run isolation: runId-scoped directories ─────────────────────
|
|
168
|
+
|
|
169
|
+
const fs = require("fs");
|
|
170
|
+
const path = require("path");
|
|
171
|
+
const os = require("os");
|
|
172
|
+
|
|
173
|
+
const RUN_ID = ($ARGS._runId && typeof $ARGS._runId === "string") ? $ARGS._runId : "run-" + Date.now();
|
|
174
|
+
const RUN_ROOT = path.join(os.tmpdir(), "review-fix-loop", RUN_ID);
|
|
175
|
+
const STATE_FILE = RUN_ROOT + "/state.json";
|
|
176
|
+
|
|
177
|
+
fs.mkdirSync(RUN_ROOT, { recursive: true });
|
|
178
|
+
log("Run directory: " + RUN_ROOT);
|
|
179
|
+
|
|
180
|
+
// ── State management (persistent, atomic writes) ────────────────────
|
|
181
|
+
|
|
182
|
+
function loadState() {
|
|
183
|
+
try {
|
|
184
|
+
return JSON.parse(fs.readFileSync(STATE_FILE, "utf-8"));
|
|
185
|
+
} catch {
|
|
186
|
+
return {
|
|
187
|
+
meta: {
|
|
188
|
+
runId: RUN_ID, workspace: $WORKSPACE || "", model: MODEL || "(default)",
|
|
189
|
+
targetType, target, batches: BATCHES, startedAt: new Date().toISOString(),
|
|
190
|
+
},
|
|
191
|
+
agentStatus: {},
|
|
192
|
+
fixCount: 0,
|
|
193
|
+
batches: [],
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function saveState(state) {
|
|
199
|
+
const tmp = STATE_FILE + ".tmp";
|
|
200
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
|
|
201
|
+
fs.renameSync(tmp, STATE_FILE);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// clean 记录:lastCleanBatch + 当时全局 fixCount 快照(跨批跳过判定依据)
|
|
205
|
+
function recordAgentClean(state, agentName, batchIndex) {
|
|
206
|
+
const s = state.agentStatus[agentName] || { lastCleanBatch: 0, lastCleanFixCount: 0, lastActiveRound: 0, lastMustFix: undefined };
|
|
207
|
+
s.lastCleanBatch = batchIndex;
|
|
208
|
+
s.lastCleanFixCount = state.fixCount;
|
|
209
|
+
s.lastActiveRound = batchIndex;
|
|
210
|
+
state.agentStatus[agentName] = s;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function recordAgentDirty(state, agentName, mustFix, batchIndex) {
|
|
214
|
+
const s = state.agentStatus[agentName] || { lastCleanBatch: 0, lastCleanFixCount: 0, lastActiveRound: 0, lastMustFix: undefined };
|
|
215
|
+
s.lastActiveRound = batchIndex;
|
|
216
|
+
s.lastMustFix = mustFix;
|
|
217
|
+
state.agentStatus[agentName] = s;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── Helpers ─────────────────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
function parseResult(raw) {
|
|
223
|
+
if (typeof raw === "object" && raw !== null) return raw;
|
|
224
|
+
if (typeof raw === "string") {
|
|
225
|
+
let s = raw.trim();
|
|
226
|
+
const fence = s.match(/^```(?:json)?\s*\n([\s\S]*?)\n?```\s*$/i);
|
|
227
|
+
if (fence) s = fence[1].trim();
|
|
228
|
+
if (!s.startsWith("{") && !s.startsWith("[")) {
|
|
229
|
+
const first = s.indexOf("{");
|
|
230
|
+
const last = s.lastIndexOf("}");
|
|
231
|
+
if (first !== -1 && last > first) s = s.slice(first, last + 1);
|
|
232
|
+
}
|
|
233
|
+
try { return JSON.parse(s); } catch { /* fall through */ }
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function normalizeAggregatorResult(raw) {
|
|
239
|
+
const parsed = parseResult(raw);
|
|
240
|
+
if (!parsed) return null;
|
|
241
|
+
const mustFix =
|
|
242
|
+
typeof parsed.must_fix === "number" ? parsed.must_fix :
|
|
243
|
+
typeof parsed.totalMustFix === "number" ? parsed.totalMustFix :
|
|
244
|
+
typeof parsed.mustFix === "number" ? parsed.mustFix : undefined;
|
|
245
|
+
const suggestion =
|
|
246
|
+
typeof parsed.suggestion === "number" ? parsed.suggestion :
|
|
247
|
+
typeof parsed.totalSuggestions === "number" ? parsed.totalSuggestions :
|
|
248
|
+
typeof parsed.suggestions === "number" ? parsed.suggestions : 0;
|
|
249
|
+
if (typeof mustFix !== "number") return null;
|
|
250
|
+
return { report_file: parsed.report_file || parsed.reportFile, must_fix: mustFix, suggestion };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function parseAggregatedMd(content) {
|
|
254
|
+
const mustFixMatch = content.match(/[-*]\s*Must[-_]fix\s*[::]\s*(\d+)/i);
|
|
255
|
+
if (!mustFixMatch) return null;
|
|
256
|
+
const suggestionMatch = content.match(/[-*]\s*Suggestions?\s*[::]\s*(\d+)/i);
|
|
257
|
+
return {
|
|
258
|
+
must_fix: parseInt(mustFixMatch[1], 10),
|
|
259
|
+
suggestion: suggestionMatch ? parseInt(suggestionMatch[1], 10) : 0,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// 自定义 .md agent 加载:frontmatter(name/description/model)+ 正文
|
|
264
|
+
function loadAgentMd(filePath) {
|
|
265
|
+
let content;
|
|
266
|
+
try {
|
|
267
|
+
content = fs.readFileSync(filePath, "utf-8");
|
|
268
|
+
} catch (e) {
|
|
269
|
+
fail("agent 文件读取失败: " + filePath + " (" + e.message + ")");
|
|
270
|
+
}
|
|
271
|
+
let name = path.basename(filePath, ".md");
|
|
272
|
+
let model, description;
|
|
273
|
+
let body = content.trim();
|
|
274
|
+
if (content.startsWith("---")) {
|
|
275
|
+
const closeIdx = content.indexOf("---", 3);
|
|
276
|
+
if (closeIdx !== -1) {
|
|
277
|
+
const yaml = content.slice(3, closeIdx);
|
|
278
|
+
body = content.slice(closeIdx + 3).trim();
|
|
279
|
+
const extract = (key) => {
|
|
280
|
+
const m = yaml.match(new RegExp("^" + key + ":\\s*(.+)$", "m"));
|
|
281
|
+
if (!m) return undefined;
|
|
282
|
+
let v = m[1].trim();
|
|
283
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
|
|
284
|
+
return v || undefined;
|
|
285
|
+
};
|
|
286
|
+
name = extract("name") || name;
|
|
287
|
+
model = extract("model");
|
|
288
|
+
description = extract("description");
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return { name, model, description, systemPrompt: body, report: name, title: description || name, isCustom: true };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ── Agent defs 解析(每批元素统一解析) ─────────────────────────────
|
|
295
|
+
|
|
296
|
+
// fallow-scan:内置工具型 def(无 .md,跑 fallow audit 静态分析)
|
|
297
|
+
const FALLOW_DEF = { name: "fallow-scan", title: "FALLOW STATIC ANALYSIS", report: "fallow-scan", isFallow: true };
|
|
298
|
+
|
|
299
|
+
function resolveAgentDefs(batchNames) {
|
|
300
|
+
return batchNames.map((item) => {
|
|
301
|
+
if (item === "fallow-scan") return FALLOW_DEF;
|
|
302
|
+
if (item.includes("/") || item.endsWith(".md")) return loadAgentMd(item);
|
|
303
|
+
return { name: item, report: item.replace(/^review-/, ""), title: item.toUpperCase() };
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ── Build review calls ──────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
function buildReviewCall(def, round, max, batchIndex, roundDir) {
|
|
310
|
+
const header = "Batch " + batchIndex + " Round " + round + "/" + max + " — " + BATCH_NAMES[batchIndex - 1];
|
|
311
|
+
const prevBatchesHint = batchIndex > 1
|
|
312
|
+
? "\nPrior batch reports (optional context): " + RUN_ROOT + "/batch-*/ (use read)"
|
|
313
|
+
: "";
|
|
314
|
+
const base = {
|
|
315
|
+
model: MODEL || def.model,
|
|
316
|
+
schema: reviewerSchema,
|
|
317
|
+
description: def.name,
|
|
318
|
+
timeoutMs: 1_800_000,
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
if (def.isFallow) {
|
|
322
|
+
return {
|
|
323
|
+
...base,
|
|
324
|
+
prompt: [
|
|
325
|
+
header,
|
|
326
|
+
"",
|
|
327
|
+
"Fallow static-analysis pre-scan (tool-based, NOT a git-diff review).",
|
|
328
|
+
"",
|
|
329
|
+
"Steps:",
|
|
330
|
+
"1. Check if fallow is installed: `which fallow`",
|
|
331
|
+
"2. If NOT installed: write the report with a one-line note, must_fix=0, suggestion=0.",
|
|
332
|
+
"3. If installed, run: `fallow audit --base " + target + " --format json --quiet`",
|
|
333
|
+
"4. Extract: complexity hotspots, dead code, unused exports, circular deps",
|
|
334
|
+
"5. Classify findings: critical/major count into must_fix; minor into suggestion.",
|
|
335
|
+
"",
|
|
336
|
+
"output 路径:" + roundDir + "/" + def.report + ".md",
|
|
337
|
+
"Write report to: " + roundDir + "/" + def.report + ".md",
|
|
338
|
+
].join("\n"),
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const spec = def.isCustom
|
|
343
|
+
? "\n\nReviewer specification (from agent file):\n" + def.systemPrompt
|
|
344
|
+
: "";
|
|
345
|
+
return {
|
|
346
|
+
...base,
|
|
347
|
+
prompt: [
|
|
348
|
+
header,
|
|
349
|
+
"",
|
|
350
|
+
reviewInstruction + prevBatchesHint,
|
|
351
|
+
"",
|
|
352
|
+
"Review requirements:",
|
|
353
|
+
reviewPrompt + spec,
|
|
354
|
+
"",
|
|
355
|
+
"output 路径:" + roundDir + "/" + def.report + ".md",
|
|
356
|
+
"Write report to: " + roundDir + "/" + def.report + ".md",
|
|
357
|
+
].join("\n"),
|
|
358
|
+
agent: def.isCustom ? undefined : def.name,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// agent 名解析失败(AgentRegistry not found)时,尝试 review- 前缀兜底
|
|
363
|
+
async function runReviewAgent(call) {
|
|
364
|
+
let raw = await agent(call);
|
|
365
|
+
if (raw && typeof raw === "object" && raw.error && typeof raw.error === "string"
|
|
366
|
+
&& raw.error.includes("Agent not found") && call.agent && !call.agent.startsWith("review-")) {
|
|
367
|
+
log("Agent not found: " + call.agent + " — retrying with review- prefix");
|
|
368
|
+
raw = await agent({ ...call, agent: "review-" + call.agent });
|
|
369
|
+
}
|
|
370
|
+
return raw;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ── Main loop: batches (serial) × rounds (per-batch) ────────────────
|
|
374
|
+
|
|
375
|
+
const state = loadState();
|
|
376
|
+
let totalFixed = 0;
|
|
377
|
+
let terminated = "clean";
|
|
378
|
+
let finalMessage = "";
|
|
379
|
+
|
|
380
|
+
for (let batchIndex = 1; batchIndex <= BATCHES.length; batchIndex++) {
|
|
381
|
+
const defs = resolveAgentDefs(BATCHES[batchIndex - 1]);
|
|
382
|
+
const cleanNames = new Set();
|
|
383
|
+
let round = 0;
|
|
384
|
+
let prevTotal = -1;
|
|
385
|
+
let stuckCount = 0;
|
|
386
|
+
let batchClean = false;
|
|
387
|
+
let roundHasFix = false; // recheckAfterFix 用:上轮是否有 fix
|
|
388
|
+
const batchRounds = [];
|
|
389
|
+
|
|
390
|
+
// 跨批跳过:agent 在更早批 clean 且此后无 fix → 本批不派发
|
|
391
|
+
if (batchIndex > 1) {
|
|
392
|
+
for (const def of defs) {
|
|
393
|
+
const s = state.agentStatus[def.name];
|
|
394
|
+
if (s && s.lastCleanBatch && s.lastCleanBatch < batchIndex && s.lastCleanFixCount === state.fixCount) {
|
|
395
|
+
cleanNames.add(def.name);
|
|
396
|
+
log("Cross-batch skip: " + def.name + " (clean in batch " + s.lastCleanBatch + ", no fix since)");
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
while (round < maxRounds) {
|
|
402
|
+
round++;
|
|
403
|
+
log("--- Batch " + batchIndex + "/" + BATCHES.length + " (" + BATCH_NAMES[batchIndex - 1] + ") Round " + round + "/" + maxRounds + " ---");
|
|
404
|
+
|
|
405
|
+
phase("Review");
|
|
406
|
+
const roundDir = RUN_ROOT + "/batch-" + batchIndex + "/round-" + round;
|
|
407
|
+
fs.mkdirSync(roundDir, { recursive: true });
|
|
408
|
+
|
|
409
|
+
let active = defs.filter((def) => !(skipCleanAgents && cleanNames.has(def.name)));
|
|
410
|
+
if (recheckAfterFix && round > 1 && roundHasFix) {
|
|
411
|
+
active = defs; // fix 后重派全批(回归防护)
|
|
412
|
+
cleanNames.clear();
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (active.length === 0) {
|
|
416
|
+
log("All agents clean/skipped — batch " + batchIndex + " done.");
|
|
417
|
+
batchClean = true;
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
log("Review: " + active.map((d) => d.name).join(", ") + " (" + active.length + " agent(s) in parallel)...");
|
|
422
|
+
const calls = active.map((def) => buildReviewCall(def, round, maxRounds, batchIndex, roundDir));
|
|
423
|
+
const allRaw = await parallel(calls);
|
|
424
|
+
|
|
425
|
+
// per-agent 结果区分:parallel 结果与 calls 一一对应
|
|
426
|
+
const reviewResults = [];
|
|
427
|
+
const agentRoundResults = [];
|
|
428
|
+
for (let i = 0; i < allRaw.length; i++) {
|
|
429
|
+
const raw = allRaw[i];
|
|
430
|
+
if (raw && typeof raw === "object" && raw.error) {
|
|
431
|
+
// fail-fast:审查 agent 调用失败(含 AgentRegistry not found)不得静默跳过
|
|
432
|
+
fail("审查 agent 调用失败: " + active[i].name + " — " + raw.error);
|
|
433
|
+
}
|
|
434
|
+
const parsed = parseResult(raw);
|
|
435
|
+
if (parsed && typeof parsed.must_fix === "number") {
|
|
436
|
+
reviewResults.push(parsed);
|
|
437
|
+
const def = active[i];
|
|
438
|
+
if (parsed.must_fix === 0) {
|
|
439
|
+
recordAgentClean(state, def.name, batchIndex);
|
|
440
|
+
cleanNames.add(def.name);
|
|
441
|
+
} else {
|
|
442
|
+
recordAgentDirty(state, def.name, parsed.must_fix, batchIndex);
|
|
443
|
+
}
|
|
444
|
+
agentRoundResults.push({ name: def.name, must_fix: parsed.must_fix, suggestion: parsed.suggestion ?? 0, clean: parsed.must_fix === 0 });
|
|
445
|
+
} else {
|
|
446
|
+
// tools 受限的 agent(如 tools: read)会过滤掉 structured-output → schema 失效,
|
|
447
|
+
// 结果缺 must_fix。fail 信息完整 dump raw 便于定位。
|
|
448
|
+
fail("审查 agent 结果无效(缺 must_fix): " + active[i].name + " raw=" + JSON.stringify(raw).slice(0, 400));
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (reviewResults.every((r) => r.must_fix === 0)) {
|
|
453
|
+
log("Batch " + batchIndex + " round " + round + ": all agents clean.");
|
|
454
|
+
batchRounds.push({ round, mustFix: 0, suggestion: reviewResults.reduce((a, r) => a + (r.suggestion ?? 0), 0), agents: agentRoundResults, modifiedFiles: [] });
|
|
455
|
+
saveState(state);
|
|
456
|
+
batchClean = true;
|
|
457
|
+
break;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// ── Aggregate(内置 prompt,不依赖任何 agent.md) ─────────
|
|
461
|
+
const aggRaw = await agent({
|
|
462
|
+
prompt: [
|
|
463
|
+
"Batch " + batchIndex + "/" + BATCHES.length + " Round " + round + "/" + maxRounds + " — AGGREGATE REVIEWS",
|
|
464
|
+
"",
|
|
465
|
+
"You have TWO outputs: (1) a markdown report file and (2) a JSON return value.",
|
|
466
|
+
"",
|
|
467
|
+
"Sub-review results: " + JSON.stringify(reviewResults, null, 2),
|
|
468
|
+
"outputDir: " + roundDir,
|
|
469
|
+
"",
|
|
470
|
+
"─── PART 1: WRITE FILE ───────────────────────────────────",
|
|
471
|
+
"Write the human-readable aggregated report to:",
|
|
472
|
+
roundDir + "/aggregated.md",
|
|
473
|
+
"",
|
|
474
|
+
"Top section MUST be:",
|
|
475
|
+
"```",
|
|
476
|
+
"## Summary",
|
|
477
|
+
"- Must-fix: <N>",
|
|
478
|
+
"- Suggestions: <N>",
|
|
479
|
+
"- Infos: <N>",
|
|
480
|
+
"- Dimensions reviewed: <comma-separated>",
|
|
481
|
+
"- Dedup: <N> duplicates removed",
|
|
482
|
+
"```",
|
|
483
|
+
"",
|
|
484
|
+
"Followed by tables of Must-Fix Issues, Suggestions, Infos, and a Conclusion section.",
|
|
485
|
+
"The format `- Must-fix: N` and `- Suggestions: N` is critical: a fallback parser depends on it.",
|
|
486
|
+
"",
|
|
487
|
+
"─── PART 2: RETURN JSON (CRITICAL — loop reads THIS) ─────",
|
|
488
|
+
"Your FINAL response MUST be a single JSON object and NOTHING ELSE.",
|
|
489
|
+
"",
|
|
490
|
+
"Required shape (exact field names, no aliases, no extras):",
|
|
491
|
+
"{",
|
|
492
|
+
' "report_file": "' + roundDir + '/aggregated.md",',
|
|
493
|
+
' "must_fix": <integer>,',
|
|
494
|
+
' "suggestion": <integer>',
|
|
495
|
+
"}",
|
|
496
|
+
"",
|
|
497
|
+
"STRICT RULES:",
|
|
498
|
+
"- Field names MUST be exactly: report_file, must_fix, suggestion",
|
|
499
|
+
"- must_fix and suggestion MUST be integers — NOT strings, NOT null, NOT undefined",
|
|
500
|
+
"- The JSON object MUST be the ONLY thing in your final response",
|
|
501
|
+
"- DO NOT wrap in markdown code fences, DO NOT add prose before/after",
|
|
502
|
+
"",
|
|
503
|
+
"─── SELF-CHECK before returning ──────────────────────────",
|
|
504
|
+
"1. Did you write " + roundDir + "/aggregated.md? If not, do it first.",
|
|
505
|
+
"2. Is must_fix in your JSON equal to the 'Must-fix: N' in your markdown?",
|
|
506
|
+
"3. Is your final response the bare JSON object, no fences, no prose?",
|
|
507
|
+
].join("\n"),
|
|
508
|
+
model: MODEL,
|
|
509
|
+
schema: aggregatorSchema,
|
|
510
|
+
description: "aggregate",
|
|
511
|
+
timeoutMs: 1_800_000,
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
let agg = normalizeAggregatorResult(aggRaw);
|
|
515
|
+
|
|
516
|
+
if (!agg || typeof agg.must_fix !== "number") {
|
|
517
|
+
const rawPreview = (typeof aggRaw === "string" ? aggRaw : JSON.stringify(aggRaw)).slice(0, 200);
|
|
518
|
+
log("Aggregator JSON invalid (len=" + (aggRaw?.length ?? 0) + "): " + rawPreview);
|
|
519
|
+
const fallbackPath = (agg && agg.report_file) || (roundDir + "/aggregated.md");
|
|
520
|
+
try {
|
|
521
|
+
const content = fs.readFileSync(fallbackPath, "utf-8");
|
|
522
|
+
const parsed = parseAggregatedMd(content);
|
|
523
|
+
if (parsed && typeof parsed.must_fix === "number") {
|
|
524
|
+
agg = { report_file: fallbackPath, must_fix: parsed.must_fix, suggestion: parsed.suggestion ?? 0 };
|
|
525
|
+
log("Fallback parsed from " + fallbackPath + ": must_fix=" + agg.must_fix);
|
|
526
|
+
}
|
|
527
|
+
} catch { /* fallback read failed */ }
|
|
528
|
+
|
|
529
|
+
if (!agg || typeof agg.must_fix !== "number") {
|
|
530
|
+
log("Aggregator failed and fallback failed, stopping.");
|
|
531
|
+
state.batches.push({ index: batchIndex, name: BATCH_NAMES[batchIndex - 1], rounds: batchRounds });
|
|
532
|
+
saveState(state);
|
|
533
|
+
terminated = "aggregator-failure";
|
|
534
|
+
finalMessage = "Batch " + batchIndex + " round " + round + ": aggregator 失败且 fallback 解析失败";
|
|
535
|
+
batchIndex = BATCHES.length + 1; // 终止外层循环
|
|
536
|
+
break;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const mustFix = agg.must_fix;
|
|
541
|
+
const suggestion = agg.suggestion ?? 0;
|
|
542
|
+
log("Aggregated: " + mustFix + " must-fix + " + suggestion + " suggestion(s).");
|
|
543
|
+
|
|
544
|
+
// ── Stuck detection ─────────────────────────────────────
|
|
545
|
+
const total = mustFix + suggestion;
|
|
546
|
+
if (prevTotal >= 0 && total >= prevTotal) {
|
|
547
|
+
stuckCount++;
|
|
548
|
+
if (stuckCount >= stuckThreshold) {
|
|
549
|
+
log("Stuck: total issues not decreasing for " + stuckThreshold + " rounds. Stopping.");
|
|
550
|
+
batchRounds.push({ round, mustFix, suggestion, agents: agentRoundResults, modifiedFiles: [] });
|
|
551
|
+
state.batches.push({ index: batchIndex, name: BATCH_NAMES[batchIndex - 1], rounds: batchRounds });
|
|
552
|
+
saveState(state);
|
|
553
|
+
terminated = "stuck";
|
|
554
|
+
finalMessage = "Batch " + batchIndex + " round " + round + ": 总问题数连续 " + stuckThreshold + " 轮不下降";
|
|
555
|
+
batchIndex = BATCHES.length + 1;
|
|
556
|
+
break;
|
|
557
|
+
}
|
|
558
|
+
} else {
|
|
559
|
+
stuckCount = 0;
|
|
560
|
+
}
|
|
561
|
+
prevTotal = total;
|
|
562
|
+
|
|
563
|
+
// ── Fix ─────────────────────────────────────────────────
|
|
564
|
+
phase("Fix");
|
|
565
|
+
let reportContent;
|
|
566
|
+
try {
|
|
567
|
+
reportContent = fs.readFileSync(agg.report_file, "utf-8");
|
|
568
|
+
} catch {
|
|
569
|
+
reportContent = "(could not read aggregated report)";
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
let prevHead = "";
|
|
573
|
+
try {
|
|
574
|
+
prevHead = require("child_process").execSync(
|
|
575
|
+
"git rev-parse HEAD", { encoding: "utf-8", timeout: 10_000 }
|
|
576
|
+
).trim();
|
|
577
|
+
} catch {
|
|
578
|
+
// 非 git 项目(如纯文档目录):prevHead 为空,跳过 modifiedFiles 统计
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const commitInstr = autoCommit
|
|
582
|
+
? "- After all fixes, stage ONLY the files you modified: `git add <file1> <file2> ...` (explicit paths).\n" +
|
|
583
|
+
"- NEVER use `git add -A` or `git add .` — the workspace may contain unrelated untracked files.\n" +
|
|
584
|
+
"- Commit with message: `fix: review batch " + batchIndex + " round " + round + " — " + mustFix + " must-fix`"
|
|
585
|
+
: "- Do NOT commit. Leave the fixes in the working tree (autoCommit=false).";
|
|
586
|
+
|
|
587
|
+
const fxRaw = await agent({
|
|
588
|
+
prompt: [
|
|
589
|
+
"Fix round " + round + " (batch " + batchIndex + "): Fix ALL must-fix issues from the aggregated review report below.",
|
|
590
|
+
"",
|
|
591
|
+
"## Aggregated Review Report",
|
|
592
|
+
reportContent,
|
|
593
|
+
"",
|
|
594
|
+
"## Instructions",
|
|
595
|
+
"- Fix every must-fix issue listed in the report",
|
|
596
|
+
"- Apply the MINIMAL correct fix (no refactoring, no style changes)",
|
|
597
|
+
"- Verify each fix by reading the changed file afterwards",
|
|
598
|
+
fixPrompt,
|
|
599
|
+
commitInstr,
|
|
600
|
+
"",
|
|
601
|
+
"Return the count of issues fixed.",
|
|
602
|
+
].join("\n"),
|
|
603
|
+
schema: {
|
|
604
|
+
type: "object",
|
|
605
|
+
properties: {
|
|
606
|
+
fixed_count: { type: "number", description: "Number of issues fixed" },
|
|
607
|
+
fixes: { type: "array", items: { type: "string" }, description: "One-line description of each fix" },
|
|
608
|
+
},
|
|
609
|
+
required: ["fixed_count"],
|
|
610
|
+
},
|
|
611
|
+
model: MODEL,
|
|
612
|
+
description: "fix",
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
const fx = parseResult(fxRaw);
|
|
616
|
+
if (!fx) {
|
|
617
|
+
log("Fix agent failed, stopping.");
|
|
618
|
+
batchRounds.push({ round, mustFix, suggestion, agents: agentRoundResults, modifiedFiles: [] });
|
|
619
|
+
state.batches.push({ index: batchIndex, name: BATCH_NAMES[batchIndex - 1], rounds: batchRounds });
|
|
620
|
+
saveState(state);
|
|
621
|
+
terminated = "fix-failure";
|
|
622
|
+
finalMessage = "Batch " + batchIndex + " round " + round + ": fix agent 结果无效";
|
|
623
|
+
batchIndex = BATCHES.length + 1;
|
|
624
|
+
break;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
const fixedCount = fx.fixed_count ?? mustFix;
|
|
628
|
+
totalFixed += fixedCount;
|
|
629
|
+
state.fixCount++;
|
|
630
|
+
roundHasFix = true;
|
|
631
|
+
|
|
632
|
+
let modifiedFiles = [];
|
|
633
|
+
if (prevHead) {
|
|
634
|
+
try {
|
|
635
|
+
const out = require("child_process").execSync(
|
|
636
|
+
"git diff --name-only " + prevHead, { encoding: "utf-8", timeout: 10_000 }
|
|
637
|
+
).trim();
|
|
638
|
+
modifiedFiles = out ? out.split("\n") : [];
|
|
639
|
+
} catch { /* empty */ }
|
|
640
|
+
}
|
|
641
|
+
batchRounds.push({ round, mustFix, suggestion, agents: agentRoundResults, modifiedFiles });
|
|
642
|
+
saveState(state);
|
|
643
|
+
|
|
644
|
+
log("Fixed " + fixedCount + " issue(s). Total: " + totalFixed + ". Modified " + modifiedFiles.length + " file(s). Continuing...");
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (batchIndex > BATCHES.length) break; // 已终止
|
|
648
|
+
|
|
649
|
+
state.batches.push({ index: batchIndex, name: BATCH_NAMES[batchIndex - 1], rounds: batchRounds });
|
|
650
|
+
|
|
651
|
+
if (!batchClean) {
|
|
652
|
+
// 该批达到 maxRounds 仍残留 must-fix → fail-fast,不进入后续批
|
|
653
|
+
terminated = "max-rounds";
|
|
654
|
+
finalMessage = "Batch " + batchIndex + " (" + BATCH_NAMES[batchIndex - 1] + ") 达到 maxRounds=" + maxRounds + " 仍有 must-fix,终止整个 workflow";
|
|
655
|
+
log(finalMessage);
|
|
656
|
+
saveState(state);
|
|
657
|
+
batchIndex = BATCHES.length + 1;
|
|
658
|
+
break;
|
|
659
|
+
}
|
|
660
|
+
saveState(state);
|
|
661
|
+
log("=== Batch " + batchIndex + " (" + BATCH_NAMES[batchIndex - 1] + ") CLEAN ===");
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
log("\n=== Loop Complete ===");
|
|
665
|
+
saveState(state);
|
|
666
|
+
|
|
667
|
+
return {
|
|
668
|
+
batches: BATCHES.length,
|
|
669
|
+
totalFixed,
|
|
670
|
+
terminated,
|
|
671
|
+
targetType,
|
|
672
|
+
target,
|
|
673
|
+
runDir: RUN_ROOT,
|
|
674
|
+
message: terminated === "clean"
|
|
675
|
+
? "All batches clean. " + totalFixed + " issue(s) fixed total. State: " + STATE_FILE
|
|
676
|
+
: finalMessage + ". State: " + STATE_FILE,
|
|
677
|
+
};
|