agentflowctl 0.5.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/README.md CHANGED
@@ -307,12 +307,14 @@ agentflowctl agent cycle claude-strong,codex,gemini # 不帶參數時顯
307
307
  | 有幾家 | 誰來仲裁 | 結果 |
308
308
  | --- | --- | --- |
309
309
  | 三家以上 | 沒參與這次討論的那一家 | 核准就繼續,否則 run 失敗 |
310
- | 兩家 | 兩家各自在全新 context 裡判斷 | 都核准就繼續;都不核准就依裁決意見修訂並重新審查,仲裁達重試上限才停下;分歧依 `tieBreak` |
310
+ | 兩家 | 兩家各自在全新 context 裡判斷 | 都核准就繼續;都不核准就依裁決意見修訂並重新審查;分歧依 `tieBreak` |
311
311
  | 一家 | 同一家 | 由它自己仲裁 |
312
312
 
313
313
  兩家時的仲裁是雙盲的。仲裁者只看計畫,以及一份不含模型名稱的爭議清單(`.flow/dispute.md`)。帶有名稱的審查紀錄移到 worktree 以外。`tieBreak` 預設 `proceed`,因為後面還有測試紅燈、綠燈、verify 與程式碼審查。
314
314
 
315
- 計畫定案或仲裁最終停止時,裁決與每位仲裁者的理由附在 `plan.md` 最後的「仲裁紀錄」。需再修訂時,裁決理由寫進 `.flow/feedback.md`,供修訂者處理;重新審查會從第一輪計數。原始審查與每輪仲裁紀錄在 `.agentflowctl/runs/<id>/reviews/`。仲裁最多進行 `AGENTFLOWCTL_MAX_ATTEMPTS` 次,預設三次。
315
+ 計畫定案或仲裁最終停止時,裁決與每位仲裁者的理由附在 `plan.md` 最後的「仲裁紀錄」。需再修訂時,裁決理由寫進 `.flow/feedback.md`,供修訂者處理;重新審查會從第一輪計數。原始審查與每輪仲裁紀錄在 `.agentflowctl/runs/<id>/reviews/`。兩家都要求修改時不因仲裁輪數而直接失敗;整個 run 仍受 `maxAgentRuns` 限制。
316
+
317
+ 仲裁 JSON 的 `verdict` 應為 `approve` 或 `changes_requested`;若模型寫成 `reject`,程式會當成 `changes_requested` 並保留理由。缺少檔案、JSON 格式錯誤或其他不合法輸出不算反對票,run 會暫停並顯示驗證錯誤;檢查 `agentflowctl logs <id>` 與 `.flow/plan-arbiter.json` 後可用 `resume` 重新執行。
316
318
 
317
319
  ## 階段與通過條件
318
320
 
@@ -322,7 +324,7 @@ agentflowctl agent cycle claude-strong,codex,gemini # 不帶參數時顯
322
324
  | plan | 與 spec 同一位 | zod、相依存在、無循環、每條驗收條件都有任務 | 重試 |
323
325
  | plan_review | 計畫作者以外隨機挑(可多位,不重複) | 所有審查者都 `approve` | 進入 plan_fix |
324
326
  | plan_fix | 依 `fixStrategy` | 修改後仍通過 plan 的格式與 DAG 檢查 | 還原並重試 |
325
- | 仲裁 | 見上一節 | 一致核准;分歧依 `tieBreak` | 兩家都不核准時先進入 plan_fix,達仲裁上限才失敗;第三方不核准或 `tieBreak: stop` 時失敗 |
327
+ | 仲裁 | 見上一節 | 一致核准;分歧依 `tieBreak` | 兩家都不核准時進入 plan_fix 再審查;第三方不核准或 `tieBreak: stop` 時失敗 |
326
328
  | 人工確認 | 你(只有 `--manual-plan`) | `agentflowctl approve` | — |
327
329
  | implement 紅燈 | 洗牌輪流,每家各一次 | 有測試變更,而且測試執行後失敗 | 還原並重試 |
328
330
  | implement 綠燈 | 測試作者以外隨機一位 | 測試檔沒有任何修改,而且測試通過 | 還原,或帶著輸出重試 |
@@ -349,6 +351,14 @@ Agent 的最後回覆要附上 XML 中繼資料:
349
351
 
350
352
  `blocked` 與 `concerns` 會印在終端機上,完整回覆留在 log,可用 `agentflowctl logs` 查看。這份中繼資料只給人看;缺少或格式錯誤都不影響流程,是否通過仍由上表的程式檢查決定。
351
353
 
354
+ ### Agent 交接紀錄
355
+
356
+ 每次 agent 執行前,程式會把與當前階段有關的未結事項寫入 `.flow/handoff-context.md`。agent 完成時必須寫 `.flow/handoff-response.json`,包含 `newIssues` 和 `dispositions` 兩個陣列;沒有事項也要明確寫成 `{ "newIssues": [], "dispositions": [] }`。缺少檔案或 JSON 格式不合法,會依該步驟的重試規則處理。
357
+
358
+ 程式只在原有關卡通過後接收交接回覆,並將正式紀錄原子儲存於 `.agentflowctl/runs/<id>/handoff.json`。`action` 是需要後續處理的事項;`info` 只供參考。作者只能提出已修正並附證據,審查者才能確認結案或附理由接受。XML `<concerns>` 可以供人閱讀,但重要疑慮必須寫進交接 JSON,才能交給下一位 agent。額度代打與重試不會接收失敗呼叫的交接內容;中斷後可用 `resume` 接續。
359
+
360
+ 計畫審查或程式碼審查若核准,但該階段仍有未結的 `action`,程式會視為互相矛盾的審查結果並重試。計畫定案和開 PR 前也會再檢查一次;`info` 會提供給目標階段閱讀,但不阻擋通關。
361
+
352
362
  ## Adapter
353
363
 
354
364
  | adapter | 執行方式 | 權限 |
@@ -1,10 +1,12 @@
1
- /** 仲裁沒有共識時,兩家先依裁決意見修訂計畫;重複仲裁仍受次數上限約束。 */
2
- export function arbitrationDecision(verdicts, tieBreak, round, maxAttempts) {
1
+ /** 兩家仲裁都要求修改時交回計畫修訂;整體執行次數由 maxAgentRuns 限制。 */
2
+ export function arbitrationDecision(verdicts, tieBreak) {
3
+ if (verdicts.includes("abstain"))
4
+ return "invalid";
3
5
  const approvals = verdicts.filter((v) => v === "approve").length;
4
6
  if (approvals === verdicts.length)
5
7
  return "proceed";
6
8
  if (approvals > 0)
7
9
  return tieBreak;
8
- return verdicts.length === 2 && round < maxAttempts ? "revise" : "stop";
10
+ return verdicts.length === 2 ? "revise" : "stop";
9
11
  }
10
12
  //# sourceMappingURL=arbitration.js.map
package/dist/engine.js CHANGED
@@ -5,12 +5,13 @@ import { config } from "./config.js";
5
5
  import { arbitrationDecision } from "./arbitration.js";
6
6
  import { detectProjectDefaults, withProjectDefaults } from "./detect.js";
7
7
  import { changedFiles, commitAll, discardChanges, git, headCommit, resetTo } from "./git.js";
8
+ import { acceptHandoff, openActions, prepareHandoff, previewHandoff, readHandoff, recoverHandoff, reviewHandoffGate, validateHandoffResponse } from "./handoff.js";
8
9
  import { flowDir, logDir, projectRoot, runDir, worktreeDir } from "./paths.js";
9
10
  import { CMD_AGENT, nextLogFile } from "./logs.js";
10
11
  import { exec } from "./proc.js";
11
12
  import { arbiterPanel, availableAgent, fixAgent, planAgent, planFixAgent, reviewers, specAgent, taskAgents } from "./roles.js";
12
13
  import { resolveAgent, runAgent, runCommand } from "./runner.js";
13
- import { AcceptanceList, RepoConfig, ReviewResult, TaskList, } from "./schemas.js";
14
+ import { AcceptanceList, ArbiterResult, RepoConfig, ReviewResult, TaskList, } from "./schemas.js";
14
15
  import { addSubstitution, addUsage, agentRuns, saveRun } from "./store.js";
15
16
  import { orderTasks } from "./tasks.js";
16
17
  import { readJsonFile, renderPrompt, tail } from "./util.js";
@@ -30,6 +31,14 @@ export class QuotaPause extends Error {
30
31
  }
31
32
  /** 這次執行中已確認額度用完的 agent;程序結束即清空,resume 時會重新嘗試 */
32
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
+ }
33
42
  async function agentStep(run, planned, step, prompt, mode) {
34
43
  const cfg = loadRepoConfig();
35
44
  const reset = mode.reset ?? (() => discardChanges(worktreeDir(run.id)));
@@ -47,11 +56,13 @@ async function agentStep(run, planned, step, prompt, mode) {
47
56
  info(run, `🔁 ${agent} 額度已用完,${step} 由 ${sub} 代打${note ? `(注意:${note})` : ""}`);
48
57
  agent = sub;
49
58
  }
59
+ const callKey = handoffKey(run, step, mode.slot ?? 0, agent);
60
+ prepareHandoff(run.id, callKey, handoffTarget(run), mode.blind ?? false);
50
61
  const r = await runAgent(agent, resolveAgent(cfg, agent), target(run, step, agent), prompt);
51
62
  addUsage(run.id, { stage: step, agent, inputTokens: r.inputTokens, outputTokens: r.outputTokens });
52
63
  if (!r.quotaExhausted) {
53
64
  reportMeta(run, agent, r);
54
- return { r, agent };
65
+ return { r, agent, step, callKey };
55
66
  }
56
67
  info(run, `⛽ ${agent} 的額度已用完`);
57
68
  exhausted.add(agent);
@@ -59,6 +70,28 @@ async function agentStep(run, planned, step, prompt, mode) {
59
70
  // 迴圈回到開頭:review 會停下,write 會找代打
60
71
  }
61
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
+ }
62
95
  /** 印出回覆裡的 XML 中繼資料;只供人檢視,關卡仍由程式檢查決定 */
63
96
  function reportMeta(run, agent, r) {
64
97
  if (!r.meta)
@@ -113,7 +146,8 @@ function loadOrderedTasks(run) {
113
146
  async function specStage(run) {
114
147
  const agent = specAgent(run.cycle, run.id);
115
148
  info(run, `📝 產生規格(${agent})`);
116
- 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;
117
151
  await discardChanges(worktreeDir(run.id)); // 這個階段只允許寫 .flow/
118
152
  if (!r.ok)
119
153
  return retry(run, "spec", `Agent 執行失敗:${r.summary}`, "spec");
@@ -125,6 +159,9 @@ async function specStage(run) {
125
159
  const ids = ac.data.map((a) => a.id);
126
160
  if (new Set(ids).size !== ids.length)
127
161
  return retry(run, "spec", "驗收條件 id 有重複", "spec");
162
+ const handoffError = finishHandoff(run, outcome, "writer");
163
+ if (handoffError)
164
+ return retry(run, "spec", handoffError, "spec");
128
165
  return succeed(run, "spec", "plan");
129
166
  }
130
167
  // ── 規格與計畫檔案:審查者與仲裁者只能讀,不能改 ──
@@ -177,6 +214,9 @@ function announceTasks(run, ordered) {
177
214
  }
178
215
  /** 計畫定案後:預設直接開始實作;--manual-plan 時才停下來等人 */
179
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");
180
220
  const next = run.autopilot ? "implement" : "awaiting_approval";
181
221
  const ordered = loadOrderedTasks(run);
182
222
  announceTasks(run, ordered);
@@ -192,13 +232,17 @@ async function planStage(run) {
192
232
  const agent = planAgent(run.cycle, run.id);
193
233
  info(run, `🗺️ 拆解任務(${agent})`);
194
234
  const cfg = loadRepoConfig();
195
- 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;
196
237
  await discardChanges(worktreeDir(run.id));
197
238
  if (!r.ok)
198
239
  return retry(run, "plan", `Agent 執行失敗:${r.summary}`, "plan");
199
240
  const ordered = validatePlan(run);
200
241
  if (typeof ordered === "string")
201
242
  return retry(run, "plan", ordered, "plan");
243
+ const handoffError = finishHandoff(run, outcome, "writer");
244
+ if (handoffError)
245
+ return retry(run, "plan", handoffError, "plan");
202
246
  acceptPlan(run, ordered);
203
247
  rmSync(flowFile(run, "plan-review-last.txt"), { force: true });
204
248
  return { ...succeed(run, "plan", "plan_review"), planWriter: actual };
@@ -215,7 +259,8 @@ async function planReviewStage(run) {
215
259
  info(run, `🧐 計畫審查第 ${round} 輪(${reviewer},作者 ${author})`);
216
260
  const snap = snapshotPlan(run);
217
261
  rmSync(flowFile(run, "plan-review.json"), { force: true });
218
- 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;
219
264
  await discardChanges(worktreeDir(run.id));
220
265
  const tampered = restorePlan(run, snap);
221
266
  if (tampered.length)
@@ -225,6 +270,9 @@ async function planReviewStage(run) {
225
270
  const review = readJsonFile(flowFile(run, "plan-review.json"), ReviewResult);
226
271
  if (!review.ok)
227
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");
228
276
  // 審查紀錄移到 worktree 外面:之後的仲裁者看不到是哪一家提的意見
229
277
  mkdirSync(join(runDir(run.id), "reviews"), { recursive: true });
230
278
  renameSync(flowFile(run, "plan-review.json"), join(runDir(run.id), "reviews", `plan-review-${round}-${reviewer}.json`));
@@ -272,7 +320,8 @@ async function planFixStage(run) {
272
320
  info(run, `✏️ 依 ${run.planReviewer ?? "審查者"} 的意見修改計畫(${agent})`);
273
321
  const feedback = readFeedback(run);
274
322
  const snap = snapshotPlan(run);
275
- 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;
276
325
  await discardChanges(worktreeDir(run.id)); // 只允許改 .flow/
277
326
  if (!r.ok) {
278
327
  restorePlan(run, snap);
@@ -283,6 +332,11 @@ async function planFixStage(run) {
283
332
  restorePlan(run, snap);
284
333
  return retry(run, "plan-fix", `${feedback}\n\n另外,修改後的計畫沒有通過格式檢查,已還原:\n${ordered}`, "plan_fix");
285
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
+ }
286
340
  acceptPlan(run, ordered);
287
341
  writeFileSync(flowFile(run, "feedback.md"), feedback); // 保留審查意見,讓下一輪審查者知道上次提了什麼
288
342
  const attempts = { ...run.attempts };
@@ -299,30 +353,38 @@ async function arbitratePlan(run) {
299
353
  const panel = arbiterPanel(run.cycle, `${run.id}:arbiter`, run.planWriter, run.planReviewer);
300
354
  const mode = panel.length > 1 ? "雙盲交叉仲裁" : "第三方仲裁";
301
355
  const verdicts = [];
302
- for (const arbiter of panel) {
356
+ for (const [slot, arbiter] of panel.entries()) {
303
357
  info(run, `⚖️ ${mode}(${arbiter})`);
304
358
  const snap = snapshotPlan(run);
305
359
  rmSync(flowFile(run, "plan-arbiter.json"), { force: true });
306
- const { r } = await agentStep(run, arbiter, "plan-arbiter", renderPrompt("plan-arbiter", { requirement: run.requirement }), {
307
- kind: "review",
360
+ const outcome = await agentStep(run, arbiter, "plan-arbiter", renderPrompt("plan-arbiter", { requirement: run.requirement }), {
361
+ kind: "review", slot, blind: true,
308
362
  reset: async () => { await discardChanges(worktreeDir(run.id)); restorePlan(run, snap); },
309
363
  });
364
+ const { r } = outcome;
310
365
  await discardChanges(worktreeDir(run.id));
311
366
  restorePlan(run, snap);
312
- const result = r.ok ? readJsonFile(flowFile(run, "plan-arbiter.json"), ReviewResult) : undefined;
313
- if (result?.ok) {
314
- mkdirSync(join(runDir(run.id), "reviews"), { recursive: true });
315
- renameSync(flowFile(run, "plan-arbiter.json"), join(runDir(run.id), "reviews", `plan-arbiter-${arbitrationRound}-${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}` };
316
374
  }
317
- const verdict = result?.ok ? result.data.verdict : "abstain";
318
- const notes = result?.ok ? result.data.items.map((i) => `- ${i.criterion}:${i.note}`) : [`- 未產生有效裁決(${r.ok ? "輸出格式錯誤" : r.summary})`];
319
- 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" ? "可以執行" : "不可執行"}`);
320
380
  verdicts.push({ arbiter, verdict, notes });
321
381
  }
322
382
  rmSync(flowFile(run, "dispute.md"), { force: true });
323
383
  const approvals = verdicts.filter((v) => v.verdict === "approve").length;
324
384
  const unanimous = approvals === verdicts.length;
325
- const decision = arbitrationDecision(verdicts.map((v) => v.verdict), cfg.tieBreak, arbitrationRound, config.maxAttempts);
385
+ const decision = arbitrationDecision(verdicts.map((v) => v.verdict), cfg.tieBreak);
386
+ if (decision === "invalid")
387
+ throw new Error("仲裁結果缺少有效裁決");
326
388
  const summary = unanimous
327
389
  ? `${mode}一致核准`
328
390
  : approvals === 0
@@ -363,7 +425,8 @@ async function implementStage(run) {
363
425
  const key = `${task.id}:tests`;
364
426
  info(run, `🧪 [${progress}] 撰寫測試(${agents.tests})`);
365
427
  const before = await headCommit(repo);
366
- 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;
367
430
  if (!r.ok) {
368
431
  await resetTo(repo, before);
369
432
  return retry(run, key, `Agent 執行失敗:${r.summary}`, "implement");
@@ -381,6 +444,11 @@ async function implementStage(run) {
381
444
  await resetTo(repo, before);
382
445
  return retry(run, key, "測試在功能尚未實作前就全部通過,代表測試沒有驗證到新行為。請撰寫會因功能尚未實作而失敗的測試。", "implement");
383
446
  }
447
+ const handoffError = finishHandoff(run, outcome, "writer");
448
+ if (handoffError) {
449
+ await resetTo(repo, before);
450
+ return retry(run, key, handoffError, "implement");
451
+ }
384
452
  writeFileSync(flowFile(run, "red-output.txt"), red.output);
385
453
  info(run, `🔴 [${progress}] 測試如預期失敗`);
386
454
  return { ...succeed(run, key, "implement"), taskPhase: "code", testsCommit: commit, lastTestsAuthor: testsAuthor };
@@ -392,7 +460,8 @@ async function implementStage(run) {
392
460
  throw new Error("缺少 testsCommit,狀態不一致");
393
461
  info(run, `🛠️ [${progress}] 實作(${agents.code},測試由 ${run.lastTestsAuthor ?? agents.tests} 撰寫)`);
394
462
  const redOutput = existsSync(flowFile(run, "red-output.txt")) ? readFileSync(flowFile(run, "red-output.txt"), "utf8") : "";
395
- 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;
396
465
  if (!r.ok)
397
466
  return retry(run, key, `Agent 執行失敗:${r.summary}`, "implement");
398
467
  await commitAll(repo, `feat(${task.id}): ${task.title} [${codeAuthor}]`);
@@ -406,6 +475,11 @@ async function implementStage(run) {
406
475
  info(run, ` ✗ 測試仍未通過${logHint(run, green.seq)}`);
407
476
  return retry(run, key, `測試仍未通過:\n\n\`\`\`\n${tail(green.output)}\n\`\`\``, "implement");
408
477
  }
478
+ const handoffError = finishHandoff(run, outcome, "writer");
479
+ if (handoffError) {
480
+ await resetTo(repo, testsCommit);
481
+ return retry(run, key, handoffError, "implement");
482
+ }
409
483
  info(run, `🟢 [${progress}] 完成`);
410
484
  return {
411
485
  ...succeed(run, key, "implement"),
@@ -454,10 +528,11 @@ async function fixStage(run) {
454
528
  const repo = worktreeDir(run.id);
455
529
  const feedback = readFeedback(run);
456
530
  const before = await headCommit(repo);
457
- 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 }), {
458
532
  kind: "write",
459
533
  reset: () => resetTo(repo, before),
460
534
  });
535
+ const { r, agent: actual } = outcome;
461
536
  if (!r.ok)
462
537
  return retry(run, "fix", `${feedback}\n\n(上次修正時 Agent 執行失敗:${r.summary})`, "fix");
463
538
  await commitAll(repo, `fix: ${why} [${actual}]`);
@@ -467,6 +542,11 @@ async function fixStage(run) {
467
542
  await resetTo(repo, before);
468
543
  return retry(run, "fix", `${feedback}\n\n另外:不可刪除測試檔來讓檢查通過,已還原:${deleted.join(", ")}`, "fix");
469
544
  }
545
+ const handoffError = finishHandoff(run, outcome, "writer");
546
+ if (handoffError) {
547
+ await resetTo(repo, before);
548
+ return retry(run, "fix", handoffError, "fix");
549
+ }
470
550
  // 修正者成為新的作者,下一輪審查會換成別人
471
551
  return { ...to(run, "verify"), lastWriter: actual };
472
552
  }
@@ -479,18 +559,22 @@ async function reviewStage(run) {
479
559
  .map((s) => s.slice(1, -1));
480
560
  const issues = [];
481
561
  let firstObjector;
482
- for (const reviewer of panel) {
562
+ for (const [slot, reviewer] of panel.entries()) {
483
563
  info(run, `👀 程式碼審查(${reviewer})`);
484
564
  rmSync(flowFile(run, "review.json"), { force: true });
485
- const { r } = await agentStep(run, reviewer, "review", renderPrompt("review", { reviewer, authors: authors.join("、") || "未知" }), {
486
- kind: "review",
565
+ const outcome = await agentStep(run, reviewer, "review", renderPrompt("review", { reviewer, authors: authors.join("、") || "未知" }), {
566
+ kind: "review", slot,
487
567
  });
568
+ const { r } = outcome;
488
569
  await discardChanges(repo); // 審查者不可改程式碼
489
570
  if (!r.ok)
490
571
  return retry(run, "review-run", `Agent 執行失敗:${r.summary}`, "review");
491
572
  const review = readJsonFile(flowFile(run, "review.json"), ReviewResult);
492
573
  if (!review.ok)
493
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");
494
578
  renameSync(flowFile(run, "review.json"), flowFile(run, `review-${reviewer}.json`));
495
579
  if (review.data.verdict === "approve") {
496
580
  info(run, ` ✓ ${reviewer} 核准`);
@@ -513,6 +597,9 @@ async function reviewStage(run) {
513
597
  };
514
598
  }
515
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("、")}` };
516
603
  const repo = worktreeDir(run.id);
517
604
  const remotes = (await git(repo, "remote")).split("\n").filter(Boolean);
518
605
  if (!remotes.includes("origin")) {
@@ -546,6 +633,7 @@ const STAGES = {
546
633
  /** 一路推進到需要人介入(awaiting_approval、paused)或結束(done / failed)為止;每一步都寫回檔案,中斷後可接續 */
547
634
  export async function advance(initial) {
548
635
  let run = initial;
636
+ recoverHandoff(run.id);
549
637
  for (;;) {
550
638
  if (["done", "failed", "awaiting_approval", "paused"].includes(run.stage))
551
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);
package/dist/schemas.js CHANGED
@@ -14,6 +14,50 @@ export const Stage = z.enum([
14
14
  "done",
15
15
  "failed",
16
16
  ]);
17
+ export const HandoffSource = z.object({
18
+ stage: Stage,
19
+ step: z.string().min(1),
20
+ agent: z.string().min(1),
21
+ callKey: z.string().min(1),
22
+ });
23
+ const HandoffKind = z.enum(["action", "info"]);
24
+ const HandoffTarget = z.enum(["plan", "code"]);
25
+ const HandoffStatus = z.enum(["open", "proposed_resolved", "resolved", "accepted"]);
26
+ export const HandoffResponse = z.object({
27
+ newIssues: z.array(z.object({
28
+ kind: HandoffKind,
29
+ summary: z.string().trim().min(1),
30
+ evidence: z.string().trim().min(1),
31
+ targetStage: HandoffTarget,
32
+ })),
33
+ dispositions: z.array(z.object({
34
+ id: z.string().min(1),
35
+ status: z.enum(["proposed_resolved", "resolved", "accepted"]),
36
+ reason: z.string().trim().min(1),
37
+ evidence: z.string().trim().min(1),
38
+ })),
39
+ });
40
+ export const HandoffIssue = z.object({
41
+ id: z.string().min(1),
42
+ source: HandoffSource,
43
+ kind: HandoffKind,
44
+ summary: z.string().min(1),
45
+ evidence: z.string().min(1),
46
+ targetStage: HandoffTarget,
47
+ status: HandoffStatus,
48
+ resolution: z.object({
49
+ agent: z.string(),
50
+ reason: z.string(),
51
+ evidence: z.string(),
52
+ }).optional(),
53
+ updatedAt: z.string(),
54
+ });
55
+ export const HandoffLedger = z.object({
56
+ version: z.literal(1),
57
+ issues: z.array(HandoffIssue),
58
+ /** 用來辨識沒有新增事項的回覆是否已經合併。 */
59
+ appliedCalls: z.array(z.string()).optional(),
60
+ });
17
61
  /** Agent 在 spec 階段產出的 .flow/acceptance.json */
18
62
  export const AcceptanceList = z
19
63
  .array(z.object({
@@ -39,6 +83,11 @@ export const ReviewResult = z.object({
39
83
  note: z.string().default(""),
40
84
  })),
41
85
  });
86
+ /** 仲裁者可能用 reject 表示否決;讀取時正規化,保留相同的理由欄位。 */
87
+ export const ArbiterResult = ReviewResult.extend({
88
+ verdict: z.enum(["approve", "changes_requested", "reject"])
89
+ .transform((verdict) => verdict === "reject" ? "changes_requested" : verdict),
90
+ });
42
91
  /** 一個 agent 的定義;名稱(agents 的 key)用在 cycle 裡 */
43
92
  export const AgentDef = z.object({
44
93
  adapter: z.enum(["claude", "codex", "gemini", "command"]),
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agentflowctl",
3
3
  "license": "MIT",
4
- "version": "0.5.0",
4
+ "version": "0.6.0",
5
5
  "description": "跨廠商 AI 開發 harness:Claude Code、Codex、Gemini 輪流實作、審查、修正",
6
6
  "keywords": [
7
7
  "ai",
package/prompts/fix.md CHANGED
@@ -6,6 +6,17 @@
6
6
  目前的工作目錄就是專案(agentflowctl 為這次任務建立的專用 git worktree)。
7
7
  </context>
8
8
 
9
+ <handoff>
10
+ 先閱讀 .flow/handoff-context.md,處理與本階段有關的待辦事項。完成時寫入 .flow/handoff-response.json;即使沒有事項也必須寫出空陣列:
11
+
12
+ ```json
13
+ { "newIssues": [], "dispositions": [] }
14
+ ```
15
+
16
+ 新增事項格式:{ "kind": "action 或 info", "summary": "具體問題", "evidence": "檔案位置或檢查證據", "targetStage": "plan 或 code" }。
17
+ 處置格式:{ "id": "既有事項 ID", "status": "proposed_resolved、resolved 或 accepted", "reason": "具體處理理由", "evidence": "檔案、commit 或檢查結果" }。撰寫者只能用 proposed_resolved 提出修正;審查者可以用 resolved 或 accepted 結案。重要疑慮必須放在這份檔案,不能只寫在回覆的 <concerns>。
18
+ </handoff>
19
+
9
20
  <inputs>
10
21
  - .flow/feedback.md:失敗的檢查(型別、lint、測試、建置)或審查意見
11
22
  - .flow/spec.md 與 .flow/acceptance.json:規格
@@ -6,6 +6,17 @@
6
6
  目前的工作目錄就是專案(agentflowctl 為這次任務建立的專用 git worktree)。
7
7
  </context>
8
8
 
9
+ <handoff>
10
+ 先閱讀 .flow/handoff-context.md,處理與本階段有關的待辦事項。完成時寫入 .flow/handoff-response.json;即使沒有事項也必須寫出空陣列:
11
+
12
+ ```json
13
+ { "newIssues": [], "dispositions": [] }
14
+ ```
15
+
16
+ 新增事項格式:{ "kind": "action 或 info", "summary": "具體問題", "evidence": "檔案位置或檢查證據", "targetStage": "plan 或 code" }。
17
+ 處置格式:{ "id": "既有事項 ID", "status": "proposed_resolved、resolved 或 accepted", "reason": "具體處理理由", "evidence": "檔案、commit 或檢查結果" }。撰寫者只能用 proposed_resolved 提出修正;審查者可以用 resolved 或 accepted 結案。重要疑慮必須放在這份檔案,不能只寫在回覆的 <concerns>。
18
+ </handoff>
19
+
9
20
  <task>
10
21
  ```json
11
22
  {{task}}
@@ -6,6 +6,17 @@
6
6
  目前的工作目錄就是專案(agentflowctl 為這次任務建立的專用 git worktree)。
7
7
  </context>
8
8
 
9
+ <handoff>
10
+ 先閱讀 .flow/handoff-context.md,處理與本階段有關的待辦事項。完成時寫入 .flow/handoff-response.json;即使沒有事項也必須寫出空陣列:
11
+
12
+ ```json
13
+ { "newIssues": [], "dispositions": [] }
14
+ ```
15
+
16
+ 新增事項格式:{ "kind": "action 或 info", "summary": "具體問題", "evidence": "檔案位置或檢查證據", "targetStage": "plan 或 code" }。
17
+ 處置格式:{ "id": "既有事項 ID", "status": "proposed_resolved、resolved 或 accepted", "reason": "具體處理理由", "evidence": "檔案、commit 或檢查結果" }。撰寫者只能用 proposed_resolved 提出修正;審查者可以用 resolved 或 accepted 結案。重要疑慮必須放在這份檔案,不能只寫在回覆的 <concerns>。
18
+ </handoff>
19
+
9
20
  <task>
10
21
  ```json
11
22
  {{task}}
@@ -6,6 +6,17 @@
6
6
  目前的工作目錄就是專案(agentflowctl 為這次任務建立的專用 git worktree)。
7
7
  </context>
8
8
 
9
+ <handoff>
10
+ 先閱讀 .flow/handoff-context.md,處理與本階段有關的待辦事項。完成時寫入 .flow/handoff-response.json;即使沒有事項也必須寫出空陣列:
11
+
12
+ ```json
13
+ { "newIssues": [], "dispositions": [] }
14
+ ```
15
+
16
+ 新增事項格式:{ "kind": "action 或 info", "summary": "具體問題", "evidence": "檔案位置或檢查證據", "targetStage": "plan 或 code" }。
17
+ 處置格式:{ "id": "既有事項 ID", "status": "proposed_resolved、resolved 或 accepted", "reason": "具體處理理由", "evidence": "檔案、commit 或檢查結果" }。撰寫者只能用 proposed_resolved 提出修正;審查者可以用 resolved 或 accepted 結案。重要疑慮必須放在這份檔案,不能只寫在回覆的 <concerns>。
18
+ </handoff>
19
+
9
20
  <requirement>
10
21
  {{requirement}}
11
22
  </requirement>
@@ -36,10 +47,11 @@
36
47
  ```
37
48
 
38
49
  dispute.md 裡的每一條意見都要列一筆並說明你的判斷。
50
+ `verdict` 只能寫 `approve` 或 `changes_requested`。若計畫有會導致錯誤結果、遺漏需求或無法驗收的問題,請寫 `changes_requested`,不要寫 `reject`。
39
51
  </output_format>
40
52
 
41
53
  <constraints>
42
- - 只能寫入 .flow/plan-arbiter.json,不可修改規格、計畫或任何程式碼。
54
+ - 只能寫入 .flow/plan-arbiter.json 與 .flow/handoff-response.json,不可修改規格、計畫或任何程式碼。
43
55
  </constraints>
44
56
 
45
57
  <reply_format>
@@ -6,6 +6,17 @@
6
6
  目前的工作目錄就是專案(agentflowctl 為這次任務建立的專用 git worktree)。
7
7
  </context>
8
8
 
9
+ <handoff>
10
+ 先閱讀 .flow/handoff-context.md,處理與本階段有關的待辦事項。完成時寫入 .flow/handoff-response.json;即使沒有事項也必須寫出空陣列:
11
+
12
+ ```json
13
+ { "newIssues": [], "dispositions": [] }
14
+ ```
15
+
16
+ 新增事項格式:{ "kind": "action 或 info", "summary": "具體問題", "evidence": "檔案位置或檢查證據", "targetStage": "plan 或 code" }。
17
+ 處置格式:{ "id": "既有事項 ID", "status": "proposed_resolved、resolved 或 accepted", "reason": "具體處理理由", "evidence": "檔案、commit 或檢查結果" }。撰寫者只能用 proposed_resolved 提出修正;審查者可以用 resolved 或 accepted 結案。重要疑慮必須放在這份檔案,不能只寫在回覆的 <concerns>。
18
+ </handoff>
19
+
9
20
  <requirement>
10
21
  {{requirement}}
11
22
  </requirement>
@@ -6,6 +6,17 @@
6
6
  目前的工作目錄就是專案(agentflowctl 為這次任務建立的專用 git worktree)。
7
7
  </context>
8
8
 
9
+ <handoff>
10
+ 先閱讀 .flow/handoff-context.md,處理與本階段有關的待辦事項。完成時寫入 .flow/handoff-response.json;即使沒有事項也必須寫出空陣列:
11
+
12
+ ```json
13
+ { "newIssues": [], "dispositions": [] }
14
+ ```
15
+
16
+ 新增事項格式:{ "kind": "action 或 info", "summary": "具體問題", "evidence": "檔案位置或檢查證據", "targetStage": "plan 或 code" }。
17
+ 處置格式:{ "id": "既有事項 ID", "status": "proposed_resolved、resolved 或 accepted", "reason": "具體處理理由", "evidence": "檔案、commit 或檢查結果" }。撰寫者只能用 proposed_resolved 提出修正;審查者可以用 resolved 或 accepted 結案。重要疑慮必須放在這份檔案,不能只寫在回覆的 <concerns>。
18
+ </handoff>
19
+
9
20
  <requirement>
10
21
  {{requirement}}
11
22
  </requirement>
@@ -46,7 +57,7 @@
46
57
  </output_format>
47
58
 
48
59
  <constraints>
49
- - 只能寫入 .flow/plan-review.json,不可修改規格、計畫或任何程式碼,其他變更都會被還原。
60
+ - 只能寫入 .flow/plan-review.json 與 .flow/handoff-response.json,不可修改規格、計畫或任何程式碼,其他變更都會被還原。
50
61
  </constraints>
51
62
 
52
63
  <reply_format>
package/prompts/plan.md CHANGED
@@ -6,6 +6,17 @@
6
6
  目前的工作目錄就是專案(agentflowctl 為這次任務建立的專用 git worktree)。
7
7
  </context>
8
8
 
9
+ <handoff>
10
+ 先閱讀 .flow/handoff-context.md,處理與本階段有關的待辦事項。完成時寫入 .flow/handoff-response.json;即使沒有事項也必須寫出空陣列:
11
+
12
+ ```json
13
+ { "newIssues": [], "dispositions": [] }
14
+ ```
15
+
16
+ 新增事項格式:{ "kind": "action 或 info", "summary": "具體問題", "evidence": "檔案位置或檢查證據", "targetStage": "plan 或 code" }。
17
+ 處置格式:{ "id": "既有事項 ID", "status": "proposed_resolved、resolved 或 accepted", "reason": "具體處理理由", "evidence": "檔案、commit 或檢查結果" }。撰寫者只能用 proposed_resolved 提出修正;審查者可以用 resolved 或 accepted 結案。重要疑慮必須放在這份檔案,不能只寫在回覆的 <concerns>。
18
+ </handoff>
19
+
9
20
  <inputs>
10
21
  - .flow/spec.md
11
22
  - .flow/acceptance.json
package/prompts/review.md CHANGED
@@ -6,6 +6,17 @@
6
6
  目前的工作目錄就是專案(agentflowctl 為這次任務建立的專用 git worktree)。
7
7
  </context>
8
8
 
9
+ <handoff>
10
+ 先閱讀 .flow/handoff-context.md,處理與本階段有關的待辦事項。完成時寫入 .flow/handoff-response.json;即使沒有事項也必須寫出空陣列:
11
+
12
+ ```json
13
+ { "newIssues": [], "dispositions": [] }
14
+ ```
15
+
16
+ 新增事項格式:{ "kind": "action 或 info", "summary": "具體問題", "evidence": "檔案位置或檢查證據", "targetStage": "plan 或 code" }。
17
+ 處置格式:{ "id": "既有事項 ID", "status": "proposed_resolved、resolved 或 accepted", "reason": "具體處理理由", "evidence": "檔案、commit 或檢查結果" }。撰寫者只能用 proposed_resolved 提出修正;審查者可以用 resolved 或 accepted 結案。重要疑慮必須放在這份檔案,不能只寫在回覆的 <concerns>。
18
+ </handoff>
19
+
9
20
  <inputs>
10
21
  - 規格:.flow/spec.md
11
22
  - 驗收條件:.flow/acceptance.json
@@ -39,7 +50,7 @@
39
50
  </output_format>
40
51
 
41
52
  <constraints>
42
- - 只能寫入 .flow/review.json,不可修改任何程式碼,其他變更都會被捨棄。
53
+ - 只能寫入 .flow/review.json 與 .flow/handoff-response.json,不可修改任何程式碼,其他變更都會被捨棄。
43
54
  </constraints>
44
55
 
45
56
  <reply_format>
package/prompts/spec.md CHANGED
@@ -6,6 +6,17 @@
6
6
  目前的工作目錄就是專案(agentflowctl 為這次任務建立的專用 git worktree)。
7
7
  </context>
8
8
 
9
+ <handoff>
10
+ 先閱讀 .flow/handoff-context.md,處理與本階段有關的待辦事項。完成時寫入 .flow/handoff-response.json;即使沒有事項也必須寫出空陣列:
11
+
12
+ ```json
13
+ { "newIssues": [], "dispositions": [] }
14
+ ```
15
+
16
+ 新增事項格式:{ "kind": "action 或 info", "summary": "具體問題", "evidence": "檔案位置或檢查證據", "targetStage": "plan 或 code" }。
17
+ 處置格式:{ "id": "既有事項 ID", "status": "proposed_resolved、resolved 或 accepted", "reason": "具體處理理由", "evidence": "檔案、commit 或檢查結果" }。撰寫者只能用 proposed_resolved 提出修正;審查者可以用 resolved 或 accepted 結案。重要疑慮必須放在這份檔案,不能只寫在回覆的 <concerns>。
18
+ </handoff>
19
+
9
20
  <requirement>
10
21
  {{requirement}}
11
22
  </requirement>