@zhushanwen/pi-subagent-workflow 4.0.0 → 5.0.0-dev.1

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.
@@ -13,48 +13,75 @@
13
13
  // ⚠️ 唯一带写操作的内置 workflow:fix 阶段会修改文件(autoCommit=true 时 commit)。
14
14
  // ⚠️ lintScript 约束(本脚本已遵守):含 parallel() 入口,禁止 bare IIFE;
15
15
  // agent() 调用顺序确定(批次按配置顺序稳定排序,callId 重放安全)。
16
+ //
17
+ // ⚠️ 与 main 的 4.0.0 版分叉(merge 时 add/add 冲突,刻意决策记录):
18
+ // 本版(feat-recursive-optimize)保留——纯函数拆到 review-fix-loop-utils.cjs(vitest 覆盖)、
19
+ // 支持 fallow-scan 前置批次、无默认批次(batch1..batchN/agents 必传)、recheckAfterFix 默认 false。
20
+ // main 4.0.0 版自包含 677 行、缺批次参数时默认单批 ["reviewer"]、recheckAfterFix 默认 false。
21
+ // merge 时保留本版(功能更全),详见 .changeset/tidy-waves-description-phase-lint.md。
16
22
 
17
23
  const meta = {
18
24
  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
- ],
25
+ description: "审查-修复循环:多批串行(批内并行 review → aggregate → fix → 重审直到 clean)。必填 targetType(git-diff/file/dir/text)+ target。批次由 batch1..batchN 控制(如 batch1=fallow-scan batch2=reviewer),用于前置检查先行的场景。注意:唯一带写操作/commit 副作用的内置 workflow,autoCommit 默认 false;skipCleanAgents 默认 true + recheckAfterFix 默认 false(clean agent 下轮跳过,与字面语义一致);传 recheckAfterFix=true 启用可选强回归模式(fix 后重派全批,clean agent 走限定 prompt 只审改动文件)。可选 fixAgent/maxFixAttempts/convergeNewIssues/convergeRounds 控制修复 agent 与收敛终止(详见 workflows/README.md)。",
26
+ phases: ["Review", "Fix"],
24
27
  };
25
28
 
26
29
  // ── 参数解析 + 白名单校验(fail-fast) ────────────────────────────
27
30
 
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
31
  function fail(msg) {
36
32
  throw new Error("review-fix-loop: " + msg);
37
33
  }
38
34
 
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
- }
35
+ // ── 可测纯函数模块 ────────────────────────────────────────────────
36
+ // 参数校验(normalizeBool/normalizeInt/白名单)/批次解析/聚合结果解析/审查指令构建
37
+ // 的纯函数在 review-fix-loop-utils.cjs(与 recursive-split-utils.cjs 同款模式,
38
+ // vitest 单测见 src/__tests__/review-fix-loop-utils.test.ts)。
39
+ // worker 运行时经 workerData.scriptPath 定位自身目录——内置 workflow npm 包内,
40
+ // process.cwd() 是用户项目目录,不能作为锚点;其他引擎无 workerData 时回退 cwd。
41
+ const {
42
+ TARGET_TYPES,
43
+ VALID_ARG_KEYS,
44
+ normalizeBool,
45
+ normalizeInt,
46
+ parseBatches,
47
+ resolveBatchNames,
48
+ validateFallowScan,
49
+ buildReviewInstruction,
50
+ lockReviewBase,
51
+ buildScopedRecheckPrompt,
52
+ wrapUntrusted,
53
+ buildFixPrompt,
54
+ buildR2ReviewPrompt,
55
+ buildAggregatorPrompt,
56
+ resolveReviewReportPath,
57
+ normalizeFixResult,
58
+ validateFixResult,
59
+ reconcileIssues,
60
+ normalizeReviewResult,
61
+ computeKnownRemaining,
62
+ checkConvergence,
63
+ findNeedsRedesign,
64
+ parseResult,
65
+ normalizeAggregatorResult,
66
+ parseAggregatedMd,
67
+ shouldRetryWithReviewPrefix,
68
+ resolveAgentDefs,
69
+ recordAgentClean,
70
+ recordAgentDirty,
71
+ shouldSkipAgent,
72
+ updateStuckState,
73
+ resolveBatchTerminated,
74
+ } = require(
75
+ (typeof workerData !== "undefined" && workerData && typeof workerData.scriptPath === "string"
76
+ ? require("path").dirname(workerData.scriptPath)
77
+ : process.cwd()) + "/review-fix-loop-utils.cjs"
78
+ );
52
79
 
53
80
  // 白名单校验:未知参数名(防 batchX 拼错如 batchl)→ 报错
54
81
  for (const key of Object.keys($ARGS)) {
55
82
  if (VALID_ARG_KEYS.has(key)) continue;
56
83
  if (/^batch\d+$/.test(key)) continue;
57
- fail("未知参数: " + key + "(合法参数: targetType/target/batch1..batchN/agents/batchNames/reviewPrompt/fixPrompt/autoCommit/maxRounds/stuckThreshold/model/skipCleanAgents/recheckAfterFix)");
84
+ fail("未知参数: " + key + "(合法参数: targetType/target/batch1..batchN/agents/batchNames/reviewPrompt/fixPrompt/autoCommit/maxRounds/stuckThreshold/model/skipCleanAgents/recheckAfterFix/fixAgent/maxFixAttempts/convergeNewIssues/convergeRounds)");
58
85
  }
59
86
 
60
87
  const targetType = $ARGS.targetType;
@@ -70,77 +97,52 @@ const reviewPrompt = typeof $ARGS.reviewPrompt === "string" && $ARGS.reviewPromp
70
97
  const fixPrompt = typeof $ARGS.fixPrompt === "string" && $ARGS.fixPrompt.trim()
71
98
  ? $ARGS.fixPrompt.trim()
72
99
  : "修复全部 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);
100
+ const autoCommit = normalizeBool($ARGS.autoCommit, "autoCommit", false, fail);
101
+ const maxRounds = normalizeInt($ARGS.maxRounds, "maxRounds", 10, fail);
102
+ const stuckThreshold = normalizeInt($ARGS.stuckThreshold, "stuckThreshold", 3, fail);
103
+ const skipCleanAgents = normalizeBool($ARGS.skipCleanAgents, "skipCleanAgents", true, fail);
104
+ // 默认 recheckAfterFix=false:clean agent 下轮跳过(与 skipCleanAgents=true 字面语义一致),
105
+ // RC-5(fix 后全批全量重审放大 token)在默认场景消失。传 true 启用可选强回归模式:fix 后重派
106
+ // 全批,clean agent 走限定 prompt(buildScopedRecheckPrompt,只审 modifiedFiles,5.5)。
107
+ const recheckAfterFix = normalizeBool($ARGS.recheckAfterFix, "recheckAfterFix", false, fail);
108
+ // fixAgent(5.3):值语义同 batchN 的 agent 项(内置名 / agent.md 路径),解析复用
109
+ // resolveAgentDefs 白名单与加载逻辑。传入时 fix 阶段用 agent({agent: ...}) 派发(代码场景
110
+ // 的 verify 命令写在该 agent.md 内);未传保持现状(通用 subagent + 内联 prompt)。
111
+ const FIX_AGENT_RAW = typeof $ARGS.fixAgent === "string" && $ARGS.fixAgent.trim()
112
+ ? $ARGS.fixAgent.trim() : undefined;
113
+ const FIX_DEF = FIX_AGENT_RAW ? resolveAgentDefs([FIX_AGENT_RAW])[0] : null;
114
+ // 5.7 收敛终止参数:maxFixAttempts(needs-redesign 阈值,RC-7)/ convergeNewIssues +
115
+ // convergeRounds(新发现率收敛阈值)
116
+ const maxFixAttempts = normalizeInt($ARGS.maxFixAttempts, "maxFixAttempts", 2, fail);
117
+ const convergeNewIssues = normalizeInt($ARGS.convergeNewIssues, "convergeNewIssues", 1, fail);
118
+ const convergeRounds = normalizeInt($ARGS.convergeRounds, "convergeRounds", 2, fail);
78
119
  const MODEL = typeof $ARGS.model === "string" && $ARGS.model.trim() ? $ARGS.model.trim() : undefined;
79
120
 
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];
121
+ // base 锁定(RC-6,5.6):git-diff 场景 run 启动时锁定 base commit,全程用锁定 hash 构造
122
+ // diff 指令,防止 run 期间 base ref 被更新导致各轮 diff 范围不一致。rev-parse 失败(非 git
123
+ // 目录 / ref 不存在)降级用原 ref 并 warn,锁定结果随 state.meta.baseHash 记录。
124
+ const lockedBase = lockReviewBase(targetType, target);
125
+ if (targetType === "git-diff") {
126
+ if (lockedBase.hash) {
127
+ log("Locked review base: " + target + " -> " + lockedBase.hash);
115
128
  } else {
116
- rawBatches = ["reviewer"]; // 默认单批:包内置通用审查 agent
129
+ log("WARN: git rev-parse " + target + " failed, falling back to ref for diff base: " + target);
117
130
  }
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
131
  }
127
- const BATCHES = parseBatches();
132
+ const reviewInstruction = buildReviewInstruction(targetType, lockedBase.base);
133
+
134
+ // 批次解析:batch1..batchN(缺号报错)/ agents 简写;无默认批次——缺批次参数时
135
+ // parseBatches 直接 fail-fast(与头注释「batch1..batchN/agents 必传」一致)
136
+ const BATCHES = parseBatches($ARGS, fail);
128
137
 
129
138
  // batchNames(数量校验)
130
139
  const rawBatchNames = typeof $ARGS.batchNames === "string" && $ARGS.batchNames.trim()
131
140
  ? $ARGS.batchNames.split(",").map((s) => s.trim()).filter(Boolean)
132
141
  : [];
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));
142
+ const BATCH_NAMES = resolveBatchNames(rawBatchNames, BATCHES, fail);
137
143
 
138
144
  // 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
- }
145
+ validateFallowScan(BATCHES, targetType, fail);
144
146
 
145
147
  // ── Schemas ─────────────────────────────────────────────────────────
146
148
 
@@ -148,8 +150,22 @@ const reviewerSchema = {
148
150
  type: "object",
149
151
  properties: {
150
152
  report_file: { type: "string", description: "Absolute path to the written review report (.md)" },
153
+ report_content: { type: "string", description: "Full markdown report body (for schema-only agents without write tool; workflow writes it to <roundDir>/<report>.md)" },
151
154
  must_fix: { type: "number", description: "Number of must-fix (critical+major) issues found" },
152
155
  suggestion: { type: "number", description: "Number of suggestion-level (minor) issues found" },
156
+ reconciliation: {
157
+ type: "array",
158
+ items: {
159
+ type: "object",
160
+ properties: {
161
+ prev_id: { type: "string", description: "Issue id from the previous round (reconciliation continuation)" },
162
+ status: { type: "string", description: "fixed / not-fixed / regressed / escalate — only fixed counts as resolved; fix result claiming fixed is NOT evidence; escalate = a DEFERRED issue whose context was changed by this round's fix (workflow re-opens it for fixing)" },
163
+ evidence: { type: "string", description: "What was read/confirmed (file + what changed)" },
164
+ },
165
+ required: ["prev_id", "status"],
166
+ },
167
+ description: "R2+ reconciliation table (structured): MANDATORY for R2+ rounds, optional for R1",
168
+ },
153
169
  },
154
170
  required: ["report_file", "must_fix", "suggestion"],
155
171
  };
@@ -160,10 +176,69 @@ const aggregatorSchema = {
160
176
  report_file: { type: "string", description: "Absolute path to aggregated.md" },
161
177
  must_fix: { type: "number", description: "Total must-fix after dedup across all dimensions" },
162
178
  suggestion: { type: "number", description: "Total suggestions after dedup across all dimensions" },
179
+ must_fix_ids: {
180
+ type: "array",
181
+ // M1: 支持 [{id, severity}] 对象(severity: critical/major/minor——converged 终止的
182
+ // 「无 critical」判定数据源)+ 旧格式 string[] 兼容。ajv 权威校验两者皆放行。
183
+ items: {
184
+ oneOf: [
185
+ { type: "string" },
186
+ {
187
+ type: "object",
188
+ required: ["id"],
189
+ properties: {
190
+ id: { type: "string" },
191
+ severity: { type: "string" },
192
+ },
193
+ },
194
+ ],
195
+ },
196
+ description: "Issue ids of the deduplicated must-fix list (MF-1..N), matching the first column of the markdown table",
197
+ },
198
+ fixes_caution: {
199
+ type: "array",
200
+ items: { type: "string" },
201
+ description: "Caution entries for weak-evidence or high-risk claims (passed to fix stage)",
202
+ },
163
203
  },
164
204
  required: ["report_file", "must_fix", "suggestion"],
165
205
  };
166
206
 
207
+ // fix schema(5.3):object[](issue_id/description/self_check/affected_files)+ deferred。
208
+ // 兼容性:normalizeFixResult 对旧格式(fixes string[]、无 deferred)兜底解析。
209
+ const fixSchema = {
210
+ type: "object",
211
+ properties: {
212
+ fixed_count: { type: "number", description: "Number of issues fixed" },
213
+ fixes: {
214
+ type: "array",
215
+ items: {
216
+ type: "object",
217
+ properties: {
218
+ issue_id: { type: "string", description: "Issue identifier from the aggregated report" },
219
+ description: { type: "string", description: "One-line description of the fix" },
220
+ self_check: { type: "string", description: "grep command + hit count + sync action proving the fix is complete" },
221
+ affected_files: { type: "array", items: { type: "string" }, description: "Files touched by this fix + files checked/synced as reference points" },
222
+ },
223
+ // m3: issue_id 是 ES3 交叉校验(must-fix 必须全进 fixes[])的匹配键,必填
224
+ required: ["issue_id"],
225
+ },
226
+ },
227
+ deferred: {
228
+ type: "array",
229
+ items: {
230
+ type: "object",
231
+ properties: {
232
+ issue_id: { type: "string" },
233
+ severity: { type: "string", description: "Must be minor — critical/major must not be deferred" },
234
+ reason: { type: "string", description: "Concrete cost description: which files/mechanisms involved, why high cost, suggested follow-up" },
235
+ },
236
+ },
237
+ },
238
+ },
239
+ required: ["fixed_count"],
240
+ };
241
+
167
242
  // ── Per-run isolation: runId-scoped directories ─────────────────────
168
243
 
169
244
  const fs = require("fs");
@@ -191,6 +266,11 @@ function loadState() {
191
266
  agentStatus: {},
192
267
  fixCount: 0,
193
268
  batches: [],
269
+ fixResults: [],
270
+ issues: undefined,
271
+ knownRemaining: [],
272
+ convergeStreak: 0,
273
+ lastModifiedFiles: [],
194
274
  };
195
275
  }
196
276
  }
@@ -201,112 +281,29 @@ function saveState(state) {
201
281
  fs.renameSync(tmp, STATE_FILE);
202
282
  }
203
283
 
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
- }
284
+ // clean 记录(recordAgentClean/recordAgentDirty 与跨批跳过判定 shouldSkipAgent
285
+ // review-fix-loop-utils.cjs,vitest 单测见 src/__tests__/review-fix-loop-utils.test.ts)
237
286
 
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
- }
287
+ // ── Agent defs(loadAgentMd/resolveAgentDefs 在 review-fix-loop-utils.cjs) ──
252
288
 
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
- }
289
+ // ── Build review calls ──────────────────────────────────────────────
262
290
 
263
- // 自定义 .md agent 加载:frontmattername/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 + ")");
291
+ // 上一轮 fix modifiedFilesgit diff 实测,fix 阶段写入 batchRounds)。
292
+ // recheck 限定 prompt(scoped)的 scope 来源(初版 = modifiedFiles)。
293
+ function lastModifiedFiles() {
294
+ // M4: 批内轮次循环中 state.batches 尚未 push(仅在终止/批结束 push)——scoped 分支
295
+ // (recheckAfterFix=true 的下轮 review)执行时读不到本批数据。fix 阶段把
296
+ // modifiedFiles 同步写入 state.lastModifiedFiles(即时字段,批内立即可用),
297
+ // 此处优先读它;fallback 旧路径(跨批场景)。
298
+ if (state.lastModifiedFiles && Array.isArray(state.lastModifiedFiles)) {
299
+ return state.lastModifiedFiles;
270
300
  }
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
- });
301
+ const b = state.batches[state.batches.length - 1];
302
+ const r = b && b.rounds && b.rounds[b.rounds.length - 1];
303
+ return (r && Array.isArray(r.modifiedFiles)) ? r.modifiedFiles : [];
305
304
  }
306
305
 
307
- // ── Build review calls ──────────────────────────────────────────────
308
-
309
- function buildReviewCall(def, round, max, batchIndex, roundDir) {
306
+ function buildReviewCall(def, round, max, batchIndex, roundDir, scoped) {
310
307
  const header = "Batch " + batchIndex + " Round " + round + "/" + max + " — " + BATCH_NAMES[batchIndex - 1];
311
308
  const prevBatchesHint = batchIndex > 1
312
309
  ? "\nPrior batch reports (optional context): " + RUN_ROOT + "/batch-*/ (use read)"
@@ -316,6 +313,10 @@ function buildReviewCall(def, round, max, batchIndex, roundDir) {
316
313
  schema: reviewerSchema,
317
314
  description: def.name,
318
315
  timeoutMs: 1_800_000,
316
+ // returnMeta: true — 与 recursive-split 脚本的 executeActionAgent 对齐:失败时 resolve
317
+ // {value, error},raw.error 可检测(review- 前缀兜底/结构化终止可达);成功时
318
+ // value = parsedOutput ?? content,parseResult 作用于 raw.value(MF-1)。
319
+ returnMeta: true,
319
320
  };
320
321
 
321
322
  if (def.isFallow) {
@@ -329,7 +330,7 @@ function buildReviewCall(def, round, max, batchIndex, roundDir) {
329
330
  "Steps:",
330
331
  "1. Check if fallow is installed: `which fallow`",
331
332
  "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
+ "3. If installed, run: `fallow audit --base " + lockedBase.base + " --format json --quiet`",
333
334
  "4. Extract: complexity hotspots, dead code, unused exports, circular deps",
334
335
  "5. Classify findings: critical/major count into must_fix; minor into suggestion.",
335
336
  "",
@@ -339,6 +340,50 @@ function buildReviewCall(def, round, max, batchIndex, roundDir) {
339
340
  };
340
341
  }
341
342
 
343
+ // R2+ 三段式分支(5.2):round>1 且非 scoped → verify-first 对账 + known-remaining + 收敛 hunt。
344
+ // scoped 分支(recheck 限定)在下方单独处理(含对账段)。
345
+ if (round > 1 && !scoped) {
346
+ const prevRoundDir = RUN_ROOT + "/batch-" + batchIndex + "/round-" + (round - 1);
347
+ const r2Spec = def.isCustom
348
+ ? "\n\nReviewer specification (from agent file):\n" + def.systemPrompt
349
+ : "";
350
+ return {
351
+ ...base,
352
+ schema: { ...reviewerSchema, required: [...reviewerSchema.required, "reconciliation"] },
353
+ prompt: buildR2ReviewPrompt({
354
+ header, round, max, roundDir,
355
+ reportFile: def.report,
356
+ aggPath: prevRoundDir + "/aggregated.md",
357
+ fixResult: state.fixResults && state.fixResults.length
358
+ ? state.fixResults[state.fixResults.length - 1]
359
+ : null,
360
+ knownRemaining: (state.knownRemaining && Array.isArray(state.knownRemaining)) ? state.knownRemaining : [],
361
+ }) + r2Spec,
362
+ agent: def.isCustom ? undefined : def.name,
363
+ };
364
+ }
365
+
366
+ // recheck 限定分支(5.5):clean agent 重派时只审 fix 改动文件 + 自检关联点
367
+ // (scope = modifiedFiles ∪ affected_files,后者来自 state.fixImpactFiles),不诱导全量重扫。
368
+ if (scoped) {
369
+ return {
370
+ ...base,
371
+ // m2: scoped 分支与 R2+ 分支一致——reconciliation 必填(recheck 也须对账前轮 fix)
372
+ schema: { ...reviewerSchema, required: [...reviewerSchema.required, "reconciliation"] },
373
+ prompt: buildScopedRecheckPrompt({
374
+ header, round, max, roundDir,
375
+ reportFile: def.report,
376
+ modifiedFiles: lastModifiedFiles(),
377
+ affectedFiles: (state.fixImpactFiles && Array.isArray(state.fixImpactFiles)) ? state.fixImpactFiles : [],
378
+ aggPath: RUN_ROOT + "/batch-" + batchIndex + "/round-" + (round - 1) + "/aggregated.md",
379
+ fixResult: state.fixResults && state.fixResults.length
380
+ ? state.fixResults[state.fixResults.length - 1]
381
+ : null,
382
+ }) + (def.isCustom ? "\n\nReviewer specification (from agent file):\n" + def.systemPrompt : ""),
383
+ agent: def.isCustom ? undefined : def.name,
384
+ };
385
+ }
386
+
342
387
  const spec = def.isCustom
343
388
  ? "\n\nReviewer specification (from agent file):\n" + def.systemPrompt
344
389
  : "";
@@ -359,11 +404,11 @@ function buildReviewCall(def, round, max, batchIndex, roundDir) {
359
404
  };
360
405
  }
361
406
 
362
- // agent 名解析失败(AgentRegistry not found)时,尝试 review- 前缀兜底
407
+ // agent 名解析失败(AgentRegistry not found,报错文案 `Agent "${name}" not found.`)时,尝试 review- 前缀兜底
363
408
  async function runReviewAgent(call) {
364
409
  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-")) {
410
+ if (raw && typeof raw === "object" && raw.error
411
+ && shouldRetryWithReviewPrefix(raw.error, call.agent)) {
367
412
  log("Agent not found: " + call.agent + " — retrying with review- prefix");
368
413
  raw = await agent({ ...call, agent: "review-" + call.agent });
369
414
  }
@@ -373,6 +418,9 @@ async function runReviewAgent(call) {
373
418
  // ── Main loop: batches (serial) × rounds (per-batch) ────────────────
374
419
 
375
420
  const state = loadState();
421
+ // 5.6 锁定结果落 state.meta.baseHash(commit 1 声称与实现一致化,run 后可从 state.json 追溯审查基线)
422
+ state.meta = state.meta || {};
423
+ state.meta.baseHash = lockedBase.hash || "";
376
424
  let totalFixed = 0;
377
425
  let terminated = "clean";
378
426
  let finalMessage = "";
@@ -381,17 +429,24 @@ for (let batchIndex = 1; batchIndex <= BATCHES.length; batchIndex++) {
381
429
  const defs = resolveAgentDefs(BATCHES[batchIndex - 1]);
382
430
  const cleanNames = new Set();
383
431
  let round = 0;
384
- let prevTotal = -1;
432
+ let prevMustFix = -1;
385
433
  let stuckCount = 0;
386
434
  let batchClean = false;
387
435
  let roundHasFix = false; // recheckAfterFix 用:上轮是否有 fix
388
436
  const batchRounds = [];
389
437
 
438
+ // MF-1 批级 review 状态隔离:每批开始重置 issue 追踪 / 收敛计数 / known-remaining,
439
+ // 防止跨批 newFindings 语义污染(firstSeen 用批内 round 号写入、convergeStreak 跨批
440
+ // 累加会让前批收敛状态泄漏到后批,复合导致 converged 错误跳过后续批次)。
441
+ state.issues = undefined;
442
+ state.convergeStreak = 0;
443
+ state.knownRemaining = [];
444
+
390
445
  // 跨批跳过:agent 在更早批 clean 且此后无 fix → 本批不派发
391
446
  if (batchIndex > 1) {
392
447
  for (const def of defs) {
393
448
  const s = state.agentStatus[def.name];
394
- if (s && s.lastCleanBatch && s.lastCleanBatch < batchIndex && s.lastCleanFixCount === state.fixCount) {
449
+ if (shouldSkipAgent(s, state.fixCount, batchIndex)) {
395
450
  cleanNames.add(def.name);
396
451
  log("Cross-batch skip: " + def.name + " (clean in batch " + s.lastCleanBatch + ", no fix since)");
397
452
  }
@@ -407,8 +462,10 @@ for (let batchIndex = 1; batchIndex <= BATCHES.length; batchIndex++) {
407
462
  fs.mkdirSync(roundDir, { recursive: true });
408
463
 
409
464
  let active = defs.filter((def) => !(skipCleanAgents && cleanNames.has(def.name)));
465
+ let scopedClean = new Set(); // recheckAfterFix 重派时:上一轮 clean 的 agent 本轮走限定 prompt
410
466
  if (recheckAfterFix && round > 1 && roundHasFix) {
411
- active = defs; // fix 后重派全批(回归防护)
467
+ scopedClean = new Set(cleanNames); // 重派前快照上一轮 clean 集合
468
+ active = defs; // fix 后重派全批(强回归模式)
412
469
  cleanNames.clear();
413
470
  }
414
471
 
@@ -419,21 +476,47 @@ for (let batchIndex = 1; batchIndex <= BATCHES.length; batchIndex++) {
419
476
  }
420
477
 
421
478
  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);
479
+ const calls = active.map((def) => buildReviewCall(def, round, maxRounds, batchIndex, roundDir, scopedClean.has(def.name)));
480
+ const allRaw = await parallel(calls.map(runReviewAgent));
424
481
 
425
482
  // per-agent 结果区分:parallel 结果与 calls 一一对应
426
483
  const reviewResults = [];
427
484
  const agentRoundResults = [];
485
+ const reconSeen = new Set(); // R2+ reconciliation 声明的上轮 ID(status !== fixed)——stuck ID 驱动数据源(5.1)
486
+ const reconEscalate = new Set(); // 5.1-5 escalate 声明:deferred 条目上下文改变 → 重新 open
487
+ const reconAll = new Set(); // M2: 所有 status 条目(含 fixed)的 prev_id 去重——reconcile 门控数据源
428
488
  for (let i = 0; i < allRaw.length; i++) {
429
489
  const raw = allRaw[i];
430
490
  if (raw && typeof raw === "object" && raw.error) {
431
- // fail-fast:审查 agent 调用失败(含 AgentRegistry not found)不得静默跳过
432
- fail("审查 agent 调用失败: " + active[i].name + " — " + raw.error);
491
+ // 审查 agent 调用失败(含 AgentRegistry not found / 超时)。不裸 throw——与
492
+ // aggregator-failure/stuck/fix-failure 路径一致:saveState + terminated 结构化终止,
493
+ // 保证 state.json 有记录、调用方拿到结构化结果而非裸异常(MF-3)。
494
+ state.batches.push({ index: batchIndex, name: BATCH_NAMES[batchIndex - 1], rounds: batchRounds });
495
+ saveState(state);
496
+ terminated = "review-failure";
497
+ finalMessage = "Batch " + batchIndex + " round " + round + ": 审查 agent 调用失败 " + active[i].name + " — " + raw.error;
498
+ batchIndex = BATCHES.length + 1; // 终止外层循环
499
+ break;
433
500
  }
434
- const parsed = parseResult(raw);
435
- if (parsed && typeof parsed.must_fix === "number") {
501
+ const parsed = normalizeReviewResult(raw.value);
502
+ if (parsed) {
503
+ // 5.8 通用落盘:schema-only agent(report_content 无 report_file,如 doc-reviewer)→
504
+ // workflow 写盘到 <roundDir>/<def.report>.md 并填入 report_file(aggregator 读取路径不变)。
505
+ const reportPath = resolveReviewReportPath(parsed, roundDir, active[i].report);
506
+ if (reportPath && !(parsed.report_file && parsed.report_file.trim())) {
507
+ try {
508
+ fs.writeFileSync(reportPath, parsed.report_content || "", "utf-8");
509
+ parsed.report_file = reportPath;
510
+ } catch (e) {
511
+ log("WARN: failed to write report_content to " + reportPath + " — " + e.message);
512
+ }
513
+ }
436
514
  reviewResults.push(parsed);
515
+ for (const r of parsed.reconciliation) {
516
+ if (r.status === "escalate") reconEscalate.add(r.prev_id);
517
+ else if (r.status !== "fixed") reconSeen.add(r.prev_id);
518
+ reconAll.add(r.prev_id); // M2: 含 fixed——全 fixed 时 reconSeen 空但 reconcile 仍需执行
519
+ }
437
520
  const def = active[i];
438
521
  if (parsed.must_fix === 0) {
439
522
  recordAgentClean(state, def.name, batchIndex);
@@ -444,11 +527,18 @@ for (let batchIndex = 1; batchIndex <= BATCHES.length; batchIndex++) {
444
527
  agentRoundResults.push({ name: def.name, must_fix: parsed.must_fix, suggestion: parsed.suggestion ?? 0, clean: parsed.must_fix === 0 });
445
528
  } else {
446
529
  // 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));
530
+ // 结果缺 must_fix。结构化终止(MF-3),raw 完整 dump 便于定位。
531
+ state.batches.push({ index: batchIndex, name: BATCH_NAMES[batchIndex - 1], rounds: batchRounds });
532
+ saveState(state);
533
+ terminated = "review-failure";
534
+ finalMessage = "Batch " + batchIndex + " round " + round + ": 审查 agent 结果无效(缺 must_fix) " + active[i].name + " raw=" + JSON.stringify(raw.value).slice(0, 400);
535
+ batchIndex = BATCHES.length + 1; // 终止外层循环
536
+ break;
449
537
  }
450
538
  }
451
539
 
540
+ if (terminated === "review-failure") break; // 已结构化终止,退出 round 循环(MF-3)
541
+
452
542
  if (reviewResults.every((r) => r.must_fix === 0)) {
453
543
  log("Batch " + batchIndex + " round " + round + ": all agents clean.");
454
544
  batchRounds.push({ round, mustFix: 0, suggestion: reviewResults.reduce((a, r) => a + (r.suggestion ?? 0), 0), agents: agentRoundResults, modifiedFiles: [] });
@@ -459,63 +549,25 @@ for (let batchIndex = 1; batchIndex <= BATCHES.length; batchIndex++) {
459
549
 
460
550
  // ── Aggregate(内置 prompt,不依赖任何 agent.md) ─────────
461
551
  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"),
552
+ prompt: buildAggregatorPrompt({
553
+ header: "Batch " + batchIndex + "/" + BATCHES.length + " Round " + round + "/" + maxRounds + " — AGGREGATE REVIEWS",
554
+ round, max: maxRounds, roundDir,
555
+ reviewResults,
556
+ }),
508
557
  model: MODEL,
509
558
  schema: aggregatorSchema,
510
559
  description: "aggregate",
511
560
  timeoutMs: 1_800_000,
561
+ returnMeta: true,
512
562
  });
513
563
 
514
- let agg = normalizeAggregatorResult(aggRaw);
564
+ // returnMeta 下 aggRaw = {value, error};失败时 value 为空串、error 在 finalMessage 透出(MF-1)
565
+ const aggValue = aggRaw?.value ?? aggRaw;
566
+ let agg = normalizeAggregatorResult(aggValue);
515
567
 
516
568
  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);
569
+ const rawPreview = (aggValue === undefined ? "undefined" : typeof aggValue === "string" ? aggValue : JSON.stringify(aggValue)).slice(0, 200);
570
+ log("Aggregator JSON invalid (len=" + (typeof aggValue === "string" ? aggValue.length : 0) + "): " + rawPreview);
519
571
  const fallbackPath = (agg && agg.report_file) || (roundDir + "/aggregated.md");
520
572
  try {
521
573
  const content = fs.readFileSync(fallbackPath, "utf-8");
@@ -531,7 +583,8 @@ for (let batchIndex = 1; batchIndex <= BATCHES.length; batchIndex++) {
531
583
  state.batches.push({ index: batchIndex, name: BATCH_NAMES[batchIndex - 1], rounds: batchRounds });
532
584
  saveState(state);
533
585
  terminated = "aggregator-failure";
534
- finalMessage = "Batch " + batchIndex + " round " + round + ": aggregator 失败且 fallback 解析失败";
586
+ finalMessage = "Batch " + batchIndex + " round " + round + ": aggregator 失败且 fallback 解析失败"
587
+ + (aggRaw && typeof aggRaw === "object" && aggRaw.error ? " — " + aggRaw.error : "");
535
588
  batchIndex = BATCHES.length + 1; // 终止外层循环
536
589
  break;
537
590
  }
@@ -541,24 +594,159 @@ for (let batchIndex = 1; batchIndex <= BATCHES.length; batchIndex++) {
541
594
  const suggestion = agg.suggestion ?? 0;
542
595
  log("Aggregated: " + mustFix + " must-fix + " + suggestion + " suggestion(s).");
543
596
 
597
+ // 5.1:R1 的 aggregator must_fix_ids 初始化 state.issues(数字 ID 起点)
598
+ if (round === 1 && agg.must_fix_ids && agg.must_fix_ids.length > 0) {
599
+ if (!state.issues) state.issues = {};
600
+ for (const entry of agg.must_fix_ids) {
601
+ const id = typeof entry === "string" ? entry : entry && entry.id;
602
+ if (!id || state.issues[id]) continue;
603
+ state.issues[id] = {
604
+ // severity 结构化(5.7):aggregator 标注 critical/major/minor,converged 终止的
605
+ // 「无 critical」判定依赖它;旧格式(string)默认 major(must-fix 语义)。
606
+ firstSeen: 1,
607
+ severity: typeof entry === "string" ? "major" : (entry.severity || "major"),
608
+ status: "open",
609
+ history: [{ round: 1, status: "open" }], fixAttempts: 0,
610
+ };
611
+ }
612
+ }
613
+
544
614
  // ── 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.");
615
+ // 5.1 ID 驱动:R2+ 用 reconciliation 声明的 seenIds + reconcileIssues(同一 ID 连续 N 轮
616
+ // open/regressed);无 reconciliation 数据(R1 reviewer 未填)降级计数式 updateStuckState
617
+ // (现状语义,MF-2 注释保留)。needs-redesign(fixAttempts>=2)在 wave 5 接入。
618
+ // M2: reconCount = 所有 status 条目的 prev_id 去重计数(含 fixed)——全 fixed(最常见
619
+ // 收敛路径)时 reconSeen 为空,但 reconciliation 有数据仍须调 reconcileIssues:
620
+ // fix-attempted → fixed 的唯一转换点(否则 fix-attempted 永不转 fixed)
621
+ const reconCount = reconAll.size;
622
+ // F1: 无对账数据(doc-reviewer-only 批 reconciliation 恒空)时,fix-attempted 条目
623
+ // 存在仍须执行 reconcile——空 seenIds 语义 = 未被重新报告 = 已修复(5.1「未出现→fixed」)
624
+ const hasFixAttempted = state.issues
625
+ ? Object.values(state.issues).some((i) => i.status === "fix-attempted")
626
+ : false;
627
+
628
+ // 5.1-2 R2+ 新发现 ID 契约(M2 移出 reconcile 分支,独立执行;F1 扩展为
629
+ // 「重新报告 = 未修复」转换):aggregator 的 must_fix_ids 中
630
+ // a) 不在 issues → 创建为新条目(firstSeen=round,severity 从 aggregator 标注)
631
+ // b) 已存在且(fix-attempted 或 fixed)且本轮无对账数据(reconCount===0,
632
+ // doc-reviewer 场景)→ 重新报告 = 修复失败:转 regressed + fixAttempts+1 + openStreak+1
633
+ // (RC-7 needs-redesign 出口在无对账配置下可达;reconciliation 场景由
634
+ // reconcileIssues 处理,避免双计)。fixed 重报分支(MF-2):已确认修复的问题
635
+ // 再次被报告同样转 regressed——否则 fixed 停留 + 收敛终止组合会在默认配置下
636
+ // R3 即以 converged 提前终止而 must-fix 仍活跃。
637
+ // newFindings 统计与 needs-redesign/fixAttempts 追踪对 R2+ 新发现生效。
638
+ if (round > 1 && state.issues && agg.must_fix_ids && agg.must_fix_ids.length > 0) {
639
+ let added = 0;
640
+ for (const entry of agg.must_fix_ids) {
641
+ const id = typeof entry === "string" ? entry : entry && entry.id;
642
+ if (!id) continue;
643
+ if (state.issues[id]) {
644
+ if (reconCount === 0 && (state.issues[id].status === "fix-attempted" || state.issues[id].status === "fixed")) {
645
+ state.issues[id].status = "regressed";
646
+ state.issues[id].fixAttempts = (state.issues[id].fixAttempts || 0) + 1;
647
+ state.issues[id].openStreak = (state.issues[id].openStreak || 0) + 1;
648
+ state.issues[id].severity = typeof entry === "string" ? "major" : (entry.severity || "major");
649
+ state.issues[id].history.push({ round, status: "regressed" });
650
+ added++;
651
+ }
652
+ continue;
653
+ }
654
+ state.issues[id] = {
655
+ firstSeen: round,
656
+ severity: typeof entry === "string" ? "major" : (entry.severity || "major"),
657
+ status: "open", openStreak: 1,
658
+ history: [{ round, status: "open" }], fixAttempts: 0,
659
+ };
660
+ added++;
661
+ }
662
+ if (added > 0) log("New findings tracked: " + added + " new or re-reported issue(s) in round " + round);
663
+ }
664
+
665
+ let stuck = { stuck: false };
666
+ if (round > 1 && (reconCount > 0 || hasFixAttempted)) {
667
+ const rec = reconcileIssues(state.issues || {}, { seenIds: reconSeen, escalateIds: reconEscalate, round, stuckThreshold });
668
+ state.issues = rec.issues;
669
+ state.knownRemaining = rec.knownRemaining;
670
+ stuck = { stuck: rec.stuck, stuckIds: rec.stuckIds };
671
+ log("Reconcile: " + Object.keys(rec.issues).length + " tracked issue(s), known-remaining: " + rec.knownRemaining.length);
672
+ } else {
673
+ const s = updateStuckState(prevMustFix, stuckCount, mustFix, stuckThreshold);
674
+ stuckCount = s.stuckCount;
675
+ prevMustFix = s.prevMustFix;
676
+ stuck = { stuck: s.stuck };
677
+ }
678
+ if (stuck.stuck) {
679
+ const stuckIds = (stuck.stuckIds || []).join(", ");
680
+ log("Stuck: issue(s) not converging for " + stuckThreshold + " rounds: " + stuckIds + ". Stopping.");
681
+ batchRounds.push({ round, mustFix, suggestion, agents: agentRoundResults, modifiedFiles: [] });
682
+ state.batches.push({ index: batchIndex, name: BATCH_NAMES[batchIndex - 1], rounds: batchRounds });
683
+ saveState(state);
684
+ terminated = "stuck";
685
+ finalMessage = "Batch " + batchIndex + " round " + round + ": 问题 " + stuckIds + " 连续 " + stuckThreshold + " 轮未收敛。残留: "
686
+ + (state.knownRemaining && state.knownRemaining.length ? state.knownRemaining.join("; ") : "无 deferred");
687
+ batchIndex = BATCHES.length + 1;
688
+ break;
689
+ }
690
+
691
+ // 5.7 needs-redesign(RC-7):fixAttempts >= maxFixAttempts 且 regressed → 终止。
692
+ // 顺序在 stuck 之后:stuck(一直在)信息更宏观,needs-redesign(修不好)更具体,先 stuck 后 redesign。
693
+ if (round > 1 && state.issues) {
694
+ const redesign = findNeedsRedesign(state.issues, maxFixAttempts);
695
+ if (redesign.length > 0) {
696
+ const ids = redesign.map((r) => r.issue_id).join(", ");
697
+ // 5.7 message 三要素:ID + 修复历史摘要 + 残留清单(history 全文随 state.json 落盘)
698
+ const historySummary = redesign.map((r) => {
699
+ const hist = (r.history || []).map((h) => "R" + h.round + ":" + h.status).join(" -> ");
700
+ return r.issue_id + " [" + (hist || "no history") + "]";
701
+ }).join("; ");
702
+ log("Needs redesign: " + ids + " not converging after " + maxFixAttempts + " fix attempts. Stopping.");
550
703
  batchRounds.push({ round, mustFix, suggestion, agents: agentRoundResults, modifiedFiles: [] });
551
704
  state.batches.push({ index: batchIndex, name: BATCH_NAMES[batchIndex - 1], rounds: batchRounds });
552
705
  saveState(state);
553
- terminated = "stuck";
554
- finalMessage = "Batch " + batchIndex + " round " + round + ": 总问题数连续 " + stuckThreshold + " 轮不下降";
706
+ terminated = "needs-redesign";
707
+ finalMessage = "Batch " + batchIndex + " round " + round + ": 问题 " + historySummary + " " + maxFixAttempts
708
+ + " 次修复仍未收敛,属于需要重新设计而非继续补丁的结构性问题,请人工介入。残留: "
709
+ + (state.knownRemaining && state.knownRemaining.length ? state.knownRemaining.join("; ") : "无 deferred");
555
710
  batchIndex = BATCHES.length + 1;
556
711
  break;
557
712
  }
558
- } else {
559
- stuckCount = 0;
713
+
714
+ // 5.7 新发现率收敛:连续 convergeRounds 轮新发现 <= convergeNewIssues → converged
715
+ const newIssues = Object.values(state.issues).filter((i) => i.firstSeen === round);
716
+ const newFindings = newIssues.length;
717
+ const newFindingsCritical = newIssues.filter((i) => i.severity === "critical").length;
718
+ const conv = checkConvergence({
719
+ prevStreak: state.convergeStreak || 0, newFindings, newFindingsCritical,
720
+ convergeNewIssues, convergeRounds,
721
+ });
722
+ state.convergeStreak = conv.streak;
723
+ // MF-2/S-21 收敛门槛:新发现率收敛 ≠ 问题已解决——必须同时满足「无 open/regressed
724
+ // 活跃条目」才允许 converged 终止。fixed 条目复发(reconcile/merge 已转 regressed)
725
+ // 后活跃条目存在 → 不收敛,继续修复循环(默认配置下 R3 复发不再提前终止)。
726
+ // issues 无追踪(aggregator 缺 must_fix_ids)时回退 mustFix===0 数字级判定,
727
+ // 避免 must_fix>0 照常收敛掩盖未处理问题(S-21)。
728
+ const trackedCount = Object.keys(state.issues || {}).length;
729
+ const activeIssues = Object.values(state.issues || {})
730
+ .filter((i) => i.status === "open" || i.status === "regressed");
731
+ const noActiveIssues = trackedCount === 0 ? mustFix === 0 : activeIssues.length === 0;
732
+ if (conv.converged && noActiveIssues) {
733
+ // MF-2 ④:converged 消息列出 open issue ID(对齐 max-rounds 的 remainingIds 逻辑)。
734
+ // 门槛保证正常路径此处为空;状态漂移时调用方仍能看到残留而非「无 deferred」误报。
735
+ const remainingIds = Object.entries(state.issues || {})
736
+ .filter(([, i]) => i.status !== "fixed" && i.status !== "deferred")
737
+ .map(([id]) => id);
738
+ log("Converged: new findings <= " + convergeNewIssues + " for " + convergeRounds + " rounds. Batch " + batchIndex + " done, proceeding to next batch.");
739
+ batchRounds.push({ round, mustFix, suggestion, agents: agentRoundResults, modifiedFiles: [] });
740
+ saveState(state);
741
+ terminated = "converged";
742
+ finalMessage = "Batch " + batchIndex + " round " + round + ": 新发现率收敛(连续 " + convergeRounds
743
+ + " 轮新问题 ≤" + convergeNewIssues + ")。残留: "
744
+ + (remainingIds.length ? remainingIds.join(", ") : "无")
745
+ + (state.knownRemaining && state.knownRemaining.length ? ";deferred: " + state.knownRemaining.join("; ") : "");
746
+ batchClean = true; // MF-1: 与 clean 一致,让外层 for 推进下一批(不跳过后续批次)
747
+ break;
748
+ }
560
749
  }
561
- prevTotal = total;
562
750
 
563
751
  // ── Fix ─────────────────────────────────────────────────
564
752
  phase("Fix");
@@ -585,35 +773,44 @@ for (let batchIndex = 1; batchIndex <= BATCHES.length; batchIndex++) {
585
773
  : "- Do NOT commit. Leave the fixes in the working tree (autoCommit=false).";
586
774
 
587
775
  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",
776
+ prompt: buildFixPrompt({
777
+ header: "Fix round " + round + " (batch " + batchIndex + ")",
592
778
  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,
779
+ fixPrompt: FIX_DEF && FIX_DEF.isCustom
780
+ ? fixPrompt + "\n\nFixer specification (from agent file):\n" + FIX_DEF.systemPrompt
781
+ : fixPrompt,
599
782
  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",
783
+ caution: agg.fixes_caution && agg.fixes_caution.length ? agg.fixes_caution : [],
784
+ }),
785
+ schema: fixSchema,
786
+ // 与 buildReviewCall 的 model: MODEL || def.model 对齐:custom fixer.md 的
787
+ // frontmatter model 字段同样生效(之前丢弃了 FIX_DEF.model,只在 review 阶段消费)
788
+ model: MODEL || (FIX_DEF && FIX_DEF.model),
789
+ description: (FIX_DEF && FIX_DEF.name) || "fix",
790
+ // info #15: 显式 timeoutMs review/aggregator 档位一致(fix 是写操作中最长阶段,
791
+ // 不依赖引擎默认值——引擎默认值变化不会悄然缩短 fix 预算)
792
+ timeoutMs: 1_800_000,
793
+ returnMeta: true,
794
+ ...(FIX_DEF && !FIX_DEF.isCustom ? { agent: FIX_DEF.name } : {}),
613
795
  });
614
796
 
615
- const fx = parseResult(fxRaw);
616
- if (!fx) {
797
+ // returnMeta 下 fxRaw = {value, error}:先查 error(失败分支可达,MF-1),再对 value 做 parseResult
798
+ if (fxRaw && typeof fxRaw === "object" && fxRaw.error) {
799
+ // fix agent 调用失败(AgentRegistry not found / 超时等)。
800
+ // 与 review 路径(raw.error → review-failure)对齐:结构化终止而非静默当成功——
801
+ // 否则 fixed_count 缺失被 `?? mustFix` 回退,totalFixed 虚增且 must_fix 不降白跑轮次(MF-1)。
802
+ log("Fix agent failed, stopping.");
803
+ batchRounds.push({ round, mustFix, suggestion, agents: agentRoundResults, modifiedFiles: [] });
804
+ state.batches.push({ index: batchIndex, name: BATCH_NAMES[batchIndex - 1], rounds: batchRounds });
805
+ saveState(state);
806
+ terminated = "fix-failure";
807
+ finalMessage = "Batch " + batchIndex + " round " + round + ": fix agent 调用失败 — " + fxRaw.error;
808
+ batchIndex = BATCHES.length + 1;
809
+ break;
810
+ }
811
+ const fx = parseResult(fxRaw.value);
812
+ const fixResult = fx ? normalizeFixResult(fx) : null;
813
+ if (!fixResult) {
617
814
  log("Fix agent failed, stopping.");
618
815
  batchRounds.push({ round, mustFix, suggestion, agents: agentRoundResults, modifiedFiles: [] });
619
816
  state.batches.push({ index: batchIndex, name: BATCH_NAMES[batchIndex - 1], rounds: batchRounds });
@@ -624,7 +821,83 @@ for (let batchIndex = 1; batchIndex <= BATCHES.length; batchIndex++) {
624
821
  break;
625
822
  }
626
823
 
627
- const fixedCount = fx.fixed_count ?? mustFix;
824
+ // ES3 硬校验(5.3 红线,恢复 mustFixIds 交叉校验——wave 3 后 agg.must_fix_ids
825
+ // 已是标准字段):(1) deferred 只允许 minor;(2) must-fix 必须全进 fixes[](漏修
826
+ // 判 violation)。任一违规 → fix-failure(结构化终止)
827
+ const es3Violations = validateFixResult(fixResult, agg.must_fix_ids);
828
+ if (es3Violations.length > 0) {
829
+ // m7: violation 分两类——deferred 非 minor / must-fix 漏修(must-fix-not-fixed),
830
+ // finalMessage 文案区分:统一文案会把漏修误报成 defer 违规,误导修复方向
831
+ const parts = es3Violations.map((v) =>
832
+ v.severity === "must-fix-not-fixed"
833
+ ? "must-fix 未在 fixes[] 中修复(漏修)— " + v.issue_id
834
+ : "deferred 含非 minor 条目(must-fix 不得 defer)— " + v.issue_id + "(" + v.severity + ")"
835
+ );
836
+ log("ES3 violation: " + JSON.stringify(es3Violations));
837
+ batchRounds.push({ round, mustFix, suggestion, agents: agentRoundResults, modifiedFiles: [] });
838
+ state.batches.push({ index: batchIndex, name: BATCH_NAMES[batchIndex - 1], rounds: batchRounds });
839
+ saveState(state);
840
+ terminated = "fix-failure";
841
+ finalMessage = "Batch " + batchIndex + " round " + round + ": " + parts.join("; ");
842
+ batchIndex = BATCHES.length + 1;
843
+ break;
844
+ }
845
+
846
+ // ES2 软校验(5.3 证据标准):defer 理由过短/无实质 → warning 日志(不终止)
847
+ for (const d of fixResult.deferred) {
848
+ const reason = typeof d.reason === "string" ? d.reason : "";
849
+ if (reason.trim().length < 20) {
850
+ log("WARN: deferred reason too short / no concrete cost description: " + JSON.stringify(d));
851
+ }
852
+ }
853
+
854
+ // affected_files 并入 state.fixImpactFiles(5.3/5.5):recheck scope = modifiedFiles ∪ fixImpactFiles
855
+ const impactFiles = [];
856
+ for (const f of fixResult.fixes) {
857
+ if (Array.isArray(f.affected_files)) {
858
+ for (const af of f.affected_files) {
859
+ if (typeof af === "string" && af.trim() && !impactFiles.includes(af.trim())) impactFiles.push(af.trim());
860
+ }
861
+ }
862
+ }
863
+ state.fixImpactFiles = impactFiles;
864
+
865
+ // m4: 先初始化容器——aggregator JSON 无效走 parseAggregatedMd 回退时 agg 无
866
+ // must_fix_ids → R1 初始化跳过 → state.issues undefined;此处初始化保证 deferred
867
+ // 写入与 knownRemaining 同步链路生效(否则 knownRemaining 恒空,deferred 跨轮继承整链失效)
868
+ if (!state.issues) state.issues = {};
869
+ // 5.1:fix 结果标记 fix-attempted(ID 对账驱动)+ fixResults 落库(R2+ prompt 输入)
870
+ for (const f of fixResult.fixes) {
871
+ if (f && typeof f.issue_id === "string" && state.issues[f.issue_id]) {
872
+ state.issues[f.issue_id].status = "fix-attempted";
873
+ state.issues[f.issue_id].history.push({ round, status: "fix-attempted" });
874
+ }
875
+ }
876
+ // 5.3-4 deferred 写入 state.issues(known-remaining 跨轮继承链路):deferred 条目
877
+ // 以 status=deferred 入 issues,据此生成 knownRemaining 传给 R2+ prompt。
878
+ // 若 ID 已存在(曾被修复/降级)→ 更新状态 + reason;不存在(S-x minor)→ 新建。
879
+ for (const d of fixResult.deferred) {
880
+ if (!d || typeof d.issue_id !== "string" || !d.issue_id) continue;
881
+ const reason = typeof d.reason === "string" ? d.reason : "";
882
+ if (state.issues[d.issue_id]) {
883
+ state.issues[d.issue_id].status = "deferred";
884
+ state.issues[d.issue_id].deferredReason = reason;
885
+ state.issues[d.issue_id].history.push({ round, status: "deferred" });
886
+ } else {
887
+ state.issues[d.issue_id] = {
888
+ firstSeen: round, severity: "minor", status: "deferred",
889
+ deferredReason: reason,
890
+ history: [{ round, status: "deferred" }], fixAttempts: 0,
891
+ };
892
+ }
893
+ }
894
+ // known-remaining 同步更新:deferred 在本轮 fix 后即生效,R2+ prompt 立即消费
895
+ // (不依赖下轮 reconcile 才生成——否则滞后一轮,reviewer 本轮看不到 deferred 清单)
896
+ state.knownRemaining = computeKnownRemaining(state.issues);
897
+ if (!state.fixResults) state.fixResults = [];
898
+ state.fixResults.push(fixResult);
899
+
900
+ const fixedCount = fixResult.fixed_count ?? mustFix;
628
901
  totalFixed += fixedCount;
629
902
  state.fixCount++;
630
903
  roundHasFix = true;
@@ -639,6 +912,9 @@ for (let batchIndex = 1; batchIndex <= BATCHES.length; batchIndex++) {
639
912
  } catch { /* empty */ }
640
913
  }
641
914
  batchRounds.push({ round, mustFix, suggestion, agents: agentRoundResults, modifiedFiles });
915
+ // M4: 批内即时字段——下轮 scoped 分支(recheckAfterFix=true)从
916
+ // state.lastModifiedFiles 读本批 fix 的真实改动文件(git 实测兜底)
917
+ state.lastModifiedFiles = modifiedFiles;
642
918
  saveState(state);
643
919
 
644
920
  log("Fixed " + fixedCount + " issue(s). Total: " + totalFixed + ". Modified " + modifiedFiles.length + " file(s). Continuing...");
@@ -648,10 +924,19 @@ for (let batchIndex = 1; batchIndex <= BATCHES.length; batchIndex++) {
648
924
 
649
925
  state.batches.push({ index: batchIndex, name: BATCH_NAMES[batchIndex - 1], rounds: batchRounds });
650
926
 
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";
927
+ terminated = resolveBatchTerminated(batchClean, terminated);
928
+ if (terminated === "max-rounds") {
929
+ // 该批达到 maxRounds 仍残留 must-fix → fail-fast,不进入后续批。
930
+ // 5.9 残留清单:未 fixed issues(status != fixed/deferred)+ known-remaining。
931
+ const remainingIds = state.issues
932
+ ? Object.entries(state.issues)
933
+ .filter(([, i]) => i.status !== "fixed" && i.status !== "deferred")
934
+ .map(([id]) => id)
935
+ : [];
936
+ finalMessage = "Batch " + batchIndex + " (" + BATCH_NAMES[batchIndex - 1] + ") 达到 maxRounds=" + maxRounds
937
+ + " 仍有 must-fix,终止整个 workflow。残留: "
938
+ + (remainingIds.length ? remainingIds.join(", ") : "(issues 未追踪)")
939
+ + (state.knownRemaining && state.knownRemaining.length ? ";deferred: " + state.knownRemaining.join("; ") : "");
655
940
  log(finalMessage);
656
941
  saveState(state);
657
942
  batchIndex = BATCHES.length + 1;
@@ -671,7 +956,15 @@ return {
671
956
  targetType,
672
957
  target,
673
958
  runDir: RUN_ROOT,
959
+ // 5.9 terminated 透出:非 clean 时 message 含终止原因 + 残留 ID 清单 + deferred 理由
960
+ // (stuck/needs-redesign/converged/max-rounds/*-failure 均由 finalMessage 承载)。
961
+ // 渲染层特判(launcher 对 terminated 非 clean 的视觉区分)留 TODO:当前 tool 结果
962
+ // 已含完整 message,主 agent 可直接感知差异。
963
+ // 5.9 视觉区分:非 clean 终止加 [UNRESOLVED] 前缀(tool 结果即主 agent 可见层,
964
+ // launcher 透传 message——无需跨模块渲染特判,W5C3 决策更新)
674
965
  message: terminated === "clean"
675
966
  ? "All batches clean. " + totalFixed + " issue(s) fixed total. State: " + STATE_FILE
676
- : finalMessage + ". State: " + STATE_FILE,
967
+ : terminated === "converged"
968
+ ? finalMessage + " " + totalFixed + " issue(s) fixed total. State: " + STATE_FILE
969
+ : "[UNRESOLVED] " + finalMessage + ". State: " + STATE_FILE,
677
970
  };