agentflowctl 0.4.0 → 0.6.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/dist/engine.js CHANGED
@@ -2,14 +2,16 @@ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync
2
2
  import { join } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { config } from "./config.js";
5
+ import { arbitrationDecision } from "./arbitration.js";
5
6
  import { detectProjectDefaults, withProjectDefaults } from "./detect.js";
6
7
  import { changedFiles, commitAll, discardChanges, git, headCommit, resetTo } from "./git.js";
8
+ import { acceptHandoff, openActions, prepareHandoff, previewHandoff, readHandoff, recoverHandoff, reviewHandoffGate, validateHandoffResponse } from "./handoff.js";
7
9
  import { flowDir, logDir, projectRoot, runDir, worktreeDir } from "./paths.js";
8
10
  import { CMD_AGENT, nextLogFile } from "./logs.js";
9
11
  import { exec } from "./proc.js";
10
12
  import { arbiterPanel, availableAgent, fixAgent, planAgent, planFixAgent, reviewers, specAgent, taskAgents } from "./roles.js";
11
13
  import { resolveAgent, runAgent, runCommand } from "./runner.js";
12
- import { AcceptanceList, RepoConfig, ReviewResult, TaskList, } from "./schemas.js";
14
+ import { AcceptanceList, ArbiterResult, RepoConfig, ReviewResult, TaskList, } from "./schemas.js";
13
15
  import { addSubstitution, addUsage, agentRuns, saveRun } from "./store.js";
14
16
  import { orderTasks } from "./tasks.js";
15
17
  import { readJsonFile, renderPrompt, tail } from "./util.js";
@@ -29,6 +31,14 @@ export class QuotaPause extends Error {
29
31
  }
30
32
  /** 這次執行中已確認額度用完的 agent;程序結束即清空,resume 時會重新嘗試 */
31
33
  const exhausted = new Set();
34
+ function handoffTarget(run) {
35
+ return ["spec", "plan", "plan_review", "plan_fix"].includes(run.stage) ? "plan" : "code";
36
+ }
37
+ /** 同一輪重跑使用相同 key;重試次數或 panel 位置改變時使用新 key。 */
38
+ function handoffKey(run, step, slot, agent) {
39
+ const attempts = Object.entries(run.attempts).sort(([a], [b]) => a.localeCompare(b));
40
+ return JSON.stringify([run.id, run.stage, step, run.taskIndex, run.taskPhase, attempts, slot, agent]);
41
+ }
32
42
  async function agentStep(run, planned, step, prompt, mode) {
33
43
  const cfg = loadRepoConfig();
34
44
  const reset = mode.reset ?? (() => discardChanges(worktreeDir(run.id)));
@@ -38,7 +48,7 @@ async function agentStep(run, planned, step, prompt, mode) {
38
48
  if (mode.kind === "review") {
39
49
  throw new QuotaPause(`${agent} 的額度已用完;${step} 是審查步驟,不由另一家代打`);
40
50
  }
41
- const sub = availableAgent(run.cycle, agent, [...exhausted]);
51
+ const sub = availableAgent(run.cycle, agent, [...exhausted], `${run.id}:${step}:sub`);
42
52
  if (!sub)
43
53
  throw new QuotaPause(`所有 agent 的額度都已用完(${[...exhausted].join("、")})`);
44
54
  const note = step.endsWith("-code") && sub === run.lastTestsAuthor ? "測試與實作由同一家負責" : undefined;
@@ -46,11 +56,13 @@ async function agentStep(run, planned, step, prompt, mode) {
46
56
  info(run, `🔁 ${agent} 額度已用完,${step} 由 ${sub} 代打${note ? `(注意:${note})` : ""}`);
47
57
  agent = sub;
48
58
  }
59
+ const callKey = handoffKey(run, step, mode.slot ?? 0, agent);
60
+ prepareHandoff(run.id, callKey, handoffTarget(run), mode.blind ?? false);
49
61
  const r = await runAgent(agent, resolveAgent(cfg, agent), target(run, step, agent), prompt);
50
62
  addUsage(run.id, { stage: step, agent, inputTokens: r.inputTokens, outputTokens: r.outputTokens });
51
63
  if (!r.quotaExhausted) {
52
64
  reportMeta(run, agent, r);
53
- return { r, agent };
65
+ return { r, agent, step, callKey };
54
66
  }
55
67
  info(run, `⛽ ${agent} 的額度已用完`);
56
68
  exhausted.add(agent);
@@ -58,6 +70,28 @@ async function agentStep(run, planned, step, prompt, mode) {
58
70
  // 迴圈回到開頭:review 會停下,write 會找代打
59
71
  }
60
72
  }
73
+ /** 原有關卡已通過後才接受交接;失敗回覆不進入正式紀錄。 */
74
+ function finishHandoff(run, outcome, role, gate) {
75
+ const parsed = validateHandoffResponse(run.id);
76
+ if (!parsed.ok)
77
+ return parsed.error;
78
+ try {
79
+ const source = {
80
+ stage: run.stage, step: outcome.step, agent: outcome.agent, callKey: outcome.callKey,
81
+ };
82
+ const preview = previewHandoff(readHandoff(run.id), outcome.callKey, source, parsed.data, role);
83
+ if (gate) {
84
+ const error = reviewHandoffGate(preview, gate.target, gate.verdict);
85
+ if (error)
86
+ return error;
87
+ }
88
+ acceptHandoff(run.id, outcome.callKey, source, parsed.data, role);
89
+ return undefined;
90
+ }
91
+ catch (error) {
92
+ return error.message;
93
+ }
94
+ }
61
95
  /** 印出回覆裡的 XML 中繼資料;只供人檢視,關卡仍由程式檢查決定 */
62
96
  function reportMeta(run, agent, r) {
63
97
  if (!r.meta)
@@ -110,9 +144,10 @@ function loadOrderedTasks(run) {
110
144
  }
111
145
  // ───────────────────────── 各階段 ─────────────────────────
112
146
  async function specStage(run) {
113
- const agent = specAgent(run.cycle);
147
+ const agent = specAgent(run.cycle, run.id);
114
148
  info(run, `📝 產生規格(${agent})`);
115
- const { r } = await agentStep(run, agent, "spec", renderPrompt("spec", { requirement: run.requirement }), { kind: "write" });
149
+ const outcome = await agentStep(run, agent, "spec", renderPrompt("spec", { requirement: run.requirement }), { kind: "write" });
150
+ const { r } = outcome;
116
151
  await discardChanges(worktreeDir(run.id)); // 這個階段只允許寫 .flow/
117
152
  if (!r.ok)
118
153
  return retry(run, "spec", `Agent 執行失敗:${r.summary}`, "spec");
@@ -124,6 +159,9 @@ async function specStage(run) {
124
159
  const ids = ac.data.map((a) => a.id);
125
160
  if (new Set(ids).size !== ids.length)
126
161
  return retry(run, "spec", "驗收條件 id 有重複", "spec");
162
+ const handoffError = finishHandoff(run, outcome, "writer");
163
+ if (handoffError)
164
+ return retry(run, "spec", handoffError, "spec");
127
165
  return succeed(run, "spec", "plan");
128
166
  }
129
167
  // ── 規格與計畫檔案:審查者與仲裁者只能讀,不能改 ──
@@ -170,40 +208,50 @@ function announceTasks(run, ordered) {
170
208
  const cfg = loadRepoConfig();
171
209
  info(run, `📋 共 ${ordered.length} 個任務:${ordered.map((t) => t.id).join(" → ")}`);
172
210
  ordered.forEach((t, i) => {
173
- const a = taskAgents(run.cycle, i, cfg.tddSplit);
211
+ const a = taskAgents(run.cycle, i, cfg.tddSplit, run.id);
174
212
  info(run, ` ${t.id} 測試:${a.tests} 實作:${a.code}`);
175
213
  });
176
214
  }
177
215
  /** 計畫定案後:預設直接開始實作;--manual-plan 時才停下來等人 */
178
216
  function planSettled(run, key) {
217
+ const pending = openActions(readHandoff(run.id), "plan");
218
+ if (pending.length)
219
+ return retry(run, "plan-handoff", `計畫仍有未結交接事項:${pending.map((item) => item.id).join("、")}`, "plan_fix");
179
220
  const next = run.autopilot ? "implement" : "awaiting_approval";
180
221
  const ordered = loadOrderedTasks(run);
181
222
  announceTasks(run, ordered);
182
223
  if (!run.autopilot) {
183
224
  info(run, `✋ 計畫已通過審查,請檢視 ${flowFile(run, "plan.md")},確認後執行 agentflowctl approve ${run.id}`);
184
225
  }
185
- return { ...succeed(run, key, next), taskIndex: 0, taskPhase: "tests" };
226
+ const settled = succeed(run, key, next);
227
+ const attempts = { ...settled.attempts };
228
+ delete attempts["plan-arbitration"];
229
+ return { ...settled, attempts, taskIndex: 0, taskPhase: "tests" };
186
230
  }
187
231
  async function planStage(run) {
188
- const agent = planAgent(run.cycle);
232
+ const agent = planAgent(run.cycle, run.id);
189
233
  info(run, `🗺️ 拆解任務(${agent})`);
190
234
  const cfg = loadRepoConfig();
191
- const { r, agent: actual } = await agentStep(run, agent, "plan", renderPrompt("plan", { testPattern: cfg.testPattern }), { kind: "write" });
235
+ const outcome = await agentStep(run, agent, "plan", renderPrompt("plan", { testPattern: cfg.testPattern }), { kind: "write" });
236
+ const { r, agent: actual } = outcome;
192
237
  await discardChanges(worktreeDir(run.id));
193
238
  if (!r.ok)
194
239
  return retry(run, "plan", `Agent 執行失敗:${r.summary}`, "plan");
195
240
  const ordered = validatePlan(run);
196
241
  if (typeof ordered === "string")
197
242
  return retry(run, "plan", ordered, "plan");
243
+ const handoffError = finishHandoff(run, outcome, "writer");
244
+ if (handoffError)
245
+ return retry(run, "plan", handoffError, "plan");
198
246
  acceptPlan(run, ordered);
199
247
  rmSync(flowFile(run, "plan-review-last.txt"), { force: true });
200
248
  return { ...succeed(run, "plan", "plan_review"), planWriter: actual };
201
249
  }
202
250
  async function planReviewStage(run) {
203
251
  const cfg = loadRepoConfig();
204
- const author = run.planWriter ?? planAgent(run.cycle);
205
- const panel = reviewers(run.cycle, author, cfg.planReviewQuorum);
252
+ const author = run.planWriter ?? planAgent(run.cycle, run.id);
206
253
  const round = (run.attempts["plan-review"] ?? 0) + 1;
254
+ const panel = reviewers(run.cycle, author, cfg.planReviewQuorum, `${run.id}:plan-review:${round}`);
207
255
  const issues = [];
208
256
  const issueLines = []; // 只含意見內容、不含審查者名稱,用來偵測僵持
209
257
  let firstObjector;
@@ -211,7 +259,8 @@ async function planReviewStage(run) {
211
259
  info(run, `🧐 計畫審查第 ${round} 輪(${reviewer},作者 ${author})`);
212
260
  const snap = snapshotPlan(run);
213
261
  rmSync(flowFile(run, "plan-review.json"), { force: true });
214
- const { r } = await agentStep(run, reviewer, "plan-review", renderPrompt("plan-review", { reviewer, author, requirement: run.requirement }), { kind: "review", reset: async () => { await discardChanges(worktreeDir(run.id)); restorePlan(run, snap); } });
262
+ const outcome = await agentStep(run, reviewer, "plan-review", renderPrompt("plan-review", { reviewer, author, requirement: run.requirement }), { kind: "review", slot: panel.indexOf(reviewer), reset: async () => { await discardChanges(worktreeDir(run.id)); restorePlan(run, snap); } });
263
+ const { r } = outcome;
215
264
  await discardChanges(worktreeDir(run.id));
216
265
  const tampered = restorePlan(run, snap);
217
266
  if (tampered.length)
@@ -221,6 +270,9 @@ async function planReviewStage(run) {
221
270
  const review = readJsonFile(flowFile(run, "plan-review.json"), ReviewResult);
222
271
  if (!review.ok)
223
272
  return retry(run, "plan-review-run", review.error, "plan_review");
273
+ const handoffError = finishHandoff(run, outcome, "reviewer", { target: "plan", verdict: review.data.verdict });
274
+ if (handoffError)
275
+ return retry(run, "plan-review-run", handoffError, "plan_review");
224
276
  // 審查紀錄移到 worktree 外面:之後的仲裁者看不到是哪一家提的意見
225
277
  mkdirSync(join(runDir(run.id), "reviews"), { recursive: true });
226
278
  renameSync(flowFile(run, "plan-review.json"), join(runDir(run.id), "reviews", `plan-review-${round}-${reviewer}.json`));
@@ -259,11 +311,17 @@ async function planReviewStage(run) {
259
311
  }
260
312
  async function planFixStage(run) {
261
313
  const cfg = loadRepoConfig();
262
- const agent = planFixAgent(run.cycle, { strategy: cfg.fixStrategy, planWriter: run.planWriter, planReviewer: run.planReviewer });
314
+ const agent = planFixAgent(run.cycle, {
315
+ strategy: cfg.fixStrategy,
316
+ planWriter: run.planWriter,
317
+ planReviewer: run.planReviewer,
318
+ seed: `${run.id}:plan-fix:${run.attempts["plan-review"] ?? 0}`,
319
+ });
263
320
  info(run, `✏️ 依 ${run.planReviewer ?? "審查者"} 的意見修改計畫(${agent})`);
264
321
  const feedback = readFeedback(run);
265
322
  const snap = snapshotPlan(run);
266
- const { r, agent: actual } = await agentStep(run, agent, "plan-fix", renderPrompt("plan-fix", { requirement: run.requirement, testPattern: cfg.testPattern }), { kind: "write", reset: async () => { await discardChanges(worktreeDir(run.id)); restorePlan(run, snap); } });
323
+ const outcome = await agentStep(run, agent, "plan-fix", renderPrompt("plan-fix", { requirement: run.requirement, testPattern: cfg.testPattern }), { kind: "write", reset: async () => { await discardChanges(worktreeDir(run.id)); restorePlan(run, snap); } });
324
+ const { r, agent: actual } = outcome;
267
325
  await discardChanges(worktreeDir(run.id)); // 只允許改 .flow/
268
326
  if (!r.ok) {
269
327
  restorePlan(run, snap);
@@ -274,6 +332,11 @@ async function planFixStage(run) {
274
332
  restorePlan(run, snap);
275
333
  return retry(run, "plan-fix", `${feedback}\n\n另外,修改後的計畫沒有通過格式檢查,已還原:\n${ordered}`, "plan_fix");
276
334
  }
335
+ const handoffError = finishHandoff(run, outcome, "writer");
336
+ if (handoffError) {
337
+ restorePlan(run, snap);
338
+ return retry(run, "plan-fix", handoffError, "plan_fix");
339
+ }
277
340
  acceptPlan(run, ordered);
278
341
  writeFileSync(flowFile(run, "feedback.md"), feedback); // 保留審查意見,讓下一輪審查者知道上次提了什麼
279
342
  const attempts = { ...run.attempts };
@@ -286,39 +349,56 @@ async function planFixStage(run) {
286
349
  */
287
350
  async function arbitratePlan(run) {
288
351
  const cfg = loadRepoConfig();
289
- const panel = arbiterPanel(run.cycle, run.planWriter, run.planReviewer);
352
+ const arbitrationRound = (run.attempts["plan-arbitration"] ?? 0) + 1;
353
+ const panel = arbiterPanel(run.cycle, `${run.id}:arbiter`, run.planWriter, run.planReviewer);
290
354
  const mode = panel.length > 1 ? "雙盲交叉仲裁" : "第三方仲裁";
291
355
  const verdicts = [];
292
- for (const arbiter of panel) {
356
+ for (const [slot, arbiter] of panel.entries()) {
293
357
  info(run, `⚖️ ${mode}(${arbiter})`);
294
358
  const snap = snapshotPlan(run);
295
359
  rmSync(flowFile(run, "plan-arbiter.json"), { force: true });
296
- const { r } = await agentStep(run, arbiter, "plan-arbiter", renderPrompt("plan-arbiter", { requirement: run.requirement }), {
297
- kind: "review",
360
+ const outcome = await agentStep(run, arbiter, "plan-arbiter", renderPrompt("plan-arbiter", { requirement: run.requirement }), {
361
+ kind: "review", slot, blind: true,
298
362
  reset: async () => { await discardChanges(worktreeDir(run.id)); restorePlan(run, snap); },
299
363
  });
364
+ const { r } = outcome;
300
365
  await discardChanges(worktreeDir(run.id));
301
366
  restorePlan(run, snap);
302
- const result = r.ok ? readJsonFile(flowFile(run, "plan-arbiter.json"), ReviewResult) : undefined;
303
- if (result?.ok) {
304
- mkdirSync(join(runDir(run.id), "reviews"), { recursive: true });
305
- renameSync(flowFile(run, "plan-arbiter.json"), join(runDir(run.id), "reviews", `plan-arbiter-${arbiter}.json`));
367
+ const result = r.ok ? readJsonFile(flowFile(run, "plan-arbiter.json"), ArbiterResult) : undefined;
368
+ const handoffError = result?.ok ? finishHandoff(run, outcome, "reviewer", { target: "plan", verdict: result.data.verdict }) : undefined;
369
+ if (!result?.ok || handoffError) {
370
+ const outputError = !r.ok ? `Agent 執行失敗:${r.summary}` : !result ? "未產生有效裁決" : result.ok ? "未產生有效裁決" : result.error;
371
+ const reason = handoffError ?? outputError;
372
+ info(run, ` ⏸️ ${arbiter} 未產生有效裁決:${reason}`);
373
+ return { ...run, stage: "paused", pausedStage: "plan_review", pauseReason: `${arbiter} 仲裁未產生有效裁決:${reason}` };
306
374
  }
307
- const verdict = result?.ok ? result.data.verdict : "abstain";
308
- const notes = result?.ok ? result.data.items.map((i) => `- ${i.criterion}:${i.note}`) : [`- 未產生有效裁決(${r.ok ? "輸出格式錯誤" : r.summary})`];
309
- info(run, ` ${verdict === "approve" ? "✓" : verdict === "abstain" ? "…" : "✗"} ${arbiter}:${verdict === "approve" ? "可以執行" : verdict === "abstain" ? "未裁決" : "不可執行"}`);
375
+ mkdirSync(join(runDir(run.id), "reviews"), { recursive: true });
376
+ renameSync(flowFile(run, "plan-arbiter.json"), join(runDir(run.id), "reviews", `plan-arbiter-${arbitrationRound}-${arbiter}.json`));
377
+ const verdict = result.data.verdict;
378
+ const notes = result.data.items.map((i) => `- ${i.criterion}:${i.note}`);
379
+ info(run, ` ${verdict === "approve" ? "✓" : "✗"} ${arbiter}:${verdict === "approve" ? "可以執行" : "不可執行"}`);
310
380
  verdicts.push({ arbiter, verdict, notes });
311
381
  }
312
382
  rmSync(flowFile(run, "dispute.md"), { force: true });
313
383
  const approvals = verdicts.filter((v) => v.verdict === "approve").length;
314
384
  const unanimous = approvals === verdicts.length;
315
- const decision = unanimous ? "proceed" : approvals === 0 ? "stop" : cfg.tieBreak;
385
+ const decision = arbitrationDecision(verdicts.map((v) => v.verdict), cfg.tieBreak);
386
+ if (decision === "invalid")
387
+ throw new Error("仲裁結果缺少有效裁決");
316
388
  const summary = unanimous
317
389
  ? `${mode}一致核准`
318
390
  : approvals === 0
319
- ? `${mode}沒有任何一方核准`
391
+ ? `${mode}沒有任何一方核准${decision === "revise" ? ",交回計畫修訂" : ""}`
320
392
  : `${mode}意見分歧,依 tieBreak=${cfg.tieBreak} ${cfg.tieBreak === "proceed" ? "繼續實作" : "停止"}`;
321
393
  const record = verdicts.map((v) => `### ${v.arbiter}(${v.verdict})\n\n${v.notes.join("\n")}`).join("\n\n");
394
+ if (decision === "revise") {
395
+ const attempts = { ...run.attempts, "plan-arbitration": arbitrationRound };
396
+ delete attempts["plan-review"];
397
+ rmSync(flowFile(run, "plan-review-last.txt"), { force: true });
398
+ writeFileSync(flowFile(run, "feedback.md"), `# 仲裁要求修訂(第 ${arbitrationRound} 次)\n\n${summary}\n\n${record}\n`);
399
+ info(run, ` → ${summary}`);
400
+ return { ...run, attempts, stage: "plan_fix" };
401
+ }
322
402
  writeFileSync(flowFile(run, "plan.md"), `${readFileSync(flowFile(run, "plan.md"), "utf8")}\n\n## 仲裁紀錄\n\n**結果:${summary}**\n\n${record}\n`);
323
403
  if (decision === "proceed") {
324
404
  info(run, ` → ${summary}`);
@@ -339,13 +419,14 @@ async function implementStage(run) {
339
419
  const testCmd = `${cfg.install} && ${cfg.test}`;
340
420
  const progress = `${run.taskIndex + 1}/${tasks.length} ${task.id} ${task.title}`;
341
421
  const taskJson = JSON.stringify(task, null, 2);
342
- const agents = taskAgents(run.cycle, run.taskIndex, cfg.tddSplit);
422
+ const agents = taskAgents(run.cycle, run.taskIndex, cfg.tddSplit, run.id);
343
423
  // ── 紅燈:只寫測試,而且測試必須失敗 ──
344
424
  if (run.taskPhase === "tests") {
345
425
  const key = `${task.id}:tests`;
346
426
  info(run, `🧪 [${progress}] 撰寫測試(${agents.tests})`);
347
427
  const before = await headCommit(repo);
348
- const { r, agent: testsAuthor } = await agentStep(run, agents.tests, `${task.id}-tests`, renderPrompt("implement-tests", { task: taskJson, testPattern: cfg.testPattern, testCmd }), { kind: "write", reset: () => resetTo(repo, before) });
428
+ const outcome = await agentStep(run, agents.tests, `${task.id}-tests`, renderPrompt("implement-tests", { task: taskJson, testPattern: cfg.testPattern, testCmd }), { kind: "write", reset: () => resetTo(repo, before) });
429
+ const { r, agent: testsAuthor } = outcome;
349
430
  if (!r.ok) {
350
431
  await resetTo(repo, before);
351
432
  return retry(run, key, `Agent 執行失敗:${r.summary}`, "implement");
@@ -363,6 +444,11 @@ async function implementStage(run) {
363
444
  await resetTo(repo, before);
364
445
  return retry(run, key, "測試在功能尚未實作前就全部通過,代表測試沒有驗證到新行為。請撰寫會因功能尚未實作而失敗的測試。", "implement");
365
446
  }
447
+ const handoffError = finishHandoff(run, outcome, "writer");
448
+ if (handoffError) {
449
+ await resetTo(repo, before);
450
+ return retry(run, key, handoffError, "implement");
451
+ }
366
452
  writeFileSync(flowFile(run, "red-output.txt"), red.output);
367
453
  info(run, `🔴 [${progress}] 測試如預期失敗`);
368
454
  return { ...succeed(run, key, "implement"), taskPhase: "code", testsCommit: commit, lastTestsAuthor: testsAuthor };
@@ -374,7 +460,8 @@ async function implementStage(run) {
374
460
  throw new Error("缺少 testsCommit,狀態不一致");
375
461
  info(run, `🛠️ [${progress}] 實作(${agents.code},測試由 ${run.lastTestsAuthor ?? agents.tests} 撰寫)`);
376
462
  const redOutput = existsSync(flowFile(run, "red-output.txt")) ? readFileSync(flowFile(run, "red-output.txt"), "utf8") : "";
377
- const { r, agent: codeAuthor } = await agentStep(run, agents.code, `${task.id}-code`, renderPrompt("implement-code", { task: taskJson, testCmd, redOutput: tail(redOutput, 3000) }), { kind: "write", reset: () => resetTo(repo, testsCommit) });
463
+ const outcome = await agentStep(run, agents.code, `${task.id}-code`, renderPrompt("implement-code", { task: taskJson, testCmd, redOutput: tail(redOutput, 3000) }), { kind: "write", reset: () => resetTo(repo, testsCommit) });
464
+ const { r, agent: codeAuthor } = outcome;
378
465
  if (!r.ok)
379
466
  return retry(run, key, `Agent 執行失敗:${r.summary}`, "implement");
380
467
  await commitAll(repo, `feat(${task.id}): ${task.title} [${codeAuthor}]`);
@@ -388,6 +475,11 @@ async function implementStage(run) {
388
475
  info(run, ` ✗ 測試仍未通過${logHint(run, green.seq)}`);
389
476
  return retry(run, key, `測試仍未通過:\n\n\`\`\`\n${tail(green.output)}\n\`\`\``, "implement");
390
477
  }
478
+ const handoffError = finishHandoff(run, outcome, "writer");
479
+ if (handoffError) {
480
+ await resetTo(repo, testsCommit);
481
+ return retry(run, key, handoffError, "implement");
482
+ }
391
483
  info(run, `🟢 [${progress}] 完成`);
392
484
  return {
393
485
  ...succeed(run, key, "implement"),
@@ -424,16 +516,23 @@ async function verifyStage(run) {
424
516
  async function fixStage(run) {
425
517
  const cfg = loadRepoConfig();
426
518
  const source = run.fixSource ?? "verify";
427
- const agent = fixAgent(run.cycle, { source, strategy: cfg.fixStrategy, lastWriter: run.lastWriter, lastReviewer: run.lastReviewer });
519
+ const agent = fixAgent(run.cycle, {
520
+ source,
521
+ strategy: cfg.fixStrategy,
522
+ lastWriter: run.lastWriter,
523
+ lastReviewer: run.lastReviewer,
524
+ seed: `${run.id}:fix:${run.attempts.review ?? 0}`,
525
+ });
428
526
  const why = source === "review" ? `依 ${run.lastReviewer ?? "reviewer"} 的審查意見` : "修正驗證錯誤";
429
527
  info(run, `🩹 ${why}(${agent})`);
430
528
  const repo = worktreeDir(run.id);
431
529
  const feedback = readFeedback(run);
432
530
  const before = await headCommit(repo);
433
- const { r, agent: actual } = await agentStep(run, agent, "fix", renderPrompt("fix", { testPattern: cfg.testPattern }), {
531
+ const outcome = await agentStep(run, agent, "fix", renderPrompt("fix", { testPattern: cfg.testPattern }), {
434
532
  kind: "write",
435
533
  reset: () => resetTo(repo, before),
436
534
  });
535
+ const { r, agent: actual } = outcome;
437
536
  if (!r.ok)
438
537
  return retry(run, "fix", `${feedback}\n\n(上次修正時 Agent 執行失敗:${r.summary})`, "fix");
439
538
  await commitAll(repo, `fix: ${why} [${actual}]`);
@@ -443,30 +542,39 @@ async function fixStage(run) {
443
542
  await resetTo(repo, before);
444
543
  return retry(run, "fix", `${feedback}\n\n另外:不可刪除測試檔來讓檢查通過,已還原:${deleted.join(", ")}`, "fix");
445
544
  }
545
+ const handoffError = finishHandoff(run, outcome, "writer");
546
+ if (handoffError) {
547
+ await resetTo(repo, before);
548
+ return retry(run, "fix", handoffError, "fix");
549
+ }
446
550
  // 修正者成為新的作者,下一輪審查會換成別人
447
551
  return { ...to(run, "verify"), lastWriter: actual };
448
552
  }
449
553
  async function reviewStage(run) {
450
554
  const cfg = loadRepoConfig();
451
555
  const repo = worktreeDir(run.id);
452
- const panel = reviewers(run.cycle, run.lastWriter, cfg.reviewQuorum);
556
+ const panel = reviewers(run.cycle, run.lastWriter, cfg.reviewQuorum, `${run.id}:review:${run.attempts.review ?? 0}`);
453
557
  writeFileSync(flowFile(run, "diff.patch"), await git(repo, "diff", `${run.baseBranch}...HEAD`));
454
558
  const authors = [...new Set((await git(repo, "log", "--format=%s", `${run.baseBranch}..HEAD`)).match(/\[[^\]]+\]$/gm) ?? [])]
455
559
  .map((s) => s.slice(1, -1));
456
560
  const issues = [];
457
561
  let firstObjector;
458
- for (const reviewer of panel) {
562
+ for (const [slot, reviewer] of panel.entries()) {
459
563
  info(run, `👀 程式碼審查(${reviewer})`);
460
564
  rmSync(flowFile(run, "review.json"), { force: true });
461
- const { r } = await agentStep(run, reviewer, "review", renderPrompt("review", { reviewer, authors: authors.join("、") || "未知" }), {
462
- kind: "review",
565
+ const outcome = await agentStep(run, reviewer, "review", renderPrompt("review", { reviewer, authors: authors.join("、") || "未知" }), {
566
+ kind: "review", slot,
463
567
  });
568
+ const { r } = outcome;
464
569
  await discardChanges(repo); // 審查者不可改程式碼
465
570
  if (!r.ok)
466
571
  return retry(run, "review-run", `Agent 執行失敗:${r.summary}`, "review");
467
572
  const review = readJsonFile(flowFile(run, "review.json"), ReviewResult);
468
573
  if (!review.ok)
469
574
  return retry(run, "review-run", review.error, "review");
575
+ const handoffError = finishHandoff(run, outcome, "reviewer", { target: "code", verdict: review.data.verdict });
576
+ if (handoffError)
577
+ return retry(run, "review-run", handoffError, "review");
470
578
  renameSync(flowFile(run, "review.json"), flowFile(run, `review-${reviewer}.json`));
471
579
  if (review.data.verdict === "approve") {
472
580
  info(run, ` ✓ ${reviewer} 核准`);
@@ -489,6 +597,9 @@ async function reviewStage(run) {
489
597
  };
490
598
  }
491
599
  async function prStage(run) {
600
+ const pending = openActions(readHandoff(run.id));
601
+ if (pending.length)
602
+ return { ...run, stage: "failed", failedStage: "pr", failureReason: `仍有未結交接事項:${pending.map((item) => item.id).join("、")}` };
492
603
  const repo = worktreeDir(run.id);
493
604
  const remotes = (await git(repo, "remote")).split("\n").filter(Boolean);
494
605
  if (!remotes.includes("origin")) {
@@ -522,6 +633,7 @@ const STAGES = {
522
633
  /** 一路推進到需要人介入(awaiting_approval、paused)或結束(done / failed)為止;每一步都寫回檔案,中斷後可接續 */
523
634
  export async function advance(initial) {
524
635
  let run = initial;
636
+ recoverHandoff(run.id);
525
637
  for (;;) {
526
638
  if (["done", "failed", "awaiting_approval", "paused"].includes(run.stage))
527
639
  return run;
@@ -0,0 +1,109 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { z } from "zod";
5
+ import { flowDir, handoffPath, runDir } from "./paths.js";
6
+ import { HandoffLedger, HandoffResponse, HandoffSource } from "./schemas.js";
7
+ import { readJsonFile } from "./util.js";
8
+ const responsePath = (id) => join(flowDir(id), "handoff-response.json");
9
+ const receiptsDir = (id) => join(runDir(id), "handoff-receipts");
10
+ const keyHash = (key) => createHash("sha256").update(key).digest("hex").slice(0, 16);
11
+ const receiptPath = (id, key) => join(receiptsDir(id), `${keyHash(key)}.json`);
12
+ const Receipt = z.object({ callKey: z.string(), source: HandoffSource, response: HandoffResponse, role: z.enum(["writer", "reviewer"]) });
13
+ function writeAtomic(path, content) {
14
+ mkdirSync(dirname(path), { recursive: true });
15
+ const tmp = `${path}.tmp`;
16
+ writeFileSync(tmp, content);
17
+ renameSync(tmp, path);
18
+ }
19
+ export function readHandoff(id) {
20
+ const path = handoffPath(id);
21
+ if (!existsSync(path))
22
+ return { version: 1, issues: [] };
23
+ return HandoffLedger.parse(JSON.parse(readFileSync(path, "utf8")));
24
+ }
25
+ export function openActions(ledger, target) {
26
+ return ledger.issues.filter((item) => item.kind === "action"
27
+ && (item.status === "open" || item.status === "proposed_resolved")
28
+ && (!target || item.targetStage === target));
29
+ }
30
+ export function reviewHandoffGate(ledger, target, verdict) {
31
+ if (verdict !== "approve")
32
+ return undefined;
33
+ const pending = openActions(ledger, target);
34
+ return pending.length ? `審查核准與未結交接事項矛盾:${pending.map((item) => item.id).join("、")}` : undefined;
35
+ }
36
+ export function previewHandoff(ledger, callKey, source, response, role) {
37
+ source = HandoffSource.parse(source);
38
+ response = HandoffResponse.parse(response);
39
+ if (source.callKey !== callKey)
40
+ throw new Error("交接呼叫識別碼不一致");
41
+ if (ledger.appliedCalls?.includes(callKey))
42
+ return ledger;
43
+ const now = new Date().toISOString();
44
+ const issues = ledger.issues.map((item) => ({ ...item }));
45
+ for (const [index, item] of response.newIssues.entries()) {
46
+ const id = createHash("sha256").update(`${callKey}:${index}`).digest("hex").slice(0, 16);
47
+ if (issues.some((existing) => existing.id === id))
48
+ throw new Error(`交接事項 id 重複:${id}`);
49
+ issues.push({ id, source, ...item, status: "open", updatedAt: now });
50
+ }
51
+ for (const disposition of response.dispositions) {
52
+ const issue = issues.find((item) => item.id === disposition.id);
53
+ if (!issue)
54
+ throw new Error(`找不到交接事項:${disposition.id}`);
55
+ if (issue.kind !== "action")
56
+ throw new Error(`參考資訊不可結案:${disposition.id}`);
57
+ if (issue.status === "resolved" || issue.status === "accepted")
58
+ throw new Error(`交接事項已結案:${disposition.id}`);
59
+ if (role === "writer" && disposition.status !== "proposed_resolved")
60
+ throw new Error("作者只能提出已修正,不能自行結案");
61
+ if (role === "reviewer" && disposition.status === "proposed_resolved")
62
+ throw new Error("審查者須明確結案或接受風險");
63
+ issue.status = disposition.status;
64
+ issue.resolution = { agent: source.agent, reason: disposition.reason, evidence: disposition.evidence };
65
+ issue.updatedAt = now;
66
+ }
67
+ return HandoffLedger.parse({ version: 1, issues, appliedCalls: [...(ledger.appliedCalls ?? []), callKey] });
68
+ }
69
+ export function mergeHandoff(id, callKey, source, response, role) {
70
+ const next = previewHandoff(readHandoff(id), callKey, source, response, role);
71
+ writeAtomic(handoffPath(id), JSON.stringify(next, null, 2));
72
+ return next;
73
+ }
74
+ /** 只把目前步驟需要處理的事項投影給 agent。 */
75
+ export function prepareHandoff(id, _callKey, target, blind) {
76
+ const items = readHandoff(id).issues.filter((item) => item.targetStage === target && (item.kind === "info" || item.status === "open" || item.status === "proposed_resolved"));
77
+ const lines = items.map((item) => {
78
+ const source = blind ? "" : `\n來源:${item.source.stage}/${item.source.agent}`;
79
+ const resolution = item.resolution ? `\n處理理由:${item.resolution.reason}\n處理證據:${item.resolution.evidence}` : "";
80
+ return `## ${item.id}:${item.summary}\n證據:${item.evidence}\n狀態:${item.status}${resolution}${source}`;
81
+ });
82
+ mkdirSync(flowDir(id), { recursive: true });
83
+ writeFileSync(join(flowDir(id), "handoff-context.md"), `# 待處理交接事項\n\n${lines.length ? lines.join("\n\n") : "目前沒有待處理事項。"}\n`);
84
+ rmSync(responsePath(id), { force: true });
85
+ }
86
+ export function validateHandoffResponse(id) {
87
+ return readJsonFile(responsePath(id), HandoffResponse);
88
+ }
89
+ /** 已通過原有關卡的回覆先記收據,再合併;中斷後可重播。 */
90
+ export function acceptHandoff(id, callKey, source, response, role) {
91
+ const checked = Receipt.parse({ callKey, source, response, role });
92
+ previewHandoff(readHandoff(id), callKey, source, response, role);
93
+ const path = receiptPath(id, callKey);
94
+ writeAtomic(path, JSON.stringify(checked));
95
+ mergeHandoff(id, callKey, source, response, role);
96
+ rmSync(path, { force: true });
97
+ }
98
+ export function recoverHandoff(id) {
99
+ const dir = receiptsDir(id);
100
+ if (!existsSync(dir))
101
+ return;
102
+ for (const name of readdirSync(dir).filter((f) => f.endsWith(".json"))) {
103
+ const path = join(dir, name);
104
+ const receipt = Receipt.parse(JSON.parse(readFileSync(path, "utf8")));
105
+ mergeHandoff(id, receipt.callKey, receipt.source, receipt.response, receipt.role);
106
+ rmSync(path);
107
+ }
108
+ }
109
+ //# sourceMappingURL=handoff.js.map
package/dist/paths.js CHANGED
@@ -17,6 +17,7 @@ export const agentflowctlDir = () => join(projectRoot(), ".agentflowctl");
17
17
  export const runsDir = () => join(agentflowctlDir(), "runs");
18
18
  export const runDir = (id) => join(runsDir(), id);
19
19
  export const logDir = (id) => join(runDir(id), "logs");
20
+ export const handoffPath = (id) => join(runDir(id), "handoff.json");
20
21
  /** 每個 run 一個 git worktree,Agent 只在這裡工作,不碰你正在編輯的檔案 */
21
22
  export const worktreesDir = () => join(agentflowctlDir(), "worktrees");
22
23
  export const worktreeDir = (id) => join(worktreesDir(), id);