agentflowctl 0.15.1 → 0.17.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
@@ -1,7 +1,6 @@
1
1
  # agentflowctl
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/agentflowctl.svg)](https://www.npmjs.com/package/agentflowctl)
4
- [![GitHub release](https://img.shields.io/github/v/release/gogogohuang/agentflowctl)](https://github.com/gogogohuang/agentflowctl/releases)
5
4
 
6
5
  讓 Claude Code、Codex、Gemini CLI 等 agent 在同一個專案裡分工:整理需求、規劃、寫測試與程式、交叉審查,最後建立 PR。agentflowctl 負責推進流程,並用檔案、測試和檢查結果決定能否進到下一步。
7
6
 
@@ -40,11 +39,13 @@ npx agentflowctl run --req-file ./requirement.md
40
39
 
41
40
  流程預設會自動往下走。想在計畫通過審查後親自確認,可加 `--manual-plan`;確認後執行 `agentflowctl approve <id>`。
42
41
 
42
+ 需要先取用某個階段的產出時,可用 `--stop-after <階段>`。可選停點是 `spec`(規格)、`plan`(計畫審查完成)、`implement`(所有任務完成)、`verify`(測試與 checks 通過)、`review`(程式碼審查完成)或 `pr`(PR 流程完成)。除了 `pr` 會照常結束外,其他停點完成後會進入 `paused`,可檢視 worktree 與 `.flow/` 檔案,再執行 `agentflowctl resume <id>` 從下一階段接續;`--manual-plan` 與 `--stop-after` 不能同時使用。
43
+
43
44
  agentflowctl 會依專案的 `packageManager`、lockfile 與 `package.json` scripts 選擇安裝、測試及檢查指令。第一次執行時,請留意終端機印出的偵測結果;需要調整可在 `flow.config.json` 指定 `install`、`test` 或 `checks`。`package.json` 的依賴或 `test` script 看不出測試框架(且沒有手動設定 `test`)時,終端機會提示「未偵測到測試框架」,並略過紅綠燈;要改回來,在 `flow.config.json` 設定 `test`。
44
45
 
45
46
  ## 查看進度
46
47
 
47
- `run` 開始時會印出 run id,例如 `f-xxxx`。執行中預設只顯示階段進度;加 `-v` 可看到 agent 文字、工具呼叫與專案指令。
48
+ `run` 開始時會印出 run id,例如 `f-xxxx`。執行中預設只顯示階段進度;加 `-v`(或在 `flow.config.json` 設 `"verbose": true`)可看到 agent 文字、工具呼叫與專案指令。
48
49
 
49
50
  ```bash
50
51
  agentflowctl list # 列出 run
@@ -73,6 +74,7 @@ agentflowctl resume f-xxxx # 從暫停、中斷或失敗處接續
73
74
  | 按 Ctrl-C,或終端機意外關閉 | 執行 `agentflowctl resume <id>`;沒有結束紀錄的步驟會重跑 |
74
75
  | `awaiting_approval`:計畫等你確認 | 閱讀 `.agentflowctl/worktrees/<id>/.flow/plan.md`,確認後執行 `agentflowctl approve <id>` |
75
76
  | `paused`:agent 額度用完 | 等額度恢復後執行 `agentflowctl resume <id>`;審查步驟不會換 agent 代審。在仲裁途中暫停時,resume 直接回到仲裁,不重跑計畫審查;暫停期間若改了計畫檔,或在 `flow.config.json` 把 `planArbiter` 關掉,就改成重新審查 |
77
+ | `paused`:已完成指定停點 | 依 `status` 顯示的下一階段檢視產出,再執行 `agentflowctl resume <id>` 接續;run 會保留原本的停點設定 |
76
78
  | `failed`:仲裁連續沒有產生有效裁決 | 仲裁者沒寫出 `.flow/plan-arbiter.json`、格式錯誤或交接無效時不會暫停,會把原因寫進 `.flow/feedback.md` 並自動重跑仲裁(不重跑計畫審查);無效的檔案移到 `.agentflowctl/runs/<id>/reviews/plan-arbiter-<輪>-<agent>-invalid.json`。連續達重試上限才失敗,查看 log 後執行 `agentflowctl resume <id>` 會再回到仲裁 |
77
79
  | `failed`:測試、檢查、審查或 agent 執行失敗 | 依 `status` 提示查看失敗的 log,處理原因後執行 `agentflowctl resume <id>`;失敗階段會重試 |
78
80
  | `failed`:已達 agent 執行次數上限 | 用 `agentflowctl resume <id> --max-agent-runs 100` 調高上限後接續,數字須大於已執行次數 |
@@ -97,10 +99,11 @@ agentflowctl resume f-xxxx
97
99
  | --- | --- |
98
100
  | `run --req "..."` / `--req-file <檔案>` | 二選一,直接輸入需求或讀取檔案 |
99
101
  | `run --manual-plan` | 計畫通過審查後等待你確認,再用 `approve <id>` 繼續 |
102
+ | `run --stop-after <階段>` | 在 `spec`、`plan`、`implement`、`verify` 或 `review` 完成後暫停;`pr` 會完成 PR 流程並結束。與 `--manual-plan` 互斥 |
100
103
  | `run --cycle <名單>` | 指定這次參與的 agent,例如 `--cycle claude,codex`;優先於設定檔的 `cycle` |
101
104
  | `run --model-mode balanced\|adaptive` | 只覆蓋這次 run 的模型模式;`resume` 沿用建立時的模式 |
102
105
  | `run --base <分支>` | 指定起始分支;未設定時使用目前分支 |
103
- | `run --max-attempts <次數>` | 覆蓋這次的重試上限(至少 3;預設取 `AGENTFLOWCTL_MAX_ATTEMPTS`,未設定為 5);失敗後可用 `resume <id> --max-attempts <次數>` 調高 |
106
+ | `run --max-attempts <次數>` | 覆蓋這次的重試上限(至少 3;預設取 `flow.config.json` 的 `maxAttempts`,未設定為 5);失敗後可用 `resume <id> --max-attempts <次數>` 調高 |
104
107
  | `run --max-agent-runs <次數>` | 覆蓋這次的 `maxAgentRuns`;上限不夠時可用 `resume <id> --max-agent-runs <次數>` 調高 |
105
108
  | `-v` / `--verbose` | 執行時顯示 agent 文字、工具呼叫與專案指令,適用於 `run`、`resume`、`approve` |
106
109
 
@@ -108,6 +111,7 @@ agentflowctl resume f-xxxx
108
111
 
109
112
  ```bash
110
113
  agentflowctl run --req-file ./requirement.md --cycle claude,codex --max-agent-runs 80 --manual-plan
114
+ agentflowctl run --req-file ./requirement.md --stop-after plan
111
115
  agentflowctl resume f-xxxx --max-agent-runs 100
112
116
  agentflowctl resume f-xxxx --max-attempts 8
113
117
  ```
@@ -126,7 +130,7 @@ agentflowctl agent cycle claude,codex
126
130
 
127
131
  `agent add` 的 `--adapter` 可填 `claude`、`codex`、`gemini` 或 `command`。`--model` 指定個別 agent 的模型;`--extra-arg=--參數` 可重複使用,傳給該 CLI。使用 `command` adapter 時,把指令寫在 `--` 後,例如 `agentflowctl agent add aider --adapter command -- aider --message {prompt}`。`agent remove <名稱>` 會移除設定與參與名單;`agent cycle` 不帶名單則顯示目前參與者。
128
132
 
129
- `model add/set/remove` 只修改指定 agent 的模型清單;`model remove` 移除最後一個模型時,會檢查參與的 agent 是否仍有模型,不論目前使用哪種模型模式。同一 adapter 的 `agent set` 會保留清單;換 adapter 時會清掉舊 adapter 的模型設定。`agent setup` 遇到同名 agent 會先詢問是否覆寫。
133
+ `model add/set/remove` 只修改指定 agent 的模型清單;`model remove` 移除最後一個模型時,會檢查參與的 agent 是否仍有模型,不論目前使用哪種模型模式。同一 adapter 的 `agent set` 會保留清單;換 adapter 時會清掉舊 adapter 的模型設定。`agent setup` 遇到同名 agent 會先詢問是否覆寫。目前是 adaptive 模式、但參與的 agent 沒有 `models` 時,`agent setup` 會提醒並詢問是否改回 balanced;`run` 遇到同樣情況會列出所有缺 `models` 的 agent,並附上 `model add` 與 `model mode balanced` 兩種修法。
130
134
 
131
135
  ### 依階段與任務難度選模型
132
136
 
@@ -194,29 +198,52 @@ Codex 另有幾點差異:
194
198
  | `tddSplit` | `true` | 有多位 agent 時,`true` 會把同一任務的測試與實作分給不同 agent |
195
199
  | `reviewQuorum` | `1` | 任務與最終程式碼審查需要幾位不同審查者核准 |
196
200
  | `planReviewQuorum` | `1` | 計畫需要幾位不同審查者核准 |
201
+ | `reviewConcurrency` | 不限 | 同一輪審查最多幾位審查者同時執行;`1` 為一次一位;說明見表格下方,`doctor` 會顯示目前的設定 |
197
202
  | `planArbiter` | `true` | 計畫審查僵持,或修訂一次後仍被要求修改時是否啟用仲裁 |
198
203
  | `planReviewLayers` | `{ "enabled": true, "minTasks": 7, "maxGroups": 5, "tasksPerGroup": 3 }` | 任務夠多時把計畫審查拆成索引與任務群;說明見表格下方 |
199
204
  | `tieBreak` | `"proceed"` | 兩位仲裁者意見分歧時,`"proceed"` 繼續、`"stop"` 停止 |
200
205
  | `maxAgentRuns` | `60` | 一次 run 最多執行幾次 agent;可用指令選項覆蓋 |
206
+ | `maxAttempts` | `5` | 同一關連續失敗幾次後停止,至少 3;可用 `run`/`resume` 的 `--max-attempts` 覆蓋 |
207
+ | `verbose` | `false` | 顯示 agent 文字、工具呼叫與專案指令,效果同 `-v` |
201
208
  | `install`、`test` | 依專案偵測 | 寫成指令字串,例如 `"install": "pnpm install"` |
202
209
  | `checks` | 依專案偵測 | 檢查清單,例如 `[{ "name": "test", "cmd": "pnpm test" }]`;提供時會取代整份預設清單 |
203
210
  | `testPattern` | 常見的 `.test.`、`.spec.` 檔名 | 辨識測試檔的正規表示式字串;非標準檔名時調整 |
204
211
 
205
212
  計畫審查會依任務規模選做法。同時符合下列條件時,每輪先做一次索引審查,再只審查有變動的任務群:任務達到 `planReviewLayers.minTasks` 個;依 description 寫的檔案路徑能分成至少兩群,而且最大一群不超過三分之二;`plan.md` 每個任務都有 `## T-<數字>` 標題。索引審查讀規格、全部任務描述、驗收條件與整體做法,人數是 `planReviewQuorum`。群數最多 `maxGroups`,也不超過任務數除以 `tasksPerGroup`;每群一位審查者,含 `high` 任務的群改由 `planReviewQuorum` 位審查。改了 `plan.md` 的整體做法時所有群都重審;某一次審查失敗時只重跑還沒完成的部分。已達門檻卻不符其他條件時,終端機會印出原因並改由審查者讀完整份規格與計畫。`"planReviewLayers": { "enabled": false }` 可以關閉,`doctor` 會顯示目前的設定。
206
213
 
207
- 審查意見的處理寫在 `.flow/plan-replies.md`,每輪覆寫,不寫進 `plan.md` 文末。下一輪索引會看到整份回應;任務群只看到自己的 `## T-<數字>` 節。
214
+ #### 平行審查
208
215
 
209
- `install`、`test`、`checks` 未設定時,會依 `packageManager`、lockfile 和 `package.json` scripts 偵測。完整範例見 [examples/flow.config.json](examples/flow.config.json)。專案設定每一步都會重新讀取,但已建立 run 的參與 agent 與執行次數上限會沿用建立時的值;要調高後者請用 `resume --max-agent-runs`。
216
+ 同一輪的審查者(整份計畫審查、分層計畫審查的索引與各群、程式碼審查)預設同時執行,可用 `reviewConcurrency` 限制同時數量(`1` 為一次一位)。
210
217
 
211
- ### 環境變數
218
+ **審查者的環境**
212
219
 
213
- | 變數 | 預設 | 設定方式與用途 |
214
- | --- | --- | --- |
215
- | `AGENTFLOWCTL_MAX_ATTEMPTS` | `5` | 同一關連續失敗幾次後停止,至少 3(設得更小以 3 計);例如 `AGENTFLOWCTL_MAX_ATTEMPTS=10 agentflowctl run --req "..."` |
216
- | `AGENTFLOWCTL_VERBOSE` | 未開啟 | 設為 `1` 顯示詳細輸出,效果同 `-v` |
217
- | `AGENTFLOWCTL_MAX_TURNS` | `200` | 目前程式會讀取此值,但尚未用它限制 agent 執行 |
220
+ - 每位審查者在自己的臨時 git worktree 裡工作,固定放在 `.agentflowctl/runs/<id>/tmp-review/slot-<N>/`;只有該路徑被占用(例如上次中斷的殘骸)時才改用唯一的子目錄。
221
+ - 看到的是 run 目前的 HEAD、`.flow/` 的複本,以及指向 run worktree 頂層 `node_modules` 的 symlink(有才建);不含其他未 commit 或被 gitignore 的檔案(例如 `dist/`)。所以審查者改了什麼都不會影響 run 的 worktree 或其他審查者。
222
+ - 唯一的例外是 `node_modules`:它是共用的 symlink,審查者若在臨時 worktree 裡跑安裝,會寫到 run 真正的 `node_modules`。
223
+ - 程式碼審查開始前,會先把 run 的 worktree 還原成 HEAD(清掉驗證階段留下的未 commit 修改與未追蹤產物)。
224
+
225
+ **結果如何套用**
226
+
227
+ - 審查結果一完成就存進 `.agentflowctl/runs/<id>/parallel-review/`,全部審查者跑完後才依固定順序逐一套用結果與交接事項。
228
+ - 因此即使 `reviewConcurrency` 為 `1`,同一輪的審查者也看不到彼此本輪新增或結掉的交接事項,核准與否也依它開始時看到的帳本判斷。例如一位審查者結掉了某個未結事項,另一位核准卻沒有結掉它,後者會被判交接不合格而多重跑一次(重跑時就看得到該事項已結)。
229
+ - 同時執行時,引擎印出的訊息會加上 `[審查者]` 前綴;agent 自己的輸出(`-v` 時印出的內容、回報的疑慮)不加。
230
+
231
+ **中斷與 resume**
232
+
233
+ - 任何時候 Ctrl-C 或被中斷,之後 `resume` 只會補跑沒完成的審查者,已完成的不重跑。
234
+ - 已存檔的審查只在同一輪、計畫或程式碼沒變,而且交接帳本沒有被這一輪以外的呼叫(例如修正者)動過時沿用,否則重審。
235
+ - 中斷留下的臨時 worktree(`.agentflowctl/runs/<id>/tmp-review/`)在該 run 下次 `resume`(或 `clean`)時自動清掉。
236
+
237
+ **額度與預算**
238
+
239
+ - 若有審查者的額度用完,其他審查者仍會跑完並存檔,全部結束後才暫停;但同一家 agent 還沒啟動的審查者也會略過(這家在這次執行中已確認額度用完),`resume` 時再和額度用完的那位一起補跑。
240
+ - 審查只在 `maxAgentRuns` 剩餘的次數內啟動:預算不夠整輪時只啟動預算內的審查者(結果照樣存檔),run 以 `agent_budget` 失敗,用 `resume <id> --max-agent-runs <次數>` 調高後只補跑沒跑的。
241
+
242
+ 審查意見的處理寫在 `.flow/plan-replies.md`,每輪覆寫,不寫進 `plan.md` 文末。下一輪索引會看到整份回應;任務群只看到自己的 `## T-<數字>` 節。
243
+
244
+ `install`、`test`、`checks` 未設定時,會依 `packageManager`、lockfile 和 `package.json` scripts 偵測。完整範例見 [examples/flow.config.json](examples/flow.config.json)。專案設定每一步都會重新讀取,但已建立 run 的參與 agent 與執行次數上限會沿用建立時的值;要調高後者請用 `resume --max-agent-runs`。
218
245
 
219
- 環境變數對新啟動的 agentflowctl 程序生效。`AGENTFLOWCTL_MAX_ATTEMPTS` 是單一關卡的重試上限(至少 3),單一 run 可用 `run`/`resume` 的 `--max-attempts` 覆蓋;計畫審查何時交付仲裁與它無關:意見沒有變化,或第 2 輪(修訂過一次)仍被要求修改時就交付,兩家 agent 時自動進入雙盲交叉仲裁,有第三方時由第三方單獨仲裁;`maxAgentRuns` 則是整次 run 的 agent 執行次數上限。修正成功、或計畫審查與程式碼審查整組完成一輪有效審查後,該關的失敗次數會歸零,所以上限只計算連續失敗。分層計畫審查時,同一輪裡只要有一次審查呼叫真的執行成功,計畫審查的失敗次數也會歸零;所以索引與各群輪流各失敗一次、每次重跑都有進展時,不會因累計達上限而失敗。
246
+ agentflowctl 不讀取任何 `AGENTFLOWCTL_*` 環境變數,設定都寫在 `flow.config.json`。`maxAttempts`(預設 5、至少 3;設得更小會直接報設定錯誤)是單一關卡的重試上限(至少 3),單一 run 可用 `run`/`resume` 的 `--max-attempts` 覆蓋;計畫審查何時交付仲裁與它無關:意見沒有變化,或第 2 輪(修訂過一次)仍被要求修改時就交付,兩家 agent 時自動進入雙盲交叉仲裁,有第三方時由第三方單獨仲裁;`maxAgentRuns` 則是整次 run 的 agent 執行次數上限。修正成功、或計畫審查與程式碼審查整組完成一輪有效審查後,該關的失敗次數會歸零,所以上限只計算連續失敗。分層計畫審查時,同一輪裡只要有一次審查呼叫真的執行成功,計畫審查的失敗次數也會歸零;所以索引與各群輪流各失敗一次、每次重跑都有進展時,不會因累計達上限而失敗。
220
247
 
221
248
  ## 發版(維護者)
222
249
 
@@ -228,7 +255,7 @@ pnpm release minor # 確認後建立 v0.x+1.0 的 GitHub Release
228
255
  pnpm release 1.0.0 # 指定版本,必須大於目前最新的 tag
229
256
  ```
230
257
 
231
- 腳本會先檢查目前在 `main`、工作區乾淨、與 `origin/main` 同步、`gh` 已登入、新 tag 不存在,再跑 typecheck、test、build,列出自上個 tag 以來的 commit 並等你輸入 `y` 確認,然後用 `gh release create --generate-notes` 建立 Release。npm 由 Release 觸發的 `npm-publish.yml` 發布;版本號取自 tag,腳本不會改 `package.json`。
258
+ 腳本會先檢查目前在 `main`、工作區乾淨、與 `origin/main` 同步、`gh` 已登入、新 tag 不存在,再跑 typecheck、test、build(通過只顯示 ✓,失敗才印出完整輸出),列出自上個 tag 以來的 commit 並等你輸入 `y` 確認,然後用 `gh release create --generate-notes` 建立 Release。npm 由 Release 觸發的 `npm-publish.yml` 發布;版本號取自 tag,腳本不會改 `package.json`。
232
259
 
233
260
  ## 更多文件
234
261
 
@@ -1,4 +1,4 @@
1
- import { mkdirSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { num, str, toolDetail, tryJson } from "./types.js";
4
4
  /**
@@ -19,7 +19,13 @@ function settingsFile(o) {
19
19
  };
20
20
  mkdirSync(o.runDir, { recursive: true });
21
21
  const path = join(o.runDir, "claude-settings.json");
22
- writeFileSync(path, JSON.stringify(settings, null, 2));
22
+ const content = JSON.stringify(settings, null, 2);
23
+ // 平行審查時多個 claude 同時啟動:內容沒變就不寫;要寫時先寫暫存檔再 rename,別的行程不會讀到被截斷的檔案而在沒有 deny list 下執行
24
+ if (existsSync(path) && readFileSync(path, "utf8") === content)
25
+ return path;
26
+ const tmp = `${path}.${process.pid}.${Date.now()}.tmp`;
27
+ writeFileSync(tmp, content);
28
+ renameSync(tmp, path);
23
29
  return path;
24
30
  }
25
31
  export const claude = {
package/dist/cleanup.js CHANGED
@@ -2,6 +2,7 @@ import { existsSync, readdirSync, rmSync } from "node:fs";
2
2
  import { git, removeWorktree } from "./git.js";
3
3
  import { projectRoot, runDir, runsDir, worktreeDir, worktreesDir } from "./paths.js";
4
4
  import { getRun } from "./store.js";
5
+ import { cleanupTempWorktrees } from "./tempWorktree.js";
5
6
  /**
6
7
  * 移除一個 run 的 worktree 與紀錄,分支保留。
7
8
  * 不需要 state.json:中斷在建立 worktree 之後、寫入紀錄之前留下的孤兒也能清。
@@ -14,6 +15,8 @@ export async function cleanRun(id) {
14
15
  const root = projectRoot();
15
16
  const wt = worktreeDir(id);
16
17
  const found = existsSync(wt) || existsSync(runDir(id));
18
+ // 平行審查中斷留下的臨時 worktree:locked 的登記 prune 不會清,要先處理;tmp-review/ 已不在時也要查
19
+ await cleanupTempWorktrees(id, { force: true });
17
20
  if (existsSync(wt)) {
18
21
  // git 不認得這個資料夾時(登記已被 prune、或 worktree add 做到一半)改成直接刪
19
22
  await removeWorktree(root, wt).catch(() => rmSync(wt, { recursive: true, force: true }));
package/dist/cli.js CHANGED
@@ -12,7 +12,7 @@ import { cleanableRuns, cleanRun } from "./cleanup.js";
12
12
  import { describeDetected, detectProjectDefaults } from "./detect.js";
13
13
  import { CMD_AGENT, listLogs, localTime, logMark, nextLogFile, renderLog } from "./logs.js";
14
14
  import { flowDir, logDir, projectRoot, worktreeDir } from "./paths.js";
15
- import { ModelStage, ModelStrength, TaskList } from "./schemas.js";
15
+ import { ModelStage, ModelStrength, StopAfterStage, TaskList } from "./schemas.js";
16
16
  import { computeInsights, failureLabel, retryLabel } from "./insights.js";
17
17
  import { computeUsageInsights } from "./usageInsights.js";
18
18
  import { computeStats, formatDuration } from "./stats.js";
@@ -41,6 +41,14 @@ function positiveInt(text, flag, min = 1) {
41
41
  throw new Error(`${flag} 必須是不小於 ${min} 的整數:${text}`);
42
42
  return n;
43
43
  }
44
+ function parseStopAfter(value) {
45
+ if (value === undefined)
46
+ return undefined;
47
+ const result = StopAfterStage.safeParse(value);
48
+ if (!result.success)
49
+ throw new Error(`未知的流程停點:${value}(可用:spec、plan、implement、verify、review、pr)`);
50
+ return result.data;
51
+ }
44
52
  function mustGetRun(id) {
45
53
  const run = getRun(id);
46
54
  if (!run)
@@ -60,6 +68,8 @@ function printSummary(run, interrupted = false) {
60
68
  console.log("");
61
69
  console.log(`run ${run.id}`);
62
70
  console.log(`階段 ${run.stage}`);
71
+ if (run.stopAfter)
72
+ console.log(`停點 ${run.stopAfter}`);
63
73
  console.log(`用量 agent 執行 ${agentRuns(run.id)} / ${run.maxAgentRuns} 次`);
64
74
  console.log(`agent ${run.cycle.join("、")}${run.lastWriter ? `(最後作者:${run.lastWriter})` : ""}`);
65
75
  console.log(`分支 ${run.branch}`);
@@ -115,10 +125,17 @@ const TASK_PHASE_MARK = { tests: "🧪", code: "🛠️ ", review: "👀", verif
115
125
  const program = new Command()
116
126
  .name("agentflowctl")
117
127
  .description("在專案資料夾內執行的 Agent 開發流程:規格 → 計畫 → TDD 實作 → 驗證 → 審查 → PR")
118
- .option("-v, --verbose", "執行時印出 agent 的文字、工具呼叫與專案指令(預設只印階段進度)")
128
+ .option("-v, --verbose", "執行時印出 agent 的文字、工具呼叫與專案指令(預設只印階段進度;也可在 flow.config.json 設 verbose)")
119
129
  .hook("preAction", (cmd) => {
120
130
  if (cmd.opts().verbose)
121
131
  config.verbose = true;
132
+ else {
133
+ // doctor、agent setup 等可能在沒有專案或設定壞掉時執行;這裡讀不到就維持安靜,指令本身會回報設定錯誤
134
+ try {
135
+ config.verbose = loadRepoConfig().verbose;
136
+ }
137
+ catch { /* 維持預設 */ }
138
+ }
122
139
  });
123
140
  program
124
141
  .command("run")
@@ -127,14 +144,18 @@ program
127
144
  .option("--req-file <file>", "從檔案讀取需求")
128
145
  .option("--base <branch>", "基底分支(預設為目前的分支)")
129
146
  .option("--max-agent-runs <n>", "單一 run 最多執行幾次 agent(預設取 flow.config.json 的 maxAgentRuns)")
130
- .option("--max-attempts <n>", "同一關連續失敗幾次後停止(預設取 AGENTFLOWCTL_MAX_ATTEMPTS,未設定為 5)")
147
+ .option("--max-attempts <n>", "同一關連續失敗幾次後停止(預設取 flow.config.json 的 maxAttempts,未設定為 5)")
131
148
  .option("--manual-plan", "計畫通過 AI 審查後,仍停下來等你確認", false)
149
+ .option("--stop-after <stage>", "完成公開階段後暫停:spec、plan、implement、verify、review 或 pr")
132
150
  .option("--cycle <agents>", "參與的 agent,例如 claude,codex,gemini(順序不影響分工)")
133
151
  .option("--model-mode <mode>", "這次 run 的模型模式:balanced 或 adaptive")
134
152
  .action(async (opts) => {
135
153
  const requirement = opts.reqFile ? readFileSync(opts.reqFile, "utf8") : opts.req;
136
154
  if (!requirement?.trim())
137
155
  throw new Error("請用 --req 或 --req-file 提供需求");
156
+ const stopAfter = parseStopAfter(opts.stopAfter);
157
+ if (opts.manualPlan && stopAfter)
158
+ throw new Error("--manual-plan 與 --stop-after 不能同時使用");
138
159
  const root = projectRoot();
139
160
  const base = opts.base ?? (await git(root, "branch", "--show-current"));
140
161
  if (!base)
@@ -159,6 +180,7 @@ program
159
180
  branch,
160
181
  requirement: requirement.trim(),
161
182
  stage: "spec",
183
+ stopAfter,
162
184
  autopilot: !opts.manualPlan,
163
185
  maxAgentRuns: opts.maxAgentRuns ? Number(opts.maxAgentRuns) : cfg.maxAgentRuns,
164
186
  maxAttempts: opts.maxAttempts ? positiveInt(opts.maxAttempts, "--max-attempts", MIN_ATTEMPTS) : undefined,
@@ -522,6 +544,7 @@ async function doctor() {
522
544
  console.log(`\n單一 run 的 agent 執行上限:${cfg.maxAgentRuns} 次`);
523
545
  console.log(`修正策略:${cfg.fixStrategy} 測試與實作分開:${cfg.tddSplit ? "是" : "否"}`);
524
546
  console.log(`程式碼審查人數:${cfg.reviewQuorum} 計畫審查人數:${cfg.planReviewQuorum} 計畫仲裁:${cfg.planArbiter ? "開啟" : "關閉"}`);
547
+ console.log(`同一輪審查者同時執行上限:${cfg.reviewConcurrency ?? "不限"}`);
525
548
  const layers = cfg.planReviewLayers;
526
549
  console.log(`計畫分層審查:${layers.enabled ? `任務達 ${layers.minTasks} 個時開啟,最多 ${layers.maxGroups} 群,每群平均至少 ${layers.tasksPerGroup} 個任務` : "關閉"}`);
527
550
  }
package/dist/config.js CHANGED
@@ -1,11 +1,8 @@
1
1
  /** 重試上限的下限:低於這個值時,修正與審查來不及往返一輪 */
2
2
  export const MIN_ATTEMPTS = 3;
3
+ /** 執行期旗標;沒有環境變數,設定一律來自 flow.config.json 與命令列選項 */
3
4
  export const config = {
4
- /** 單一 Agent 執行最多幾輪工具迴圈,避免卡在迴圈裡 */
5
- maxTurns: Number(process.env.AGENTFLOWCTL_MAX_TURNS ?? 200),
6
- /** 同一個關卡連續失敗幾次後停止;環境變數低於 3 時以 3 計 */
7
- maxAttempts: Math.max(MIN_ATTEMPTS, Number(process.env.AGENTFLOWCTL_MAX_ATTEMPTS ?? 5) || 5),
8
- /** 終端機是否印出 agent 的文字、工具呼叫與專案指令;預設安靜,-v 或 AGENTFLOWCTL_VERBOSE=1 開啟 */
9
- verbose: process.env.AGENTFLOWCTL_VERBOSE === "1",
5
+ /** 終端機是否印出 agent 的文字、工具呼叫與專案指令;預設安靜,flow.config.json 的 verbose 或 -v 開啟 */
6
+ verbose: false,
10
7
  };
11
8
  //# sourceMappingURL=config.js.map
package/dist/engine.js CHANGED
@@ -1,16 +1,17 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { z } from "zod";
4
- import { config } from "./config.js";
5
4
  import { arbitrationDecision } from "./arbitration.js";
6
5
  import { detectProjectDefaults, usesTestFramework, withProjectDefaults } from "./detect.js";
7
6
  import { escapeXml, opinion, reviewIssue } from "./feedback.js";
8
7
  import { changedFiles, commitAll, discardChanges, git, headCommit, resetTo } from "./git.js";
9
- import { acceptHandoff, openActions, prepareHandoff, previewHandoff, readHandoff, recoverHandoff, reviewHandoffGate, validateHandoffResponse } from "./handoff.js";
8
+ import { acceptHandoff, openActions, prepareHandoff, previewHandoff, readHandoff, recoverHandoff, responsePath, reviewHandoffGate, validateHandoffResponse } from "./handoff.js";
10
9
  import { flowDir, logDir, planArbitrationPath, planReviewStatePath, projectRoot, runDir, worktreeDir } from "./paths.js";
11
10
  import { CMD_AGENT, nextLogFile } from "./logs.js";
12
11
  import { exec } from "./proc.js";
13
12
  import { arbiterPanel, availableAgent, fixAgent, planAgent, planFixAgent, reviewers, specAgent, taskAgents } from "./roles.js";
13
+ import { dropCall, loadCalls, openRound, runPool, saveCall, storedCallValid } from "./parallelReview.js";
14
+ import { cleanupTempWorktrees, withTempWorktree } from "./tempWorktree.js";
14
15
  import { resolveAgent, runAgent, runCommand } from "./runner.js";
15
16
  import { AcceptanceList, ArbiterResult, ConsistentReviewResult, RepoConfig, TaskList, } from "./schemas.js";
16
17
  import { addRetry, addSubstitution, addUsage, agentRuns, saveRun } from "./store.js";
@@ -23,8 +24,8 @@ const info = (run, msg) => console.log(`[${run.id}] ${msg}`);
23
24
  const logHint = (run, seq) => `(agentflowctl logs ${run.id} ${seq})`;
24
25
  const flowFile = (run, name) => join(flowDir(run.id), name);
25
26
  const to = (run, stage) => ({ ...run, stage });
26
- function target(run, step, agent) {
27
- return { runId: run.id, cwd: worktreeDir(run.id), logFile: nextLogFile(logDir(run.id), run.stage, step, agent), stage: run.stage, step };
27
+ function target(run, step, agent, cwd = worktreeDir(run.id)) {
28
+ return { runId: run.id, cwd, logFile: nextLogFile(logDir(run.id), run.stage, step, agent), stage: run.stage, step };
28
29
  }
29
30
  /** 額度用完而必須停下:審查類步驟,或所有 agent 的額度都用完 */
30
31
  export class QuotaPause extends Error {
@@ -51,7 +52,8 @@ function handoffKey(run, step, slot, agent) {
51
52
  }
52
53
  async function agentStep(run, planned, step, prompt, mode) {
53
54
  const cfg = loadRepoConfig();
54
- const reset = mode.reset ?? (() => discardChanges(worktreeDir(run.id)));
55
+ const say = (msg) => info(run, mode.tag ? `[${mode.tag}] ${msg}` : msg);
56
+ const reset = mode.reset ?? (mode.workspace ? () => { } : () => discardChanges(worktreeDir(run.id)));
55
57
  let agent = planned;
56
58
  for (;;) {
57
59
  if (exhausted.has(agent)) {
@@ -63,16 +65,16 @@ async function agentStep(run, planned, step, prompt, mode) {
63
65
  throw new QuotaPause(`所有 agent 的額度都已用完(${[...exhausted].join("、")})`);
64
66
  const note = step.endsWith("-code") && sub === run.lastTestsAuthor ? "測試與實作由同一家負責" : undefined;
65
67
  addSubstitution(run.id, { step, planned, actual: sub, note });
66
- info(run, `🔁 ${agent} 額度已用完,${step} 由 ${sub} 代打${note ? `(注意:${note})` : ""}`);
68
+ say(`🔁 ${agent} 額度已用完,${step} 由 ${sub} 代打${note ? `(注意:${note})` : ""}`);
67
69
  agent = sub;
68
70
  }
69
71
  const selected = selectModel(run, cfg, agent, step, /^T-\d+-/.test(step) ? loadOrderedTasks(run)[run.taskIndex]?.complexity : undefined, mode.kind === "review" ? agent : undefined, mode.modelScope);
70
- info(run, `🤖 ${step}:${agent} 使用 ${selected.name ?? "CLI 預設(名稱未知)"}${selected.insufficient ? `(低於目標 ${selected.targetStrength})` : ""}`);
72
+ say(`🤖 ${step}:${agent} 使用 ${selected.name ?? "CLI 預設(名稱未知)"}${selected.insufficient ? `(低於目標 ${selected.targetStrength})` : ""}`);
71
73
  const callKey = handoffKey(run, step, mode.slot ?? 0, agent);
72
- prepareHandoff(run.id, callKey, handoffTarget(run), mode.blind ?? false);
73
- const r = await runAgent(agent, { ...resolveAgent(cfg, agent), model: selected.name }, { ...target(run, step, agent), strength: selected.strength, targetStrength: selected.targetStrength }, prompt);
74
+ prepareHandoff(run.id, callKey, handoffTarget(run), mode.blind ?? false, mode.workspace?.flow);
75
+ const r = await runAgent(agent, { ...resolveAgent(cfg, agent), model: selected.name }, { ...target(run, step, agent, mode.workspace?.dir), strength: selected.strength, targetStrength: selected.targetStrength }, prompt);
74
76
  if (r.resolvedModel && r.resolvedModel !== selected.name)
75
- info(run, ` ↳ CLI 回報實際模型:${r.resolvedModel}`);
77
+ say(` ↳ CLI 回報實際模型:${r.resolvedModel}`);
76
78
  addUsage(run.id, { stage: step, agent, model: selected.name, resolvedModel: r.resolvedModel,
77
79
  strength: selected.strength, targetStrength: selected.targetStrength, usageReported: r.usageReported,
78
80
  inputTokens: r.inputTokens, outputTokens: r.outputTokens, cacheReadTokens: r.cacheReadTokens, cacheWriteTokens: r.cacheWriteTokens });
@@ -80,14 +82,16 @@ async function agentStep(run, planned, step, prompt, mode) {
80
82
  reportMeta(run, agent, r);
81
83
  return { r, agent, step, callKey };
82
84
  }
83
- info(run, `⛽ ${agent} 的額度已用完`);
85
+ say(`⛽ ${agent} 的額度已用完`);
84
86
  exhausted.add(agent);
85
87
  await reset();
86
88
  // 迴圈回到開頭:review 會停下,write 會找代打
87
89
  }
88
90
  }
89
91
  /** 原有關卡已通過後才接受交接;失敗回覆不進入正式紀錄。 */
90
- function finishHandoff(run, outcome, role, gate) {
92
+ function finishHandoff(run, outcome, role, gate,
93
+ /** 平行審查:關卡以這個呼叫啟動時看到的帳本判斷(存檔裡的 base),套用仍落在目前的帳本 */
94
+ base) {
91
95
  const parsed = validateHandoffResponse(run.id);
92
96
  if (!parsed.ok)
93
97
  return parsed.error;
@@ -95,7 +99,7 @@ function finishHandoff(run, outcome, role, gate) {
95
99
  const source = {
96
100
  stage: run.stage, step: outcome.step, agent: outcome.agent, callKey: outcome.callKey,
97
101
  };
98
- const preview = previewHandoff(readHandoff(run.id), outcome.callKey, source, parsed.data, role);
102
+ const preview = previewHandoff(base ?? readHandoff(run.id), outcome.callKey, source, parsed.data, role);
99
103
  if (gate) {
100
104
  const error = reviewHandoffGate(preview, gate.target, gate.verdict);
101
105
  if (error)
@@ -127,9 +131,9 @@ function readFeedback(run) {
127
131
  }
128
132
  /** 計畫審查第幾輪仍有人要求修改時交付仲裁;固定值,不受重試上限影響 */
129
133
  const PLAN_ARBITRATION_ROUND = 2;
130
- /** 這個 run 的重試上限:`--max-attempts` 存在 run 裡,沒設定才用環境變數 */
134
+ /** 這個 run 的重試上限:`--max-attempts` 存在 run 裡,沒設定才用 flow.config.json 的 maxAttempts */
131
135
  function attemptLimit(run) {
132
- return run.maxAttempts ?? config.maxAttempts;
136
+ return run.maxAttempts ?? loadRepoConfig().maxAttempts;
133
137
  }
134
138
  /** 關卡未通過:寫入 feedback.md 給下一次嘗試參考,並把原因分類記進 retries.jsonl;超過上限就讓整個 run 失敗 */
135
139
  function retry(run, key, reason, backTo, category) {
@@ -151,6 +155,31 @@ function succeed(run, key, next) {
151
155
  delete attempts[key];
152
156
  return { ...run, attempts, stage: next };
153
157
  }
158
+ /** 回傳這次 transition 完成的公開階段;內部審查/修正 transition 不算交付邊界。 */
159
+ function completedStopStage(current, next) {
160
+ if (current === "spec" && next === "plan")
161
+ return "spec";
162
+ if (current === "plan_review" && next === "implement")
163
+ return "plan";
164
+ if (current === "implement" && next === "verify")
165
+ return "implement";
166
+ if (current === "verify" && next === "review")
167
+ return "verify";
168
+ if (current === "review" && next === "pr")
169
+ return "review";
170
+ return undefined;
171
+ }
172
+ function pauseAtStopAfter(run, next) {
173
+ const completed = completedStopStage(run.stage, next.stage);
174
+ if (!completed || run.stopAfter !== completed)
175
+ return next;
176
+ return {
177
+ ...next,
178
+ stage: "paused",
179
+ pausedStage: next.stage,
180
+ pauseReason: `已完成指定階段 ${completed},等待使用者執行 resume 接續`,
181
+ };
182
+ }
154
183
  /** 設定檔放在主專案根目錄,未 commit 的修改也會生效 */
155
184
  /** 讀取 flow.config.json;沒寫的 install、test、checks 依專案現況偵測 */
156
185
  export function loadRepoConfig() {
@@ -306,38 +335,115 @@ async function planReviewStage(run) {
306
335
  const layered = loadLayeredPlan(run, cfg);
307
336
  return layered ? planReviewLayered(run, layered, cfg) : planReviewFull(run, cfg);
308
337
  }
338
+ const readIfExists = (path) => (existsSync(path) ? readFileSync(path, "utf8") : null);
339
+ /**
340
+ * 平行階段:每位審查者在自己的臨時 worktree 執行,成功的結果立刻存檔。
341
+ * 這個階段不改 run、不寫交接帳本、不呼叫 retry。
342
+ * - 已有同一輪存檔、且仍有效的呼叫直接沿用(resume);帳本在它啟動後被本輪以外的呼叫(例如修正者)動過就作廢重跑
343
+ * - 預算(maxAgentRuns)不夠時只啟動預算內的,回傳 undefined,呼叫端應原樣回傳 run,由 advance 判定 agent_budget
344
+ * - 有呼叫額度用完時,其他呼叫照常跑完並存檔,最後才丟 QuotaPause
345
+ */
346
+ async function executeReviewCalls(run, cfg, scope, fingerprint, calls) {
347
+ if (calls.length === 0)
348
+ return { dir: "", finished: [] };
349
+ // openRound、loadCalls 與有效性檢查都要在 runPool 啟動任何呼叫之前(loadCalls 會刪 .tmp)
350
+ const dir = openRound(run.id, scope, fingerprint);
351
+ const saved = loadCalls(dir);
352
+ const live = readHandoff(run.id);
353
+ const round = [...saved.values()];
354
+ for (const [key, stored] of [...saved]) {
355
+ if (storedCallValid(stored, live, round))
356
+ continue;
357
+ dropCall(dir, key);
358
+ saved.delete(key);
359
+ }
360
+ let budget = run.maxAgentRuns - agentRuns(run.id);
361
+ // 已有存檔的不花預算;其餘依序佔預算,超出的這次不啟動
362
+ const launch = calls.filter((call) => saved.has(call.key) || budget-- > 0);
363
+ const limit = cfg.reviewConcurrency ?? Infinity;
364
+ const tagged = limit > 1 && launch.filter((call) => !saved.has(call.key)).length > 1;
365
+ // 預算內能跑的照跑並存檔,之後 resume 加大預算就不必重付
366
+ const executed = await runPool(launch, limit, (call) => executeOne(run, dir, saved, call, tagged));
367
+ const quota = executed.find((item) => "quota" in item);
368
+ if (quota)
369
+ throw new QuotaPause(quota.quota);
370
+ if (launch.length < calls.length)
371
+ return undefined;
372
+ return { dir, finished: executed };
373
+ }
374
+ async function executeOne(run, dir, saved, call, tagged) {
375
+ const reused = saved.get(call.key);
376
+ if (reused) {
377
+ info(run, ` ↪ 沿用本輪已完成的審查(${call.reviewer})`);
378
+ return { stored: reused, ok: true };
379
+ }
380
+ try {
381
+ return await withTempWorktree(run.id, `slot-${call.slot}`, async (ws) => {
382
+ rmSync(join(ws.flow, call.output), { force: true });
383
+ // 平行階段不動帳本,同一批呼叫看到的都是這一份;序列收尾以它評估這個呼叫的核准門檻
384
+ const base = readHandoff(run.id);
385
+ const outcome = await agentStep(run, call.reviewer, call.step, call.prompt, {
386
+ kind: "review", slot: call.slot, modelScope: call.scope, workspace: ws, tag: tagged ? call.reviewer : undefined,
387
+ });
388
+ const stored = {
389
+ key: call.key, reviewer: call.reviewer, agent: outcome.agent, step: outcome.step, callKey: outcome.callKey,
390
+ summary: outcome.r.summary,
391
+ output: readIfExists(join(ws.flow, call.output)),
392
+ handoffResponse: readIfExists(responsePath(run.id, ws.flow)),
393
+ base,
394
+ };
395
+ if (outcome.r.ok)
396
+ saveCall(dir, stored); // 先存檔,臨時 worktree 才會被移除
397
+ return { stored, ok: outcome.r.ok };
398
+ });
399
+ }
400
+ catch (err) {
401
+ if (err instanceof QuotaPause)
402
+ return { quota: err.message };
403
+ throw err;
404
+ }
405
+ }
406
+ /** 序列收尾第一步:把存檔的內容放回共用 .flow/,之後就能沿用原有的驗證與交接程式 */
407
+ function replay(run, stored, output) {
408
+ mkdirSync(flowDir(run.id), { recursive: true });
409
+ const put = (path, text) => (text === null ? rmSync(path, { force: true }) : writeFileSync(path, text));
410
+ put(flowFile(run, output), stored.output);
411
+ put(responsePath(run.id), stored.handoffResponse);
412
+ }
413
+ /** 存檔內容還原成 finishHandoff 需要的 StepOutcome */
414
+ function storedOutcome(stored, ok) {
415
+ return {
416
+ r: { ok, quotaExhausted: false, summary: stored.summary, usageReported: false },
417
+ agent: stored.agent, step: stored.step, callKey: stored.callKey,
418
+ };
419
+ }
309
420
  /**
310
- * 整份審查與分層審查共用的單次呼叫:還原審查者改過的計畫檔、驗證裁決與交接。
311
- * 失敗時回傳已呼叫 retry 的 run、沒有 collected,呼叫端應直接回傳這個 run。
421
+ * 整份審查與分層審查共用的序列收尾:驗證裁決與交接。
422
+ * 失敗時回傳已呼叫 retry 的 run、沒有 collected,呼叫端應直接回傳這個 run;
423
+ * 不合格的存檔結果一併刪掉,否則同一輪重跑會反覆讀到同一份。
312
424
  */
313
- async function collectPlanReview(run, spec) {
425
+ function applyPlanReview(run, spec, done, dir) {
314
426
  const { reviewer, step } = spec;
315
- const snap = snapshotPlan(run, PLAN_REPLY_FILES);
316
- rmSync(flowFile(run, spec.output), { force: true });
317
- const outcome = await agentStep(run, reviewer, step, spec.prompt, {
318
- kind: "review", slot: spec.slot, modelScope: spec.scope,
319
- reset: async () => { await discardChanges(worktreeDir(run.id)); restorePlan(run, snap); },
320
- });
321
- await discardChanges(worktreeDir(run.id));
322
- const tampered = restorePlan(run, snap);
323
- if (tampered.length)
324
- info(run, ` ↩️ 已還原審查者修改的檔案:${tampered.join(", ")}`);
325
- const stop = (category, reason) => ({
326
- run: retry(recordModelReviewFailure(run, step, reviewer, spec.scope), "plan-review-run", reason, "plan_review", category),
327
- });
328
- if (!outcome.r.ok)
329
- return stop("agent_error", `Agent 執行失敗:${outcome.r.summary}`);
427
+ const stop = (category, reason) => {
428
+ dropCall(dir, spec.key);
429
+ return { run: retry(recordModelReviewFailure(run, step, reviewer, spec.scope), "plan-review-run", reason, "plan_review", category) };
430
+ };
431
+ if (!done.ok)
432
+ return stop("agent_error", `Agent 執行失敗:${done.stored.summary}`);
433
+ replay(run, done.stored, spec.output);
330
434
  const review = readJsonFile(flowFile(run, spec.output), ConsistentReviewResult);
331
435
  if (!review.ok)
332
436
  return stop("format_invalid", review.error);
333
- // 群審查不帶關卡:索引要求修改並新增事項後,群的核准不算矛盾;最後由 planSettled 檢查未結事項
334
- const handoffError = finishHandoff(run, outcome, "reviewer", spec.gated ? { target: "plan", verdict: review.data.verdict } : undefined);
437
+ // 群審查不帶關卡:索引要求修改並新增事項後,群的核准不算矛盾;最後由 planSettled 檢查未結事項。
438
+ // 門檻以這個呼叫自己看到的帳本評估:同輪其他審查者剛新增(或崩潰前已套用)的事項它沒看過,不能拿來判它矛盾
439
+ const handoffError = finishHandoff(run, storedOutcome(done.stored, true), "reviewer", spec.gated ? { target: "plan", verdict: review.data.verdict } : undefined, done.stored.base);
335
440
  if (handoffError)
336
441
  return stop("handoff_invalid", handoffError);
337
442
  run = clearModelReviewFailure(run, step, reviewer, spec.scope);
338
443
  // 審查紀錄移到 worktree 外面:之後的仲裁者看不到是哪一家提的意見
339
444
  mkdirSync(join(runDir(run.id), "reviews"), { recursive: true });
340
- renameSync(flowFile(run, spec.output), join(runDir(run.id), "reviews", spec.archive));
445
+ writeFileSync(join(runDir(run.id), "reviews", spec.archive), readFileSync(flowFile(run, spec.output)));
446
+ rmSync(flowFile(run, spec.output), { force: true });
341
447
  if (review.data.verdict === "approve") {
342
448
  info(run, ` ✓ ${reviewer} 核准${spec.subject}`);
343
449
  return { run, collected: { reviewer, verdict: "approve", issueLines: [] } };
@@ -390,14 +496,19 @@ async function planReviewFull(run, cfg) {
390
496
  const author = run.planWriter ?? planAgent(run.cycle, run.id);
391
497
  const round = (run.attempts["plan-review"] ?? 0) + 1;
392
498
  const panel = reviewers(run.cycle, author, cfg.planReviewQuorum, `${run.id}:plan-review:${round}`);
499
+ const specs = panel.map((reviewer, slot) => ({
500
+ key: `full:${reviewer}`, reviewer, step: "plan-review", slot, gated: true, subject: "計畫",
501
+ prompt: renderPrompt("plan-review", { reviewer, author, requirement: run.requirement }),
502
+ output: "plan-review.json", archive: `plan-review-${round}-${reviewer}.json`,
503
+ }));
504
+ for (const spec of specs)
505
+ info(run, `🧐 計畫審查第 ${round} 輪(${spec.reviewer},作者 ${author})`);
506
+ const ran = await executeReviewCalls(run, cfg, "plan-review", `${round}:${currentPlanKey(run)}`, specs);
507
+ if (!ran)
508
+ return run;
393
509
  const calls = [];
394
- for (const [slot, reviewer] of panel.entries()) {
395
- info(run, `🧐 計畫審查第 ${round} 輪(${reviewer},作者 ${author})`);
396
- const passed = await collectPlanReview(run, {
397
- reviewer, step: "plan-review", slot, gated: true, subject: "計畫",
398
- prompt: renderPrompt("plan-review", { reviewer, author, requirement: run.requirement }),
399
- output: "plan-review.json", archive: `plan-review-${round}-${reviewer}.json`,
400
- });
510
+ for (const [i, spec] of specs.entries()) {
511
+ const passed = applyPlanReview(run, spec, ran.finished[i], ran.dir);
401
512
  run = passed.run;
402
513
  if (!passed.collected)
403
514
  return run;
@@ -466,35 +577,17 @@ async function planReviewLayered(run, layered, cfg) {
466
577
  const calls = [];
467
578
  // 沿用的呼叫也佔一格,重跑時每個呼叫的 slot 才不會變
468
579
  let nextSlot = 0;
469
- /** 執行或沿用一次呼叫;失敗時回傳 false,run 已是 retry 後的狀態 */
470
- const runCall = async (key, taskIds, label, spec) => {
471
- const slot = nextSlot++;
580
+ const items = [];
581
+ const add = (key, taskIds, label, spec) => {
472
582
  const reused = done.get(key);
473
- if (reused) {
583
+ if (reused)
474
584
  info(run, ` ↪ 沿用本輪已完成的${label}(${reused.reviewer})`);
475
- calls.push(reused);
476
- return true;
477
- }
478
- info(run, `🧐 ${label}第 ${round} 輪(${spec.reviewer},作者 ${author})`);
479
- // run 是外層參數,刻意在閉包裡更新:後續呼叫與最後的彙總都要看到 retry、clearModelReviewFailure 之後的 run
480
- const passed = await collectPlanReview(run, { ...spec, slot });
481
- run = passed.run;
482
- if (!passed.collected)
483
- return false;
484
- // 真的執行並成功就是有進展:同一輪不同呼叫輪流失敗時,不會累計到重試上限而讓 run 失敗。
485
- // 進度寫在 round 裡、不會重跑,所以一輪最多失敗「呼叫數 × maxAttempts」次。
486
- // 整份審查每次重跑整輪,不能這樣歸零,否則同一位審查者反覆失敗會無限重試。
487
- const attempts = { ...run.attempts };
488
- delete attempts["plan-review-run"];
489
- run = { ...run, attempts };
490
- const call = { key, ...passed.collected, ...(taskIds ? { taskIds } : {}) };
491
- progress.calls.push(call);
492
- calls.push(call);
493
- saveState({ version: 1, ...(reviewed ? { reviewed } : {}), round: progress });
494
- return true;
585
+ else
586
+ info(run, `🧐 ${label}第 ${round} 輪(${spec.reviewer},作者 ${author})`);
587
+ items.push({ key, taskIds, reused, spec: { ...spec, slot: nextSlot++, key } });
495
588
  };
496
589
  for (const reviewer of panel) {
497
- const ok = await runCall(`index:${reviewer}`, undefined, "計畫索引審查", {
590
+ add(`index:${reviewer}`, undefined, "計畫索引審查", {
498
591
  reviewer, step: "plan-review", gated: true, subject: "計畫索引",
499
592
  prompt: renderPrompt("plan-review-index", {
500
593
  reviewer, author, requirement: run.requirement,
@@ -505,8 +598,6 @@ async function planReviewLayered(run, layered, cfg) {
505
598
  }),
506
599
  output: "plan-review.json", archive: `plan-review-${round}-${reviewer}.json`,
507
600
  });
508
- if (!ok)
509
- return run;
510
601
  }
511
602
  for (const group of layered.dirty) {
512
603
  const groupTasks = layered.tasks.filter((task) => group.taskIds.includes(task.id));
@@ -514,7 +605,7 @@ async function planReviewLayered(run, layered, cfg) {
514
605
  const neighbors = neighborTasks(layered.tasks, group.taskIds).map(({ id, title, description, dependsOn }) => ({ id, title, description, dependsOn }));
515
606
  const groupPanel = reviewers(run.cycle, author, groupReviewerCount(groupTasks, cfg.planReviewQuorum), `${run.id}:plan-group:${group.id}:${round}`);
516
607
  for (const reviewer of groupPanel) {
517
- const ok = await runCall(`group:${group.id}:${group.taskIds.join(",")}:${reviewer}`, group.taskIds, `計畫群 ${group.id} 審查`, {
608
+ add(`group:${group.id}:${group.taskIds.join(",")}:${reviewer}`, group.taskIds, `計畫群 ${group.id} 審查`, {
518
609
  reviewer, step: "plan-review-group", gated: false, subject: `任務群 ${group.id}`, scope: group.id,
519
610
  prompt: renderPrompt("plan-review-group", {
520
611
  reviewer, author, groupId: group.id,
@@ -527,10 +618,32 @@ async function planReviewLayered(run, layered, cfg) {
527
618
  }),
528
619
  output: "plan-review-group.json", archive: `plan-review-${round}-${group.id}-${reviewer}.json`,
529
620
  });
530
- if (!ok)
531
- return run;
532
621
  }
533
622
  }
623
+ // 平行執行還沒套用的呼叫;沿用的(已套用、記在進度檔裡)不再跑
624
+ const pending = items.filter((item) => !item.reused);
625
+ const ran = await executeReviewCalls(run, cfg, "plan-review", `${round}:${planKey}`, pending.map((item) => item.spec));
626
+ if (!ran)
627
+ return run;
628
+ // 序列收尾,依原本的順序逐一套用;每套用一個就寫進度檔,中途被中斷也不會重複套用
629
+ const applied = new Map();
630
+ for (const [i, item] of pending.entries()) {
631
+ const passed = applyPlanReview(run, item.spec, ran.finished[i], ran.dir);
632
+ run = passed.run;
633
+ if (!passed.collected)
634
+ return run;
635
+ // 真的執行並成功就是有進展:同一輪不同呼叫輪流失敗時,不會累計到重試上限而讓 run 失敗。
636
+ // 進度寫在 round 裡、不會重跑,所以一輪最多失敗「呼叫數 × maxAttempts」次。
637
+ // 整份審查每次重跑整輪,不能這樣歸零,否則同一位審查者反覆失敗會無限重試。
638
+ const attempts = { ...run.attempts };
639
+ delete attempts["plan-review-run"];
640
+ run = { ...run, attempts };
641
+ const call = { key: item.key, ...passed.collected, ...(item.taskIds ? { taskIds: item.taskIds } : {}) };
642
+ progress.calls.push(call);
643
+ applied.set(item.key, call);
644
+ saveState({ version: 1, ...(reviewed ? { reviewed } : {}), round: progress });
645
+ }
646
+ calls.push(...items.map((item) => item.reused ?? applied.get(item.key)));
534
647
  // 只放這一輪真的審過的群(含沿用的),沒審到的任務才留得住前次 verdict
535
648
  const groupVerdicts = calls.flatMap((call) => call.taskIds ? [{ taskIds: call.taskIds, verdict: call.verdict }] : []);
536
649
  saveState({ version: 1, reviewed: applyReviewVerdicts(reviewed, layered.tasks, layered.acceptance, layered.planMd, groupVerdicts) });
@@ -932,32 +1045,42 @@ async function codeReview(run, opts) {
932
1045
  const cfg = loadRepoConfig();
933
1046
  const repo = worktreeDir(run.id);
934
1047
  const panel = reviewers(run.cycle, run.lastWriter, cfg.reviewQuorum, opts.seed, opts.testAuthor, opts.prefer);
1048
+ // 審查的是 HEAD:先清掉驗證階段留下的未 commit 修改與未追蹤產物,之後修正時的 commitAll 才不會把它們帶進去(.flow/ 在 exclude 內不受影響)
1049
+ await discardChanges(repo);
935
1050
  writeFileSync(flowFile(run, "diff.patch"), await git(repo, "diff", `${opts.base}...HEAD`));
936
1051
  const authors = [...new Set((await git(repo, "log", "--format=%s", `${opts.base}..HEAD`)).match(/\[[^\]]+\]$/gm) ?? [])]
937
1052
  .map((s) => s.slice(1, -1));
1053
+ const specs = panel.map((reviewer, slot) => ({
1054
+ key: `${slot}:${reviewer}`, slot, reviewer, step: opts.step,
1055
+ prompt: opts.prompt(reviewer, authors.join("、") || "未知"),
1056
+ output: "review.json",
1057
+ }));
1058
+ for (const spec of specs)
1059
+ info(run, `👀 ${opts.label}(${spec.reviewer})`);
1060
+ // 指紋含 HEAD:程式碼被修過就不沿用舊的審查結果
1061
+ const ran = await executeReviewCalls(run, cfg, opts.step, `${opts.seed}|${await headCommit(repo)}|${opts.base}`, specs);
1062
+ if (!ran)
1063
+ return { run };
938
1064
  const issues = [];
939
1065
  let objector;
940
- for (const [slot, reviewer] of panel.entries()) {
941
- info(run, `👀 ${opts.label}(${reviewer})`);
942
- rmSync(flowFile(run, "review.json"), { force: true });
943
- const snap = snapshotPlan(run, LOCKED_FILES);
944
- const outcome = await agentStep(run, reviewer, opts.step, opts.prompt(reviewer, authors.join("、") || "未知"), {
945
- kind: "review", slot, reset: async () => { await discardChanges(repo); restorePlan(run, snap); },
946
- });
947
- const { r } = outcome;
948
- await discardChanges(repo); // 審查者不可改程式碼
949
- const tampered = restorePlan(run, snap); // .flow/ 不受 git 管理,要另外還原
950
- if (tampered.length)
951
- info(run, ` ↩️ 已還原審查者修改的檔案:${tampered.join(", ")}`);
952
- if (!r.ok)
953
- return { run: retry(opts.step === "review" ? recordModelReviewFailure(run, "review", reviewer) : run, opts.runKey, `Agent 執行失敗:${r.summary}`, opts.backTo, "agent_error") };
1066
+ for (const [i, spec] of specs.entries()) {
1067
+ const reviewer = spec.reviewer;
1068
+ const done = ran.finished[i];
1069
+ const fail = (reason, category) => {
1070
+ dropCall(ran.dir, spec.key); // 不合格的存檔不能留著,否則同一輪重跑會讀到同一份
1071
+ return { run: retry(opts.step === "review" ? recordModelReviewFailure(run, "review", reviewer) : run, opts.runKey, reason, opts.backTo, category) };
1072
+ };
1073
+ if (!done.ok)
1074
+ return fail(`Agent 執行失敗:${done.stored.summary}`, "agent_error");
1075
+ replay(run, done.stored, "review.json");
954
1076
  const review = readJsonFile(flowFile(run, "review.json"), ConsistentReviewResult);
955
1077
  if (!review.ok)
956
- return { run: retry(opts.step === "review" ? recordModelReviewFailure(run, "review", reviewer) : run, opts.runKey, review.error, opts.backTo, "format_invalid") };
1078
+ return fail(review.error, "format_invalid");
957
1079
  const gate = opts.gate ? { target: "code", verdict: review.data.verdict } : undefined;
958
- const handoffError = finishHandoff(run, outcome, "reviewer", gate);
1080
+ // 門檻以這個呼叫自己看到的帳本評估:同輪其他審查者剛新增(或崩潰前已套用)的事項它沒看過,不能拿來判它矛盾
1081
+ const handoffError = finishHandoff(run, storedOutcome(done.stored, true), "reviewer", gate, done.stored.base);
959
1082
  if (handoffError)
960
- return { run: retry(opts.step === "review" ? recordModelReviewFailure(run, "review", reviewer) : run, opts.runKey, handoffError, opts.backTo, "handoff_invalid") };
1083
+ return fail(handoffError, "handoff_invalid");
961
1084
  if (opts.step === "review")
962
1085
  run = clearModelReviewFailure(run, "review", reviewer);
963
1086
  renameSync(flowFile(run, "review.json"), flowFile(run, opts.saveAs(reviewer)));
@@ -1037,6 +1160,7 @@ const STAGES = {
1037
1160
  export async function advance(initial) {
1038
1161
  let run = initial;
1039
1162
  recoverHandoff(run.id);
1163
+ await cleanupTempWorktrees(run.id); // 上次被中斷(Ctrl-C、SIGTERM、當機)時留下的平行審查臨時 worktree
1040
1164
  for (;;) {
1041
1165
  if (["done", "failed", "awaiting_approval", "paused"].includes(run.stage))
1042
1166
  return run;
@@ -1052,7 +1176,7 @@ export async function advance(initial) {
1052
1176
  });
1053
1177
  }
1054
1178
  try {
1055
- run = saveRun(await STAGES[stage](run));
1179
+ run = saveRun(pauseAtStopAfter(run, await STAGES[stage](run)));
1056
1180
  }
1057
1181
  catch (err) {
1058
1182
  if (err instanceof QuotaPause) {
package/dist/handoff.js CHANGED
@@ -5,7 +5,8 @@ import { z } from "zod";
5
5
  import { flowDir, handoffPath, runDir } from "./paths.js";
6
6
  import { HandoffLedger, HandoffResponse, HandoffSource } from "./schemas.js";
7
7
  import { readJsonFile } from "./util.js";
8
- const responsePath = (id) => join(flowDir(id), "handoff-response.json");
8
+ /** agent 寫交接回覆的位置;平行審查者用自己臨時 worktree 的 .flow/ */
9
+ export const responsePath = (id, flow = flowDir(id)) => join(flow, "handoff-response.json");
9
10
  const receiptsDir = (id) => join(runDir(id), "handoff-receipts");
10
11
  const keyHash = (key) => createHash("sha256").update(key).digest("hex").slice(0, 16);
11
12
  const receiptPath = (id, key) => join(receiptsDir(id), `${keyHash(key)}.json`);
@@ -71,7 +72,7 @@ export function mergeHandoff(id, callKey, source, response, role) {
71
72
  return next;
72
73
  }
73
74
  /** 只把目前步驟需要處理的事項投影給 agent。 */
74
- export function prepareHandoff(id, _callKey, target, blind) {
75
+ export function prepareHandoff(id, _callKey, target, blind, flow = flowDir(id)) {
75
76
  const items = readHandoff(id).issues.filter((item) => item.targetStage === target && (item.kind === "info" || item.status === "open" || item.status === "proposed_resolved"));
76
77
  const render = (item) => {
77
78
  const source = blind ? "" : `\n來源:${item.source.stage}/${item.source.agent}`;
@@ -85,12 +86,12 @@ export function prepareHandoff(id, _callKey, target, blind) {
85
86
  actions.length ? `## 待處理事項(action,可在 dispositions 處置)\n\n${actions.join("\n\n")}` : "",
86
87
  infos.length ? `## 參考資訊(info,只供參考,不要放進 dispositions)\n\n${infos.join("\n\n")}` : "",
87
88
  ].filter(Boolean);
88
- mkdirSync(flowDir(id), { recursive: true });
89
- writeFileSync(join(flowDir(id), "handoff-context.md"), `# 待處理交接事項\n\n${sections.length ? sections.join("\n\n") : "目前沒有待處理事項。"}\n`);
90
- rmSync(responsePath(id), { force: true });
89
+ mkdirSync(flow, { recursive: true });
90
+ writeFileSync(join(flow, "handoff-context.md"), `# 待處理交接事項\n\n${sections.length ? sections.join("\n\n") : "目前沒有待處理事項。"}\n`);
91
+ rmSync(responsePath(id, flow), { force: true });
91
92
  }
92
- export function validateHandoffResponse(id) {
93
- return readJsonFile(responsePath(id), HandoffResponse);
93
+ export function validateHandoffResponse(id, flow = flowDir(id)) {
94
+ return readJsonFile(responsePath(id, flow), HandoffResponse);
94
95
  }
95
96
  /** 已通過原有關卡的回覆先記收據,再合併;中斷後可重播。 */
96
97
  export function acceptHandoff(id, callKey, source, response, role) {
@@ -90,15 +90,26 @@ export function selectModel(run, cfg, agent, step, complexity, reviewer, scope)
90
90
  ?? def.models.reduce((best, current) => LEVEL[current.strength] > LEVEL[best.strength] ? current : best);
91
91
  return { mode, name: chosen.name, strength: chosen.strength, targetStrength, insufficient: LEVEL[chosen.strength] < LEVEL[targetStrength] };
92
92
  }
93
+ /** adaptive 模式下缺少 models 的錯誤說明:列出全部 agent,並給兩種修法 */
94
+ export function missingModelsMessage(names) {
95
+ return [
96
+ `目前是 adaptive 模式,但以下 agent 沒有登記 models(adaptive 只看 models,不看 model):${names.join("、")}`,
97
+ " 修法一:登記模型與強度(會用目前帳號送一個短請求驗證)",
98
+ ...names.map((n) => ` agentflowctl model add ${n} <模型名稱> --strength low|medium|high`),
99
+ " 修法二:改用 balanced,沿用各 agent 的 model",
100
+ " agentflowctl model mode balanced(只改這次 run:run --model-mode balanced)",
101
+ ].join("\n");
102
+ }
93
103
  /** 啟用 adaptive 前檢查本次實際參與的 agent。 */
94
104
  export function validateAdaptiveConfig(cfg, cycle) {
105
+ const lacking = cycle.filter((name) => cfg.agents[name] && !cfg.agents[name].models?.length);
106
+ if (lacking.length)
107
+ throw new Error(missingModelsMessage(lacking));
95
108
  for (const name of cycle) {
96
109
  const def = cfg.agents[name];
97
110
  if (!def)
98
111
  throw new Error(`未定義的 agent:${name}`);
99
- if (!def.models?.length)
100
- throw new Error(`agent ${name} 的 models 至少要有一個模型`);
101
- const names = def.models.map((m) => m.name);
112
+ const names = (def.models ?? []).map((m) => m.name);
102
113
  if (new Set(names).size !== names.length)
103
114
  throw new Error(`agent ${name} 的 models 有重複名稱`);
104
115
  if (def.adapter === "command") {
@@ -0,0 +1,112 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { z } from "zod";
5
+ import { parallelReviewDir } from "./paths.js";
6
+ import { HandoffLedger } from "./schemas.js";
7
+ /**
8
+ * 依序啟動、同時最多 limit 個;結果依輸入順序回傳。
9
+ * fn 丟例外時,其餘已啟動與待啟動的項目仍會跑完(讓它們的收尾,例如移除臨時 worktree,都能完成),最後才把第一個例外丟出。
10
+ */
11
+ export async function runPool(items, limit, fn) {
12
+ const results = new Array(items.length);
13
+ let next = 0;
14
+ let failure;
15
+ const worker = async () => {
16
+ for (;;) {
17
+ const index = next++;
18
+ if (index >= items.length)
19
+ return;
20
+ try {
21
+ results[index] = await fn(items[index], index);
22
+ }
23
+ catch (error) {
24
+ failure ??= { error };
25
+ }
26
+ }
27
+ };
28
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker));
29
+ if (failure)
30
+ throw failure.error;
31
+ return results;
32
+ }
33
+ /**
34
+ * 一次已執行成功的審查呼叫。放在 run 目錄(worktree 外),以輪次+輸入指紋為鍵記憶化:
35
+ * 中斷後 resume、或同一輪因別的呼叫失敗而重跑時直接沿用,不重複付費。
36
+ * 只存原始檔案內容:套用時再放回共用的 .flow/,走原有的驗證與交接程式。
37
+ */
38
+ export const StoredCall = z.object({
39
+ /** 同一輪內唯一的呼叫識別 */
40
+ key: z.string(),
41
+ reviewer: z.string(),
42
+ agent: z.string(),
43
+ step: z.string(),
44
+ callKey: z.string(),
45
+ summary: z.string(),
46
+ /** 裁決檔原文;agent 沒寫時為 null */
47
+ output: z.string().nullable(),
48
+ /** handoff-response.json 原文;agent 沒寫時為 null */
49
+ handoffResponse: z.string().nullable(),
50
+ /** 這個呼叫啟動時看到的交接帳本:核准門檻以它評估,也用來判斷存檔是否仍可沿用 */
51
+ base: HandoffLedger,
52
+ });
53
+ const hash = (text) => createHash("sha256").update(text).digest("hex").slice(0, 16);
54
+ const scopeDir = (runId, scope) => join(parallelReviewDir(runId), scope);
55
+ /** 這一輪的存檔目錄;同 scope 下其他指紋(計畫或程式碼已變、輪次已換)的結果一律作廢 */
56
+ export function openRound(runId, scope, fingerprint) {
57
+ const base = scopeDir(runId, scope);
58
+ const mine = hash(fingerprint);
59
+ if (existsSync(base)) {
60
+ for (const name of readdirSync(base)) {
61
+ if (name !== mine)
62
+ rmSync(join(base, name), { recursive: true, force: true });
63
+ }
64
+ }
65
+ const dir = join(base, mine);
66
+ mkdirSync(dir, { recursive: true });
67
+ return dir;
68
+ }
69
+ const callFile = (dir, key) => join(dir, `${hash(key)}.json`);
70
+ export function saveCall(dir, call) {
71
+ const path = callFile(dir, call.key);
72
+ const tmp = `${path}.tmp`;
73
+ writeFileSync(tmp, JSON.stringify(call));
74
+ renameSync(tmp, path);
75
+ }
76
+ /**
77
+ * 讀出這一輪已存檔的結果,並刪掉寫到一半的 .tmp 與損毀的檔案。
78
+ * 呼叫順序:必須在 runPool 啟動任何呼叫之前;之後才呼叫會刪到別的呼叫正在寫的 .tmp。
79
+ */
80
+ export function loadCalls(dir) {
81
+ const calls = new Map();
82
+ if (!existsSync(dir))
83
+ return calls;
84
+ for (const name of readdirSync(dir)) {
85
+ const path = join(dir, name);
86
+ if (!name.endsWith(".json")) {
87
+ rmSync(path, { force: true }); // 寫到一半留下的 .tmp
88
+ continue;
89
+ }
90
+ try {
91
+ const call = StoredCall.parse(JSON.parse(readFileSync(path, "utf8")));
92
+ calls.set(call.key, call);
93
+ }
94
+ catch {
95
+ rmSync(path, { force: true }); // 損毀或格式不符:當作沒有,重跑
96
+ }
97
+ }
98
+ return calls;
99
+ }
100
+ export function dropCall(dir, key) {
101
+ rmSync(callFile(dir, key), { force: true });
102
+ }
103
+ /**
104
+ * 存檔是否仍可沿用:帳本在這個呼叫啟動後多出的已套用呼叫,必須全是這一輪已存檔的審查者(崩潰或重跑時已套用的 slot)。
105
+ * 多出別的呼叫(例如修正者只回覆交接、沒改計畫或程式碼,輪次與指紋都沒變)代表審查者沒看過現在的帳本,要重跑。
106
+ */
107
+ export function storedCallValid(call, live, round) {
108
+ const seen = new Set(call.base.appliedCalls ?? []);
109
+ const sameRound = new Set([...round].map((item) => item.callKey));
110
+ return (live.appliedCalls ?? []).every((key) => seen.has(key) || sameRound.has(key));
111
+ }
112
+ //# sourceMappingURL=parallelReview.js.map
package/dist/paths.js CHANGED
@@ -22,6 +22,10 @@ export const handoffPath = (id) => join(runDir(id), "handoff.json");
22
22
  export const planReviewStatePath = (id) => join(runDir(id), "plan-review-state.json");
23
23
  /** 已交付仲裁、尚未得出裁決:暫停後 resume 直接回到仲裁。放在 worktree 外,agent 無法偽造 */
24
24
  export const planArbitrationPath = (id) => join(runDir(id), "plan-arbitration.json");
25
+ /** 平行審查的臨時 worktree(每個呼叫一個);孤兒在 advance() 開頭統一清掉 */
26
+ export const tempWorktreesDir = (id) => join(runDir(id), "tmp-review");
27
+ /** 平行審查已執行成功的呼叫存檔(依輪次與輸入指紋分目錄),resume 時沿用 */
28
+ export const parallelReviewDir = (id) => join(runDir(id), "parallel-review");
25
29
  /** 每個 run 一個 git worktree,Agent 只在這裡工作,不碰你正在編輯的檔案 */
26
30
  export const worktreesDir = () => join(agentflowctlDir(), "worktrees");
27
31
  export const worktreeDir = (id) => join(worktreesDir(), id);
package/dist/schemas.js CHANGED
@@ -14,6 +14,8 @@ export const Stage = z.enum([
14
14
  "done",
15
15
  "failed",
16
16
  ]);
17
+ /** 使用者可指定的公開流程停點;內部修正階段不列入。 */
18
+ export const StopAfterStage = z.enum(["spec", "plan", "implement", "verify", "review", "pr"]);
17
19
  export const HandoffSource = z.object({
18
20
  stage: Stage,
19
21
  step: z.string().min(1),
@@ -141,6 +143,8 @@ export const RepoConfig = z.object({
141
143
  reviewQuorum: z.number().int().min(1).default(1),
142
144
  /** 計畫需要幾位不同的 reviewer 都核准 */
143
145
  planReviewQuorum: z.number().int().min(1).default(1),
146
+ /** 同一輪審查最多幾位審查者同時執行;沒寫=不限,1=一次一位 */
147
+ reviewConcurrency: z.number().int().min(1).optional(),
144
148
  /** 計畫審查僵持不下(達到重試上限或意見不再變化)時,交給第三方 agent 仲裁,而不是停下來等人 */
145
149
  planArbiter: z.boolean().default(true),
146
150
  /** 任務夠多、能依檔案分群時,計畫審查改成每輪一次索引加上只審有變動的任務群 */
@@ -160,6 +164,10 @@ export const RepoConfig = z.object({
160
164
  tieBreak: z.enum(["proceed", "stop"]).default("proceed"),
161
165
  /** 單一 run 最多執行幾次 agent */
162
166
  maxAgentRuns: z.number().int().positive().default(60),
167
+ /** 同一關連續失敗幾次後停止;至少 3,修正與審查才來得及往返一輪 */
168
+ maxAttempts: z.number().int().min(3).default(5),
169
+ /** 終端機是否印出 agent 的文字、工具呼叫與專案指令;命令列 -v 也能開啟 */
170
+ verbose: z.boolean().default(false),
163
171
  install: z.string().default("npm install --no-audit --no-fund"),
164
172
  test: z.string().default("npx vitest run"),
165
173
  testPattern: z.string().default("\\.(test|spec)\\.[cm]?[jt]sx?$"),
@@ -180,10 +188,12 @@ export const FlowRun = z.object({
180
188
  branch: z.string(),
181
189
  requirement: z.string(),
182
190
  stage: Stage,
191
+ /** 完成這個公開階段後暫停;舊 run 沒有此欄位時一路跑完。 */
192
+ stopAfter: StopAfterStage.optional(),
183
193
  autopilot: z.boolean(),
184
194
  /** 單一 run 最多執行幾次 agent */
185
195
  maxAgentRuns: z.number().int().positive(),
186
- /** 這個 run 同一關連續失敗的上限;沒寫就用 AGENTFLOWCTL_MAX_ATTEMPTS(舊 state.json 沒有此欄位) */
196
+ /** 這個 run 同一關連續失敗的上限;沒寫就用 flow.config.json 的 maxAttempts(舊 state.json 沒有此欄位) */
187
197
  maxAttempts: z.number().int().min(3).optional(),
188
198
  /** 暫停前所在的階段與原因(額度用完時) */
189
199
  pausedStage: Stage.optional(),
package/dist/setup.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { addAgent, setAgent, setCycle } from "./agentConfig.js";
2
+ import { missingModelsMessage } from "./modelSelection.js";
3
+ import { setModelMode } from "./modelConfig.js";
2
4
  import { ADAPTERS } from "./agents/index.js";
3
5
  /**
4
6
  * `agentflowctl agent setup` 的互動精靈。
@@ -74,6 +76,16 @@ export async function runSetup(initial, deps) {
74
76
  log(` ${e.message}`);
75
77
  }
76
78
  }
79
+ // adaptive 只看 models;精靈只會寫 model,參與的 agent 缺 models 時 run 會直接失敗
80
+ const defs = (cfg.agents ?? {});
81
+ const lacking = cfg.cycle.filter((n) => !defs[n]?.models?.length);
82
+ if (lacking.length && (cfg.modelSelection ?? {}).mode === "adaptive") {
83
+ log(`\n⚠️ ${missingModelsMessage(lacking)}`);
84
+ if (await confirm("改回 balanced 模式?(選 n 則維持 adaptive,請之後自行登記 models)", true)) {
85
+ cfg = setModelMode(cfg, "balanced");
86
+ changes.push("modelSelection.mode → balanced");
87
+ }
88
+ }
77
89
  const agents = cfg.agents;
78
90
  log("\n即將寫入:");
79
91
  for (const name of chosen)
@@ -51,6 +51,10 @@ export function stopReport(i) {
51
51
  for (const item of i.open)
52
52
  out.push(` [${item.targetStage}] ${item.id} ${item.summary}(${item.status})`);
53
53
  }
54
+ const stoppedAfterStage = run.stage === "paused" && run.stopAfter && run.pauseReason?.startsWith("已完成指定階段");
55
+ if (stoppedAfterStage) {
56
+ out.push("", "── 指定停點 ──", ` ${run.pauseReason}`, ` 指定停點:${run.stopAfter}`, ` 下一階段:${run.pausedStage ?? "?"}`);
57
+ }
54
58
  const cmd = (c, why) => ` ${c.padEnd(40)} ${why}`;
55
59
  const actions = [];
56
60
  if (run.stage === "failed") {
@@ -61,7 +65,7 @@ export function stopReport(i) {
61
65
  actions.push(cmd(`agentflowctl cancel ${run.id}`, "放棄這個 run"));
62
66
  }
63
67
  else if (run.stage === "paused") {
64
- actions.push(cmd(`agentflowctl resume ${run.id}`, "額度恢復後接續"));
68
+ actions.push(cmd(`agentflowctl resume ${run.id}`, stoppedAfterStage ? `從 ${run.pausedStage ?? "下一階段"} 接續` : "額度恢復後接續"));
65
69
  }
66
70
  else if (run.stage === "awaiting_approval") {
67
71
  actions.push(cmd(`less ${join(i.worktree, ".flow", "plan.md")}`, "檢視計畫"));
@@ -0,0 +1,129 @@
1
+ import { cpSync, existsSync, mkdirSync, realpathSync, rmSync, symlinkSync } from "node:fs";
2
+ import { join, sep } from "node:path";
3
+ import { git, removeWorktree } from "./git.js";
4
+ import { flowDir, projectRoot, tempWorktreesDir, worktreeDir } from "./paths.js";
5
+ export { tempWorktreesDir };
6
+ let created = 0;
7
+ const warn = (what, err) => console.warn(`⚠️ ${what}失敗(下次執行或 clean 會再清):${err.message}`);
8
+ /**
9
+ * 建立臨時 worktree:detached、內容是 run worktree 的 HEAD,並複製目前的 .flow/(.flow/ 不受 git 管理,要另外帶),
10
+ * run worktree 頂層有 node_modules 時再建一個指向它的 symlink,讓審查者讀得到依賴。
11
+ * 路徑固定為 tmp-review/<name>:agent CLI(Claude Code、Gemini CLI)會依工作目錄記錄專案,路徑每次不同會一直累積紀錄。
12
+ * 固定路徑被占用(目錄已存在,或殘留的 locked 登記讓 git worktree add 失敗)時才改用唯一的父目錄,basename 仍是 name。
13
+ */
14
+ export async function createTempWorktree(runId, name) {
15
+ const stable = join(tempWorktreesDir(runId), name);
16
+ mkdirSync(tempWorktreesDir(runId), { recursive: true });
17
+ let ws;
18
+ if (!existsSync(stable)) {
19
+ try {
20
+ await git(worktreeDir(runId), "worktree", "add", "--detach", stable, "HEAD");
21
+ ws = { dir: stable, flow: join(stable, ".flow") };
22
+ }
23
+ catch {
24
+ // 固定路徑被占用:清掉這次可能留下的半成品目錄(原本不存在,是這次建的),改用唯一路徑
25
+ try {
26
+ rmSync(stable, { recursive: true, force: true });
27
+ }
28
+ catch (err) {
29
+ warn("清掉固定路徑的半成品 ", err);
30
+ }
31
+ }
32
+ }
33
+ if (!ws) {
34
+ const parent = join(tempWorktreesDir(runId), `${process.pid}-${Date.now()}-${created++}`);
35
+ const dir = join(parent, name);
36
+ mkdirSync(parent, { recursive: true });
37
+ await git(worktreeDir(runId), "worktree", "add", "--detach", dir, "HEAD");
38
+ ws = { dir, flow: join(dir, ".flow"), uniqueParent: parent };
39
+ }
40
+ try {
41
+ if (existsSync(flowDir(runId)))
42
+ cpSync(flowDir(runId), ws.flow, { recursive: true });
43
+ else
44
+ mkdirSync(ws.flow, { recursive: true });
45
+ const deps = join(worktreeDir(runId), "node_modules");
46
+ if (existsSync(deps))
47
+ symlinkSync(deps, join(ws.dir, "node_modules"), process.platform === "win32" ? "junction" : "dir");
48
+ }
49
+ catch (err) {
50
+ await removeTempWorktree(runId, ws); // 不會丟例外,不會蓋掉原本的錯誤
51
+ throw err;
52
+ }
53
+ return ws;
54
+ }
55
+ /**
56
+ * 移除這次建立的臨時 worktree:固定路徑只刪 dir(絕不刪 tmp-review/ 本身,裡面可能還有別的呼叫在用),唯一路徑連父目錄一起刪。
57
+ * git worktree remove 與 rmSync 都不會跟進 node_modules symlink 刪到原本的依賴。
58
+ * 永遠不丟例外:這在 withTempWorktree 的 finally 裡執行,丟出去會蓋掉審查本身的結果(已存檔,甚至可能是額度用完);
59
+ * 清不掉的只印警告,交給下次 advance 或 clean 的清理。
60
+ */
61
+ export async function removeTempWorktree(runId, ws) {
62
+ let removed = true;
63
+ try {
64
+ await removeWorktree(worktreeDir(runId), ws.dir);
65
+ }
66
+ catch {
67
+ removed = false; // 目錄可能已經不在:刪掉目錄後再讓 git 忘掉這一個登記
68
+ }
69
+ try {
70
+ rmSync(ws.uniqueParent ?? ws.dir, { recursive: true, force: true, maxRetries: 3 });
71
+ }
72
+ catch (err) {
73
+ warn("移除平行審查的臨時 worktree ", err);
74
+ return;
75
+ }
76
+ // 只處理自己這一個登記;repo 層級的 prune 可能清掉使用者其他資料夾暫時不在的 worktree
77
+ if (!removed)
78
+ await git(worktreeDir(runId), "worktree", "remove", "-f", "-f", ws.dir).catch(() => { });
79
+ }
80
+ export async function withTempWorktree(runId, name, fn) {
81
+ const ws = await createTempWorktree(runId, name);
82
+ try {
83
+ return await fn(ws);
84
+ }
85
+ finally {
86
+ await removeTempWorktree(runId, ws);
87
+ }
88
+ }
89
+ /** git worktree list 裡登記在這個 run 臨時目錄下的 worktree(含 locked 的) */
90
+ async function registeredTempWorktrees(repo, runId) {
91
+ const base = tempWorktreesDir(runId);
92
+ const prefixes = [base + sep];
93
+ try {
94
+ prefixes.push(realpathSync(base) + sep);
95
+ }
96
+ catch {
97
+ // 目錄已不在:只比對原路徑
98
+ }
99
+ return (await git(repo, "worktree", "list", "--porcelain"))
100
+ .split("\n")
101
+ .filter((line) => line.startsWith("worktree "))
102
+ .map((line) => line.slice("worktree ".length))
103
+ .filter((path) => prefixes.some((prefix) => path.startsWith(prefix)));
104
+ }
105
+ /**
106
+ * 清掉上次中斷(Ctrl-C 的 process.exit 不跑 finally、SIGTERM、kill -9、當機)留下的臨時 worktree 與 git 登記。
107
+ * git 在 worktree add 途中被強制中止會留下 locked 登記,prune 會略過它,所以先逐個 remove -f -f。
108
+ * 這個 run 沒有 tmp-review/ 就什麼都不做(不呼叫 git);prune 是 repo 層級的,只在確實找到這個 run 的登記時才執行。
109
+ * force(`clean` 用):tmp-review/ 已不在也照樣列出登記並清掉,否則只剩 locked 登記時會永遠留在 .git/worktrees/。
110
+ * 不丟例外:清不乾淨只印警告,下一次 advance 會再試;固定路徑被殘骸占用時建立會改用唯一路徑,不會被擋住。
111
+ */
112
+ export async function cleanupTempWorktrees(runId, opts = {}) {
113
+ if (!opts.force && !existsSync(tempWorktreesDir(runId)))
114
+ return;
115
+ try {
116
+ const repo = existsSync(worktreeDir(runId)) ? worktreeDir(runId) : projectRoot();
117
+ const registered = await registeredTempWorktrees(repo, runId);
118
+ for (const path of registered) {
119
+ await git(repo, "worktree", "remove", "-f", "-f", path).catch(() => { }); // 失敗交給下面的 rmSync 與 prune
120
+ }
121
+ rmSync(tempWorktreesDir(runId), { recursive: true, force: true, maxRetries: 3 });
122
+ if (registered.length)
123
+ await git(repo, "worktree", "prune");
124
+ }
125
+ catch (err) {
126
+ warn("清理平行審查的臨時 worktree ", err);
127
+ }
128
+ }
129
+ //# sourceMappingURL=tempWorktree.js.map
@@ -4,6 +4,7 @@
4
4
  "tddSplit": true,
5
5
  "reviewQuorum": 1,
6
6
  "planReviewQuorum": 1,
7
+ "reviewConcurrency": 2,
7
8
  "planArbiter": true,
8
9
  "planReviewLayers": { "enabled": true, "minTasks": 7, "maxGroups": 5, "tasksPerGroup": 3 },
9
10
  "tieBreak": "proceed",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agentflowctl",
3
3
  "license": "MIT",
4
- "version": "0.15.1",
4
+ "version": "0.17.0",
5
5
  "description": "跨廠商 AI 開發 harness:Claude Code、Codex、Gemini 輪流實作、審查、修正",
6
6
  "keywords": [
7
7
  "ai",