agentflowctl 0.5.0 → 0.7.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
@@ -99,10 +99,11 @@ agentflowctl run --req-file ./req.md --cycle codex,claude --max-agent-runs 40
99
99
  agentflowctl run --req "..." --manual-plan # 計畫通過 AI 審查後,仍停下來等你確認
100
100
 
101
101
  agentflowctl approve f-xxxx # 搭配 --manual-plan
102
- agentflowctl status f-xxxx # 階段、任務進度、各 agent 用量、代打紀錄
102
+ agentflowctl status f-xxxx # 階段、上一步結果、未結交接事項、下一步指令、任務進度、各 agent 用量、代打紀錄
103
103
  agentflowctl list
104
104
  agentflowctl logs f-xxxx # 列出每一份 log 的編號、結果、階段、步驟、agent
105
105
  agentflowctl logs f-xxxx 7 # 解析第 7 份 log,最後附上錯誤整理(--latest 看最新一份)
106
+ agentflowctl logs f-xxxx 7 --full # 完整顯示多行指令與絕對路徑
106
107
  agentflowctl logs f-xxxx 7 --raw # 原始內容(agent 的 JSON 行)
107
108
  agentflowctl resume f-xxxx # 從暫停、Ctrl-C 或失敗處接續
108
109
  agentflowctl cancel f-xxxx
@@ -182,14 +183,14 @@ agentflowctl clean --all # 清掉所有已結束的 run 與中斷留
182
183
  | 標記 | 內容 |
183
184
  |---|---|
184
185
  | 💬 | agent 的完整文字,不截斷 |
185
- | 🔧 | 工具呼叫與完整參數 |
186
+ | 🔧 | 工具呼叫。只顯示第一行,多行時註明共幾行;去掉 `/bin/zsh -lc '…'` 這類 shell 包裝,worktree 內的絕對路徑改成相對路徑 |
186
187
  | 📊 | token 用量 |
187
- | 🏁 | 最後結果 |
188
+ | 🏁 | 最後結果。與最後一則 💬 相同時不再重印 |
188
189
  | ⚠️ | 工具回報的錯誤。agent 通常會自己換方法繼續,所以不列進錯誤整理 |
189
190
  | ❌ | adapter 不認得的錯誤事件 |
190
191
  | 📄 | 不是 JSON 的輸出行 |
191
192
 
192
- adapter 不認得、也看不出錯誤跡象的 JSON 行不會顯示,只列出行數,要看全部請加 `--raw`。專案指令的 log 本來就是純文字,會原樣顯示。
193
+ 要看完整的工具內容與重複的最後結果,加 `--full`。adapter 不認得、也看不出錯誤跡象的 JSON 行不會顯示,只列出行數,要看全部請加 `--raw`。專案指令的 log 本來就是純文字,會原樣顯示。
193
194
 
194
195
  最後一段「錯誤」整理出結束碼、agent 回報的失敗、錯誤事件與 stderr:
195
196
 
@@ -199,7 +200,7 @@ adapter 不認得、也看不出錯誤跡象的 JSON 行不會顯示,只列出
199
200
  檔案 /repo/.agentflowctl/runs/f-xxxx/logs/003-plan-plan-codex.log
200
201
 
201
202
  💬 先讀 spec.md 與 acceptance.json
202
- 🔧 shell: bash -lc 'cat .flow/spec.md'
203
+ 🔧 shell: cat .flow/spec.md
203
204
  🏁 失敗:stream disconnected before completion
204
205
 
205
206
  ── 錯誤 ──
@@ -211,9 +212,33 @@ stderr:
211
212
 
212
213
  執行成功時,stderr 會放在「其他輸出」段落,不算錯誤。
213
214
 
215
+ ### 停下來時的結果與下一步
216
+
217
+ run 因 Ctrl-C、失敗、額度暫停或等待核准而停下時,終端機會直接印出三段;`agentflowctl status <id>` 也會印同樣的內容:
218
+
219
+ - **結果**:被中斷、還沒有結束紀錄的步驟,以及上一步的結果。agent 的步驟取回覆 `<result>` 的摘要與疑慮;失敗時優先顯示失敗的那一步,並附上結束碼、錯誤事件、stderr 或指令輸出的最後幾行。
220
+ - **未結交接事項**:交接紀錄裡還沒結案的 action 事項(open 或 proposed_resolved)。
221
+ - **下一步**:依狀態列出可以執行的指令,例如 `logs`、`cd` 到 worktree、`resume`、`cancel`、`approve`。
222
+
223
+ ```
224
+ ── 結果 ──
225
+ 中斷於 #20 plan_review / plan-review / codex(沒有結束紀錄,resume 時會重跑這一步)
226
+ #19 plan_fix / plan-fix / claude ✓
227
+ 摘要:接受三條審查意見,拆分 T-3、T-5
228
+ 疑慮:T-15 可能仍太大
229
+
230
+ ── 未結交接事項 ──
231
+ [plan] c9c730b280e87178 T-3、T-5 各混合多個獨立行為(proposed_resolved)
232
+
233
+ ── 下一步 ──
234
+ agentflowctl logs f-xxxx 19 看上一步的完整 log
235
+ agentflowctl resume f-xxxx 從 plan_review 接續
236
+ agentflowctl cancel f-xxxx 放棄這個 run
237
+ ```
238
+
214
239
  ### 出錯時怎麼查
215
240
 
216
- 1. run 停下時印出的摘要,或 `agentflowctl status <id>`,會列出失敗的階段、原因、最後一份 log,以及最近失敗的那一份。
241
+ 1. 先看 run 停下時印出的「結果」與「下一步」,或執行 `agentflowctl status <id>`。
217
242
  2. `agentflowctl logs <id> <編號>` 看那份 log 的錯誤段落。
218
243
  3. 解析結果看不出原因時,加 `--raw` 看原始輸出。
219
244
  4. 必要時直接在 worktree(`.agentflowctl/worktrees/<id>`)裡修正,再執行 `agentflowctl resume <id>`。
@@ -307,12 +332,14 @@ agentflowctl agent cycle claude-strong,codex,gemini # 不帶參數時顯
307
332
  | 有幾家 | 誰來仲裁 | 結果 |
308
333
  | --- | --- | --- |
309
334
  | 三家以上 | 沒參與這次討論的那一家 | 核准就繼續,否則 run 失敗 |
310
- | 兩家 | 兩家各自在全新 context 裡判斷 | 都核准就繼續;都不核准就依裁決意見修訂並重新審查,仲裁達重試上限才停下;分歧依 `tieBreak` |
335
+ | 兩家 | 兩家各自在全新 context 裡判斷 | 都核准就繼續;都不核准就依裁決意見修訂並重新審查;分歧依 `tieBreak` |
311
336
  | 一家 | 同一家 | 由它自己仲裁 |
312
337
 
313
- 兩家時的仲裁是雙盲的。仲裁者只看計畫,以及一份不含模型名稱的爭議清單(`.flow/dispute.md`)。帶有名稱的審查紀錄移到 worktree 以外。`tieBreak` 預設 `proceed`,因為後面還有測試紅燈、綠燈、verify 與程式碼審查。
338
+ 兩家時的仲裁是雙盲的。仲裁者只看計畫,以及一份不含審查者名稱的爭議清單(`.flow/dispute.md`)。爭議清單用 `<issue>` 包住每則意見;給修訂者的 `.flow/feedback.md` 則用 `<opinion author="…">` 包住每位審查者的意見,避免意見內文與外層結構混淆。帶有名稱的審查紀錄移到 worktree 以外。`tieBreak` 預設 `proceed`,因為後面還有測試紅燈、綠燈、verify 與程式碼審查。
314
339
 
315
- 計畫定案或仲裁最終停止時,裁決與每位仲裁者的理由附在 `plan.md` 最後的「仲裁紀錄」。需再修訂時,裁決理由寫進 `.flow/feedback.md`,供修訂者處理;重新審查會從第一輪計數。原始審查與每輪仲裁紀錄在 `.agentflowctl/runs/<id>/reviews/`。仲裁最多進行 `AGENTFLOWCTL_MAX_ATTEMPTS` 次,預設三次。
340
+ 計畫定案或仲裁最終停止時,裁決與每位仲裁者的理由附在 `plan.md` 最後的「仲裁紀錄」。需再修訂時,裁決理由寫進 `.flow/feedback.md`,供修訂者處理;重新審查會從第一輪計數。原始審查與每輪仲裁紀錄在 `.agentflowctl/runs/<id>/reviews/`。兩家都要求修改時不因仲裁輪數而直接失敗;整個 run 仍受 `maxAgentRuns` 限制。
341
+
342
+ 仲裁 JSON 的 `verdict` 應為 `approve` 或 `changes_requested`;若模型寫成 `reject`,程式會當成 `changes_requested` 並保留理由。缺少檔案、JSON 格式錯誤或其他不合法輸出不算反對票,run 會暫停並顯示驗證錯誤;檢查 `agentflowctl logs <id>` 與 `.flow/plan-arbiter.json` 後可用 `resume` 重新執行。
316
343
 
317
344
  ## 階段與通過條件
318
345
 
@@ -322,7 +349,7 @@ agentflowctl agent cycle claude-strong,codex,gemini # 不帶參數時顯
322
349
  | plan | 與 spec 同一位 | zod、相依存在、無循環、每條驗收條件都有任務 | 重試 |
323
350
  | plan_review | 計畫作者以外隨機挑(可多位,不重複) | 所有審查者都 `approve` | 進入 plan_fix |
324
351
  | plan_fix | 依 `fixStrategy` | 修改後仍通過 plan 的格式與 DAG 檢查 | 還原並重試 |
325
- | 仲裁 | 見上一節 | 一致核准;分歧依 `tieBreak` | 兩家都不核准時先進入 plan_fix,達仲裁上限才失敗;第三方不核准或 `tieBreak: stop` 時失敗 |
352
+ | 仲裁 | 見上一節 | 一致核准;分歧依 `tieBreak` | 兩家都不核准時進入 plan_fix 再審查;第三方不核准或 `tieBreak: stop` 時失敗 |
326
353
  | 人工確認 | 你(只有 `--manual-plan`) | `agentflowctl approve` | — |
327
354
  | implement 紅燈 | 洗牌輪流,每家各一次 | 有測試變更,而且測試執行後失敗 | 還原並重試 |
328
355
  | implement 綠燈 | 測試作者以外隨機一位 | 測試檔沒有任何修改,而且測試通過 | 還原,或帶著輸出重試 |
@@ -349,6 +376,14 @@ Agent 的最後回覆要附上 XML 中繼資料:
349
376
 
350
377
  `blocked` 與 `concerns` 會印在終端機上,完整回覆留在 log,可用 `agentflowctl logs` 查看。這份中繼資料只給人看;缺少或格式錯誤都不影響流程,是否通過仍由上表的程式檢查決定。
351
378
 
379
+ ### Agent 交接紀錄
380
+
381
+ 每次 agent 執行前,程式會把與當前階段有關的未結事項寫入 `.flow/handoff-context.md`。agent 完成時必須寫 `.flow/handoff-response.json`,包含 `newIssues` 和 `dispositions` 兩個陣列;沒有事項也要明確寫成 `{ "newIssues": [], "dispositions": [] }`。缺少檔案或 JSON 格式不合法,會依該步驟的重試規則處理。
382
+
383
+ 程式只在原有關卡通過後接收交接回覆,並將正式紀錄原子儲存於 `.agentflowctl/runs/<id>/handoff.json`。`action` 是需要後續處理的事項;`info` 只供參考。作者只能提出已修正並附證據,審查者才能確認結案或附理由接受。XML `<concerns>` 可以供人閱讀,但重要疑慮必須寫進交接 JSON,才能交給下一位 agent。額度代打與重試不會接收失敗呼叫的交接內容;中斷後可用 `resume` 接續。
384
+
385
+ 計畫審查或程式碼審查若核准,但該階段仍有未結的 `action`,程式會視為互相矛盾的審查結果並重試。計畫定案和開 PR 前也會再檢查一次;`info` 會提供給目標階段閱讀,但不阻擋通關。
386
+
352
387
  ## Adapter
353
388
 
354
389
  | adapter | 執行方式 | 權限 |
@@ -401,6 +436,7 @@ src/
401
436
  roles.ts 角色分配規則(含計畫修正者與仲裁者)
402
437
  runner.ts 執行 agent、正規化結果、執行專案指令
403
438
  logs.ts log 檔名、檔頭檔尾、列表與解析
439
+ stopReport.ts run 停下時的結果、未結交接事項與下一步指令
404
440
  agents/ claude、codex、gemini、command
405
441
  setup.ts agent setup 互動精靈
406
442
  git.ts worktree 與 git 操作
@@ -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/cli.js CHANGED
@@ -15,6 +15,8 @@ import { flowDir, logDir, projectRoot, worktreeDir } from "./paths.js";
15
15
  import { TaskList } from "./schemas.js";
16
16
  import { agentRuns, getRun, listRuns, listSubstitutions, saveRun, usageByAgent } from "./store.js";
17
17
  import { readJsonFile } from "./util.js";
18
+ import { openActions, readHandoff } from "./handoff.js";
19
+ import { stopReport } from "./stopReport.js";
18
20
  import { runSetup, SETUP_ADAPTERS } from "./setup.js";
19
21
  import { addAgent, readRawConfig, removeAgent, setAgent, setCycle, writeRawConfig } from "./agentConfig.js";
20
22
  function mustGetRun(id) {
@@ -26,12 +28,13 @@ function mustGetRun(id) {
26
28
  async function drive(run) {
27
29
  // Ctrl-C 會同時送給子程序(claude、測試指令),狀態已經寫在 state.json,之後可用 resume 接續
28
30
  process.once("SIGINT", () => {
29
- console.log(`\n已中斷,之後可用 agentflowctl resume ${run.id} 接續`);
31
+ console.log("\n已中斷");
32
+ printSummary(getRun(run.id) ?? run, true);
30
33
  process.exit(130);
31
34
  });
32
35
  printSummary(await advance(run));
33
36
  }
34
- function printSummary(run) {
37
+ function printSummary(run, interrupted = false) {
35
38
  console.log("");
36
39
  console.log(`run ${run.id}`);
37
40
  console.log(`階段 ${run.stage}`);
@@ -46,22 +49,21 @@ function printSummary(run) {
46
49
  if (run.stage === "failed") {
47
50
  console.log(`失敗於 ${run.failedStage ?? "?"}`);
48
51
  console.log(`原因 ${run.failureReason ?? "?"}`);
49
- const logs = listLogs(logDir(run.id));
50
- const last = logs.at(-1);
51
- const lastFailed = logs.filter((e) => e.footer && !e.footer.ok).at(-1);
52
- if (last)
53
- console.log(`最後的 log #${last.seq} ${logMark(last)}(agentflowctl logs ${run.id} ${last.seq})`);
54
- if (lastFailed && lastFailed !== last)
55
- console.log(`最近失敗的 log #${lastFailed.seq}(agentflowctl logs ${run.id} ${lastFailed.seq})`);
56
- console.log(`\n必要時直接在 worktree 裡修正,再執行 agentflowctl resume ${run.id}`);
57
52
  }
58
53
  if (run.stage === "paused") {
59
54
  console.log(`暫停於 ${run.pausedStage ?? "?"}`);
60
55
  console.log(`原因 ${run.pauseReason ?? "?"}`);
61
- console.log(`\n額度恢復後執行 agentflowctl resume ${run.id}`);
62
56
  }
63
- if (run.stage === "awaiting_approval")
64
- console.log(`\n確認計畫後執行 agentflowctl approve ${run.id}`);
57
+ const report = stopReport({
58
+ run,
59
+ interrupted,
60
+ logs: listLogs(logDir(run.id)),
61
+ read: (file) => readFileSync(file, "utf8"),
62
+ open: openActions(readHandoff(run.id)),
63
+ worktree: worktreeDir(run.id),
64
+ });
65
+ for (const line of report)
66
+ console.log(line);
65
67
  }
66
68
  /** 決定參與的 agent:指令參數 > flow.config.json > 自動偵測已安裝的 CLI */
67
69
  async function resolveCycle(flag) {
@@ -367,6 +369,7 @@ program
367
369
  .command("logs <id> [seq]")
368
370
  .description("列出 log;指定編號(或 --latest)時顯示解析後的內容,最後附上錯誤整理")
369
371
  .option("--latest", "顯示最新一份 log", false)
372
+ .option("--full", "完整顯示工具內容(多行指令、絕對路徑)與重複的最後回覆", false)
370
373
  .option("--raw", "顯示原始內容(agent 的 JSON 行)", false)
371
374
  .action((id, seq, opts) => {
372
375
  mustGetRun(id);
@@ -379,14 +382,14 @@ program
379
382
  const h = e.header;
380
383
  console.log(`${String(e.seq).padStart(3)} ${logMark(e).padEnd(4)} ${(h?.stage ?? "?").padEnd(12)} ${(h?.step ?? "?").padEnd(19)} ${(h?.agent ?? "?").padEnd(10)} ${localTime(h?.startedAt)}`);
381
384
  }
382
- console.log(`\n查看內容:agentflowctl logs ${id} <編號>(加 --raw 看原始 JSON)`);
385
+ console.log(`\n查看內容:agentflowctl logs ${id} <編號>(加 --full 看完整工具內容、--raw 看原始 JSON)`);
383
386
  return;
384
387
  }
385
388
  const entry = seq ? logs.find((e) => e.seq === Number(seq)) : logs.at(-1);
386
389
  if (!entry)
387
390
  throw new Error(`找不到 log #${seq}(共 ${logs.length} 份,可用 agentflowctl logs ${id} 列出)`);
388
391
  const text = readFileSync(entry.file, "utf8");
389
- console.log(opts.raw ? text : renderLog(text, entry.file));
392
+ console.log(opts.raw ? text : renderLog(text, entry.file, { full: opts.full }));
390
393
  });
391
394
  program.parseAsync().catch((err) => {
392
395
  console.error(`錯誤:${err.message}`);
package/dist/engine.js CHANGED
@@ -4,13 +4,15 @@ import { z } from "zod";
4
4
  import { config } from "./config.js";
5
5
  import { arbitrationDecision } from "./arbitration.js";
6
6
  import { detectProjectDefaults, withProjectDefaults } from "./detect.js";
7
+ import { escapeXml, opinion, reviewIssue } from "./feedback.js";
7
8
  import { changedFiles, commitAll, discardChanges, git, headCommit, resetTo } from "./git.js";
9
+ import { acceptHandoff, openActions, prepareHandoff, previewHandoff, readHandoff, recoverHandoff, reviewHandoffGate, validateHandoffResponse } from "./handoff.js";
8
10
  import { flowDir, logDir, projectRoot, runDir, worktreeDir } from "./paths.js";
9
11
  import { CMD_AGENT, nextLogFile } from "./logs.js";
10
12
  import { exec } from "./proc.js";
11
13
  import { arbiterPanel, availableAgent, fixAgent, planAgent, planFixAgent, reviewers, specAgent, taskAgents } from "./roles.js";
12
14
  import { resolveAgent, runAgent, runCommand } from "./runner.js";
13
- import { AcceptanceList, RepoConfig, ReviewResult, TaskList, } from "./schemas.js";
15
+ import { AcceptanceList, ArbiterResult, RepoConfig, ReviewResult, TaskList, } from "./schemas.js";
14
16
  import { addSubstitution, addUsage, agentRuns, saveRun } from "./store.js";
15
17
  import { orderTasks } from "./tasks.js";
16
18
  import { readJsonFile, renderPrompt, tail } from "./util.js";
@@ -30,6 +32,14 @@ export class QuotaPause extends Error {
30
32
  }
31
33
  /** 這次執行中已確認額度用完的 agent;程序結束即清空,resume 時會重新嘗試 */
32
34
  const exhausted = new Set();
35
+ function handoffTarget(run) {
36
+ return ["spec", "plan", "plan_review", "plan_fix"].includes(run.stage) ? "plan" : "code";
37
+ }
38
+ /** 同一輪重跑使用相同 key;重試次數或 panel 位置改變時使用新 key。 */
39
+ function handoffKey(run, step, slot, agent) {
40
+ const attempts = Object.entries(run.attempts).sort(([a], [b]) => a.localeCompare(b));
41
+ return JSON.stringify([run.id, run.stage, step, run.taskIndex, run.taskPhase, attempts, slot, agent]);
42
+ }
33
43
  async function agentStep(run, planned, step, prompt, mode) {
34
44
  const cfg = loadRepoConfig();
35
45
  const reset = mode.reset ?? (() => discardChanges(worktreeDir(run.id)));
@@ -47,11 +57,13 @@ async function agentStep(run, planned, step, prompt, mode) {
47
57
  info(run, `🔁 ${agent} 額度已用完,${step} 由 ${sub} 代打${note ? `(注意:${note})` : ""}`);
48
58
  agent = sub;
49
59
  }
60
+ const callKey = handoffKey(run, step, mode.slot ?? 0, agent);
61
+ prepareHandoff(run.id, callKey, handoffTarget(run), mode.blind ?? false);
50
62
  const r = await runAgent(agent, resolveAgent(cfg, agent), target(run, step, agent), prompt);
51
63
  addUsage(run.id, { stage: step, agent, inputTokens: r.inputTokens, outputTokens: r.outputTokens });
52
64
  if (!r.quotaExhausted) {
53
65
  reportMeta(run, agent, r);
54
- return { r, agent };
66
+ return { r, agent, step, callKey };
55
67
  }
56
68
  info(run, `⛽ ${agent} 的額度已用完`);
57
69
  exhausted.add(agent);
@@ -59,6 +71,28 @@ async function agentStep(run, planned, step, prompt, mode) {
59
71
  // 迴圈回到開頭:review 會停下,write 會找代打
60
72
  }
61
73
  }
74
+ /** 原有關卡已通過後才接受交接;失敗回覆不進入正式紀錄。 */
75
+ function finishHandoff(run, outcome, role, gate) {
76
+ const parsed = validateHandoffResponse(run.id);
77
+ if (!parsed.ok)
78
+ return parsed.error;
79
+ try {
80
+ const source = {
81
+ stage: run.stage, step: outcome.step, agent: outcome.agent, callKey: outcome.callKey,
82
+ };
83
+ const preview = previewHandoff(readHandoff(run.id), outcome.callKey, source, parsed.data, role);
84
+ if (gate) {
85
+ const error = reviewHandoffGate(preview, gate.target, gate.verdict);
86
+ if (error)
87
+ return error;
88
+ }
89
+ acceptHandoff(run.id, outcome.callKey, source, parsed.data, role);
90
+ return undefined;
91
+ }
92
+ catch (error) {
93
+ return error.message;
94
+ }
95
+ }
62
96
  /** 印出回覆裡的 XML 中繼資料;只供人檢視,關卡仍由程式檢查決定 */
63
97
  function reportMeta(run, agent, r) {
64
98
  if (!r.meta)
@@ -113,7 +147,8 @@ function loadOrderedTasks(run) {
113
147
  async function specStage(run) {
114
148
  const agent = specAgent(run.cycle, run.id);
115
149
  info(run, `📝 產生規格(${agent})`);
116
- const { r } = await agentStep(run, agent, "spec", renderPrompt("spec", { requirement: run.requirement }), { kind: "write" });
150
+ const outcome = await agentStep(run, agent, "spec", renderPrompt("spec", { requirement: run.requirement }), { kind: "write" });
151
+ const { r } = outcome;
117
152
  await discardChanges(worktreeDir(run.id)); // 這個階段只允許寫 .flow/
118
153
  if (!r.ok)
119
154
  return retry(run, "spec", `Agent 執行失敗:${r.summary}`, "spec");
@@ -125,6 +160,9 @@ async function specStage(run) {
125
160
  const ids = ac.data.map((a) => a.id);
126
161
  if (new Set(ids).size !== ids.length)
127
162
  return retry(run, "spec", "驗收條件 id 有重複", "spec");
163
+ const handoffError = finishHandoff(run, outcome, "writer");
164
+ if (handoffError)
165
+ return retry(run, "spec", handoffError, "spec");
128
166
  return succeed(run, "spec", "plan");
129
167
  }
130
168
  // ── 規格與計畫檔案:審查者與仲裁者只能讀,不能改 ──
@@ -177,6 +215,9 @@ function announceTasks(run, ordered) {
177
215
  }
178
216
  /** 計畫定案後:預設直接開始實作;--manual-plan 時才停下來等人 */
179
217
  function planSettled(run, key) {
218
+ const pending = openActions(readHandoff(run.id), "plan");
219
+ if (pending.length)
220
+ return retry(run, "plan-handoff", `計畫仍有未結交接事項:${pending.map((item) => item.id).join("、")}`, "plan_fix");
180
221
  const next = run.autopilot ? "implement" : "awaiting_approval";
181
222
  const ordered = loadOrderedTasks(run);
182
223
  announceTasks(run, ordered);
@@ -192,13 +233,17 @@ async function planStage(run) {
192
233
  const agent = planAgent(run.cycle, run.id);
193
234
  info(run, `🗺️ 拆解任務(${agent})`);
194
235
  const cfg = loadRepoConfig();
195
- const { r, agent: actual } = await agentStep(run, agent, "plan", renderPrompt("plan", { testPattern: cfg.testPattern }), { kind: "write" });
236
+ const outcome = await agentStep(run, agent, "plan", renderPrompt("plan", { testPattern: cfg.testPattern }), { kind: "write" });
237
+ const { r, agent: actual } = outcome;
196
238
  await discardChanges(worktreeDir(run.id));
197
239
  if (!r.ok)
198
240
  return retry(run, "plan", `Agent 執行失敗:${r.summary}`, "plan");
199
241
  const ordered = validatePlan(run);
200
242
  if (typeof ordered === "string")
201
243
  return retry(run, "plan", ordered, "plan");
244
+ const handoffError = finishHandoff(run, outcome, "writer");
245
+ if (handoffError)
246
+ return retry(run, "plan", handoffError, "plan");
202
247
  acceptPlan(run, ordered);
203
248
  rmSync(flowFile(run, "plan-review-last.txt"), { force: true });
204
249
  return { ...succeed(run, "plan", "plan_review"), planWriter: actual };
@@ -215,7 +260,8 @@ async function planReviewStage(run) {
215
260
  info(run, `🧐 計畫審查第 ${round} 輪(${reviewer},作者 ${author})`);
216
261
  const snap = snapshotPlan(run);
217
262
  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); } });
263
+ 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); } });
264
+ const { r } = outcome;
219
265
  await discardChanges(worktreeDir(run.id));
220
266
  const tampered = restorePlan(run, snap);
221
267
  if (tampered.length)
@@ -225,6 +271,9 @@ async function planReviewStage(run) {
225
271
  const review = readJsonFile(flowFile(run, "plan-review.json"), ReviewResult);
226
272
  if (!review.ok)
227
273
  return retry(run, "plan-review-run", review.error, "plan_review");
274
+ const handoffError = finishHandoff(run, outcome, "reviewer", { target: "plan", verdict: review.data.verdict });
275
+ if (handoffError)
276
+ return retry(run, "plan-review-run", handoffError, "plan_review");
228
277
  // 審查紀錄移到 worktree 外面:之後的仲裁者看不到是哪一家提的意見
229
278
  mkdirSync(join(runDir(run.id), "reviews"), { recursive: true });
230
279
  renameSync(flowFile(run, "plan-review.json"), join(runDir(run.id), "reviews", `plan-review-${round}-${reviewer}.json`));
@@ -236,9 +285,9 @@ async function planReviewStage(run) {
236
285
  firstObjector ??= reviewer;
237
286
  const lines = review.data.items
238
287
  .filter((i) => i.status !== "met")
239
- .map((i) => `- **${i.criterion}**(${i.status}):${i.note}`);
288
+ .map((i) => reviewIssue(i.criterion, i.status, i.note));
240
289
  issueLines.push(...lines);
241
- issues.push(`### ${reviewer} 的意見\n\n${lines.join("\n")}`);
290
+ issues.push(opinion(reviewer, lines));
242
291
  }
243
292
  if (!firstObjector)
244
293
  return planSettled(run, "plan-review");
@@ -272,7 +321,8 @@ async function planFixStage(run) {
272
321
  info(run, `✏️ 依 ${run.planReviewer ?? "審查者"} 的意見修改計畫(${agent})`);
273
322
  const feedback = readFeedback(run);
274
323
  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); } });
324
+ 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); } });
325
+ const { r, agent: actual } = outcome;
276
326
  await discardChanges(worktreeDir(run.id)); // 只允許改 .flow/
277
327
  if (!r.ok) {
278
328
  restorePlan(run, snap);
@@ -283,6 +333,11 @@ async function planFixStage(run) {
283
333
  restorePlan(run, snap);
284
334
  return retry(run, "plan-fix", `${feedback}\n\n另外,修改後的計畫沒有通過格式檢查,已還原:\n${ordered}`, "plan_fix");
285
335
  }
336
+ const handoffError = finishHandoff(run, outcome, "writer");
337
+ if (handoffError) {
338
+ restorePlan(run, snap);
339
+ return retry(run, "plan-fix", handoffError, "plan_fix");
340
+ }
286
341
  acceptPlan(run, ordered);
287
342
  writeFileSync(flowFile(run, "feedback.md"), feedback); // 保留審查意見,讓下一輪審查者知道上次提了什麼
288
343
  const attempts = { ...run.attempts };
@@ -299,45 +354,54 @@ async function arbitratePlan(run) {
299
354
  const panel = arbiterPanel(run.cycle, `${run.id}:arbiter`, run.planWriter, run.planReviewer);
300
355
  const mode = panel.length > 1 ? "雙盲交叉仲裁" : "第三方仲裁";
301
356
  const verdicts = [];
302
- for (const arbiter of panel) {
357
+ for (const [slot, arbiter] of panel.entries()) {
303
358
  info(run, `⚖️ ${mode}(${arbiter})`);
304
359
  const snap = snapshotPlan(run);
305
360
  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",
361
+ const outcome = await agentStep(run, arbiter, "plan-arbiter", renderPrompt("plan-arbiter", { requirement: run.requirement }), {
362
+ kind: "review", slot, blind: true,
308
363
  reset: async () => { await discardChanges(worktreeDir(run.id)); restorePlan(run, snap); },
309
364
  });
365
+ const { r } = outcome;
310
366
  await discardChanges(worktreeDir(run.id));
311
367
  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`));
368
+ const result = r.ok ? readJsonFile(flowFile(run, "plan-arbiter.json"), ArbiterResult) : undefined;
369
+ const handoffError = result?.ok ? finishHandoff(run, outcome, "reviewer", { target: "plan", verdict: result.data.verdict }) : undefined;
370
+ if (!result?.ok || handoffError) {
371
+ const outputError = !r.ok ? `Agent 執行失敗:${r.summary}` : !result ? "未產生有效裁決" : result.ok ? "未產生有效裁決" : result.error;
372
+ const reason = handoffError ?? outputError;
373
+ info(run, ` ⏸️ ${arbiter} 未產生有效裁決:${reason}`);
374
+ return { ...run, stage: "paused", pausedStage: "plan_review", pauseReason: `${arbiter} 仲裁未產生有效裁決:${reason}` };
316
375
  }
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" ? "未裁決" : "不可執行"}`);
320
- verdicts.push({ arbiter, verdict, notes });
376
+ mkdirSync(join(runDir(run.id), "reviews"), { recursive: true });
377
+ renameSync(flowFile(run, "plan-arbiter.json"), join(runDir(run.id), "reviews", `plan-arbiter-${arbitrationRound}-${arbiter}.json`));
378
+ const verdict = result.data.verdict;
379
+ const notes = result.data.items.map((i) => `<issue criterion="${escapeXml(i.criterion)}">${escapeXml(i.note)}</issue>`);
380
+ const markdownNotes = result.data.items.map((i) => `- ${i.criterion}:${i.note}`);
381
+ info(run, ` ${verdict === "approve" ? "✓" : "✗"} ${arbiter}:${verdict === "approve" ? "可以執行" : "不可執行"}`);
382
+ verdicts.push({ arbiter, verdict, notes, markdownNotes });
321
383
  }
322
384
  rmSync(flowFile(run, "dispute.md"), { force: true });
323
385
  const approvals = verdicts.filter((v) => v.verdict === "approve").length;
324
386
  const unanimous = approvals === verdicts.length;
325
- const decision = arbitrationDecision(verdicts.map((v) => v.verdict), cfg.tieBreak, arbitrationRound, config.maxAttempts);
387
+ const decision = arbitrationDecision(verdicts.map((v) => v.verdict), cfg.tieBreak);
388
+ if (decision === "invalid")
389
+ throw new Error("仲裁結果缺少有效裁決");
326
390
  const summary = unanimous
327
391
  ? `${mode}一致核准`
328
392
  : approvals === 0
329
393
  ? `${mode}沒有任何一方核准${decision === "revise" ? ",交回計畫修訂" : ""}`
330
394
  : `${mode}意見分歧,依 tieBreak=${cfg.tieBreak} ${cfg.tieBreak === "proceed" ? "繼續實作" : "停止"}`;
331
- const record = verdicts.map((v) => `### ${v.arbiter}(${v.verdict})\n\n${v.notes.join("\n")}`).join("\n\n");
395
+ const feedbackRecord = verdicts.map((v) => opinion(v.arbiter, v.notes, v.verdict)).join("\n\n");
332
396
  if (decision === "revise") {
333
397
  const attempts = { ...run.attempts, "plan-arbitration": arbitrationRound };
334
398
  delete attempts["plan-review"];
335
399
  rmSync(flowFile(run, "plan-review-last.txt"), { force: true });
336
- writeFileSync(flowFile(run, "feedback.md"), `# 仲裁要求修訂(第 ${arbitrationRound} 次)\n\n${summary}\n\n${record}\n`);
400
+ writeFileSync(flowFile(run, "feedback.md"), `# 仲裁要求修訂(第 ${arbitrationRound} 次)\n\n${summary}\n\n${feedbackRecord}\n`);
337
401
  info(run, ` → ${summary}`);
338
402
  return { ...run, attempts, stage: "plan_fix" };
339
403
  }
340
- writeFileSync(flowFile(run, "plan.md"), `${readFileSync(flowFile(run, "plan.md"), "utf8")}\n\n## 仲裁紀錄\n\n**結果:${summary}**\n\n${record}\n`);
404
+ writeFileSync(flowFile(run, "plan.md"), `${readFileSync(flowFile(run, "plan.md"), "utf8")}\n\n## 仲裁紀錄\n\n**結果:${summary}**\n\n${verdicts.map((v) => `### ${v.arbiter}(${v.verdict})\n\n${v.markdownNotes.join("\n")}`).join("\n\n")}\n`);
341
405
  if (decision === "proceed") {
342
406
  info(run, ` → ${summary}`);
343
407
  return planSettled(run, "plan-review");
@@ -363,7 +427,8 @@ async function implementStage(run) {
363
427
  const key = `${task.id}:tests`;
364
428
  info(run, `🧪 [${progress}] 撰寫測試(${agents.tests})`);
365
429
  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) });
430
+ 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) });
431
+ const { r, agent: testsAuthor } = outcome;
367
432
  if (!r.ok) {
368
433
  await resetTo(repo, before);
369
434
  return retry(run, key, `Agent 執行失敗:${r.summary}`, "implement");
@@ -381,6 +446,11 @@ async function implementStage(run) {
381
446
  await resetTo(repo, before);
382
447
  return retry(run, key, "測試在功能尚未實作前就全部通過,代表測試沒有驗證到新行為。請撰寫會因功能尚未實作而失敗的測試。", "implement");
383
448
  }
449
+ const handoffError = finishHandoff(run, outcome, "writer");
450
+ if (handoffError) {
451
+ await resetTo(repo, before);
452
+ return retry(run, key, handoffError, "implement");
453
+ }
384
454
  writeFileSync(flowFile(run, "red-output.txt"), red.output);
385
455
  info(run, `🔴 [${progress}] 測試如預期失敗`);
386
456
  return { ...succeed(run, key, "implement"), taskPhase: "code", testsCommit: commit, lastTestsAuthor: testsAuthor };
@@ -392,7 +462,8 @@ async function implementStage(run) {
392
462
  throw new Error("缺少 testsCommit,狀態不一致");
393
463
  info(run, `🛠️ [${progress}] 實作(${agents.code},測試由 ${run.lastTestsAuthor ?? agents.tests} 撰寫)`);
394
464
  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) });
465
+ 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) });
466
+ const { r, agent: codeAuthor } = outcome;
396
467
  if (!r.ok)
397
468
  return retry(run, key, `Agent 執行失敗:${r.summary}`, "implement");
398
469
  await commitAll(repo, `feat(${task.id}): ${task.title} [${codeAuthor}]`);
@@ -406,6 +477,11 @@ async function implementStage(run) {
406
477
  info(run, ` ✗ 測試仍未通過${logHint(run, green.seq)}`);
407
478
  return retry(run, key, `測試仍未通過:\n\n\`\`\`\n${tail(green.output)}\n\`\`\``, "implement");
408
479
  }
480
+ const handoffError = finishHandoff(run, outcome, "writer");
481
+ if (handoffError) {
482
+ await resetTo(repo, testsCommit);
483
+ return retry(run, key, handoffError, "implement");
484
+ }
409
485
  info(run, `🟢 [${progress}] 完成`);
410
486
  return {
411
487
  ...succeed(run, key, "implement"),
@@ -454,10 +530,11 @@ async function fixStage(run) {
454
530
  const repo = worktreeDir(run.id);
455
531
  const feedback = readFeedback(run);
456
532
  const before = await headCommit(repo);
457
- const { r, agent: actual } = await agentStep(run, agent, "fix", renderPrompt("fix", { testPattern: cfg.testPattern }), {
533
+ const outcome = await agentStep(run, agent, "fix", renderPrompt("fix", { testPattern: cfg.testPattern }), {
458
534
  kind: "write",
459
535
  reset: () => resetTo(repo, before),
460
536
  });
537
+ const { r, agent: actual } = outcome;
461
538
  if (!r.ok)
462
539
  return retry(run, "fix", `${feedback}\n\n(上次修正時 Agent 執行失敗:${r.summary})`, "fix");
463
540
  await commitAll(repo, `fix: ${why} [${actual}]`);
@@ -467,6 +544,11 @@ async function fixStage(run) {
467
544
  await resetTo(repo, before);
468
545
  return retry(run, "fix", `${feedback}\n\n另外:不可刪除測試檔來讓檢查通過,已還原:${deleted.join(", ")}`, "fix");
469
546
  }
547
+ const handoffError = finishHandoff(run, outcome, "writer");
548
+ if (handoffError) {
549
+ await resetTo(repo, before);
550
+ return retry(run, "fix", handoffError, "fix");
551
+ }
470
552
  // 修正者成為新的作者,下一輪審查會換成別人
471
553
  return { ...to(run, "verify"), lastWriter: actual };
472
554
  }
@@ -479,18 +561,22 @@ async function reviewStage(run) {
479
561
  .map((s) => s.slice(1, -1));
480
562
  const issues = [];
481
563
  let firstObjector;
482
- for (const reviewer of panel) {
564
+ for (const [slot, reviewer] of panel.entries()) {
483
565
  info(run, `👀 程式碼審查(${reviewer})`);
484
566
  rmSync(flowFile(run, "review.json"), { force: true });
485
- const { r } = await agentStep(run, reviewer, "review", renderPrompt("review", { reviewer, authors: authors.join("、") || "未知" }), {
486
- kind: "review",
567
+ const outcome = await agentStep(run, reviewer, "review", renderPrompt("review", { reviewer, authors: authors.join("、") || "未知" }), {
568
+ kind: "review", slot,
487
569
  });
570
+ const { r } = outcome;
488
571
  await discardChanges(repo); // 審查者不可改程式碼
489
572
  if (!r.ok)
490
573
  return retry(run, "review-run", `Agent 執行失敗:${r.summary}`, "review");
491
574
  const review = readJsonFile(flowFile(run, "review.json"), ReviewResult);
492
575
  if (!review.ok)
493
576
  return retry(run, "review-run", review.error, "review");
577
+ const handoffError = finishHandoff(run, outcome, "reviewer", { target: "code", verdict: review.data.verdict });
578
+ if (handoffError)
579
+ return retry(run, "review-run", handoffError, "review");
494
580
  renameSync(flowFile(run, "review.json"), flowFile(run, `review-${reviewer}.json`));
495
581
  if (review.data.verdict === "approve") {
496
582
  info(run, ` ✓ ${reviewer} 核准`);
@@ -498,11 +584,9 @@ async function reviewStage(run) {
498
584
  }
499
585
  info(run, ` ✗ ${reviewer} 要求修改`);
500
586
  firstObjector ??= reviewer;
501
- issues.push(`### ${reviewer} 的意見\n\n` +
502
- review.data.items
503
- .filter((i) => i.status !== "met")
504
- .map((i) => `- **${i.criterion}**(${i.status}):${i.note}`)
505
- .join("\n"));
587
+ issues.push(opinion(reviewer, review.data.items
588
+ .filter((i) => i.status !== "met")
589
+ .map((i) => reviewIssue(i.criterion, i.status, i.note))));
506
590
  }
507
591
  if (!firstObjector)
508
592
  return succeed(run, "review", "pr");
@@ -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,15 @@
1
+ /** agent 提供的文字必須跳脫,才不會打斷意見的 XML 邊界。 */
2
+ export function escapeXml(value) {
3
+ return value.replace(/[&<>"']/g, (char) => ({
4
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;",
5
+ })[char]);
6
+ }
7
+ export function reviewIssue(criterion, status, note) {
8
+ return `<issue criterion="${escapeXml(criterion)}" status="${escapeXml(status)}">${escapeXml(note)}</issue>`;
9
+ }
10
+ export function opinion(author, issues, verdict) {
11
+ const name = escapeXml(author);
12
+ const outcome = verdict ? ` verdict="${escapeXml(verdict)}"` : "";
13
+ return `<opinion author="${name}"${outcome}>\n${issues.join("\n")}\n</opinion>`;
14
+ }
15
+ //# sourceMappingURL=feedback.js.map