agentflowctl 0.3.0 → 0.4.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
@@ -10,15 +10,33 @@
10
10
 
11
11
  ## 快速開始
12
12
 
13
- 需要 Node.js 22 以上與 git。不需要全域安裝,直接用 `npx` 執行。先讓各家 CLI 完成訂閱登入,再檢查環境:
13
+ 需要 Node.js 22 以上與 git。不需要全域安裝,直接用 `npx` 執行。先讓各家 CLI 完成登入:
14
14
 
15
15
  ```bash
16
16
  claude # 完成登入
17
17
  codex # 完成登入
18
+ ```
19
+
20
+ 沒有內建的 agent,只會使用 `flow.config.json` 的 `agents` 裡設定的。用 `agent setup` 互動設定:它會偵測本機的 `claude`、`codex`、`gemini`,逐一詢問要不要加入、名稱與 model,再設定輪替順序;確認後才一次寫入,最後自動跑一次 `doctor`:
21
+
22
+ ```bash
23
+ npx agentflowctl agent setup
24
+ ```
25
+
26
+ 也可以不經互動,直接用指令新增:
27
+
28
+ ```bash
29
+ npx agentflowctl agent add claude --adapter claude
30
+ npx agentflowctl agent add codex --adapter codex
31
+ ```
32
+
33
+ 之後改了設定或換了環境,用 `doctor` 檢查。它會列出設定的 agent 的 CLI 是否已安裝,並印出即將使用的輪替順序。沒有設定 `cycle` 時,依 `agents` 的順序取已安裝的;一個都沒偵測到就無法執行。
34
+
35
+ ```bash
18
36
  npx agentflowctl doctor
19
37
  ```
20
38
 
21
- `doctor` 會列出已安裝的 CLI,並印出即將使用的輪替順序。沒有 `flow.config.json` 時,會自動採用偵測到的 CLI。
39
+ 從舊版升級:以前沒寫 `agents` 時會自動使用內建的 claude、codex、gemini,現在不會了,要先用 `agent setup` 或 `agent add` 補上。舊設定裡的 `removedAgents` 已不再使用,可以刪掉。
22
40
 
23
41
  在專案資料夾內開始一次 run:
24
42
 
@@ -81,10 +99,13 @@ agentflowctl run --req "..." --manual-plan # 計畫通過 AI 審查後,仍
81
99
  agentflowctl approve f-xxxx # 搭配 --manual-plan
82
100
  agentflowctl status f-xxxx # 階段、任務進度、各 agent 用量、代打紀錄
83
101
  agentflowctl list
84
- agentflowctl logs f-xxxx --latest
102
+ agentflowctl logs f-xxxx # 列出每一份 log 的編號、結果、階段、步驟、agent
103
+ agentflowctl logs f-xxxx 7 # 解析第 7 份 log,最後附上錯誤整理(--latest 看最新一份)
104
+ agentflowctl logs f-xxxx 7 --raw # 原始內容(agent 的 JSON 行)
85
105
  agentflowctl resume f-xxxx # 從暫停、Ctrl-C 或失敗處接續
86
106
  agentflowctl cancel f-xxxx
87
107
  agentflowctl clean f-xxxx # 移除 worktree 與 run 紀錄,分支保留
108
+ agentflowctl clean --all # 清掉所有已結束的 run 與中斷留下的 worktree
88
109
  ```
89
110
 
90
111
  | 選項 | 作用 |
@@ -94,12 +115,31 @@ agentflowctl clean f-xxxx # 移除 worktree 與 run 紀錄,分支保
94
115
  | `--cycle` | 這次 run 的輪替順序,例如 `claude,codex,gemini`;建立後就固定,`resume` 沿用 |
95
116
  | `--max-agent-runs` | 這次 run 的 agent 執行次數上限 |
96
117
  | `--manual-plan` | 計畫通過審查後進入 `awaiting_approval`,等 `approve` 才開始實作 |
118
+ | `-v` / `--verbose` | 執行時印出 agent 的文字、工具呼叫與專案指令;`run`、`resume`、`approve` 都適用,也可設 `AGENTFLOWCTL_VERBOSE=1` |
97
119
 
98
120
  `status` 會列出任務。進行中的任務會標出正在寫測試還是正在寫實作。
99
121
 
122
+ ### 清除 worktree
123
+
124
+ `run` 建好 worktree 就會寫入 run 紀錄,所以不論在哪一步中斷(包括安裝相依套件時),都能用 `resume` 接續,或用 `clean` 清掉。
125
+
126
+ - `clean <id>`:移除該 run 的 worktree 與 `.agentflowctl/runs/<id>/`,並清掉 git 裡已失效的 worktree 登記。沒有 run 紀錄的 worktree 也能清,worktree 資料夾被手動刪掉時也一樣。
127
+ - `clean --all`:清掉所有 `done`、`failed` 的 run,以及沒有 run 紀錄的 worktree。進行中、`paused`、`awaiting_approval` 的不動;Ctrl-C 中斷、之後不打算接續的 run,先 `cancel` 再 `clean --all`,或直接 `clean <id>`。
128
+
129
+ 兩者都保留 `flow/<id>` 分支,不需要時用 `git branch -D` 刪除。
130
+
100
131
  ### 執行中的終端機輸出
101
132
 
102
- agent 每次使用工具,都會印出完整的指令或主要參數,不截斷。多行指令的後續行會縮排對齊。install、測試、verify 這些專案指令也會以 `$ ` 開頭印出來:
133
+ 預設是安靜模式,只印出 `[run-id]` 開頭的階段進度(📝 🧐 ✓ ✗ ⚠️ 等)。agent 執行失敗、測試或檢查沒過時,會附上對應 log 的查看指令:
134
+
135
+ ```
136
+ [f-xxxx] 🔍 執行驗證
137
+ [f-xxxx] ✓ typecheck
138
+ [f-xxxx] ✗ lint(agentflowctl logs f-xxxx 15)
139
+ ✗ codex 執行失敗(結束碼 1),可用 agentflowctl logs f-xxxx 16 查看
140
+ ```
141
+
142
+ 加上 `-v` 會另外印出 agent 每一段文字的第一行、每次工具呼叫的完整指令或主要參數(不截斷,多行指令的後續行縮排對齊),以及 install、測試、verify 這些以 `$ ` 開頭的專案指令:
103
143
 
104
144
  ```
105
145
  💬 [claude] 先讀現有的表單元件
@@ -110,11 +150,75 @@ agent 每次使用工具,都會印出完整的指令或主要參數,不截
110
150
  $ pnpm install
111
151
  ```
112
152
 
113
- 工具參數依序取 command、檔案路徑、path、pattern、url、query,都沒有時印出整包 JSON。每次執行的完整輸出都在 `agentflowctl logs <id>`。
153
+ 工具參數依序取 command、檔案路徑、path、pattern、url、query,都沒有時印出整包 JSON。
154
+
155
+ ### Log
156
+
157
+ 每次執行 agent 或專案指令都會留一份 log,放在 `.agentflowctl/runs/<id>/logs/`,檔名是「序號-階段-步驟-agent」,專案指令的 agent 欄位是 `cmd`:
158
+
159
+ ```
160
+ 001-setup-install-cmd.log
161
+ 002-spec-spec-claude.log
162
+ 007-implement-T1-tests-codex.log
163
+ 008-implement-T1-red-cmd.log
164
+ 015-verify-lint-cmd.log
165
+ ```
166
+
167
+ 檔案保留 agent 的原始輸出,也就是各家 CLI 的 JSON 行。第一行 `# agentflowctl {...}` 記錄階段、步驟、agent 與開始時間;stderr 接在 `[stderr]` 之後;最後一行 `# exit {...}` 記錄結束碼與是否成功,沒有這行就代表還在執行或被中斷。
168
+
169
+ `agentflowctl logs <id>` 列出所有 log。結果欄的 ✓ 是成功,✗ 是失敗,… 代表沒有結束紀錄:
170
+
171
+ ```
172
+ # 結果 階段 步驟 agent 開始時間
173
+ 1 ✓ setup install cmd 2026-09-26 11:29:04
174
+ 2 ✓ spec spec claude 2026-09-26 11:29:05
175
+ 3 ✗ plan plan codex 2026-09-26 11:31:40
176
+ ```
177
+
178
+ `agentflowctl logs <id> <編號>` 會把原始 JSON 解析成易讀的格式:
179
+
180
+ | 標記 | 內容 |
181
+ |---|---|
182
+ | 💬 | agent 的完整文字,不截斷 |
183
+ | 🔧 | 工具呼叫與完整參數 |
184
+ | 📊 | token 用量 |
185
+ | 🏁 | 最後結果 |
186
+ | ⚠️ | 工具回報的錯誤。agent 通常會自己換方法繼續,所以不列進錯誤整理 |
187
+ | ❌ | adapter 不認得的錯誤事件 |
188
+ | 📄 | 不是 JSON 的輸出行 |
189
+
190
+ adapter 不認得、也看不出錯誤跡象的 JSON 行不會顯示,只列出行數,要看全部請加 `--raw`。專案指令的 log 本來就是純文字,會原樣顯示。
191
+
192
+ 最後一段「錯誤」整理出結束碼、agent 回報的失敗、錯誤事件與 stderr:
193
+
194
+ ```
195
+ #3 plan / plan / codex
196
+ 開始 2026-09-26 11:31:40 結束 2026-09-26 11:31:52 結束碼 1 ✗ 失敗
197
+ 檔案 /repo/.agentflowctl/runs/f-xxxx/logs/003-plan-plan-codex.log
198
+
199
+ 💬 先讀 spec.md 與 acceptance.json
200
+ 🔧 shell: bash -lc 'cat .flow/spec.md'
201
+ 🏁 失敗:stream disconnected before completion
202
+
203
+ ── 錯誤 ──
204
+ 結束碼 1
205
+ agent 回報失敗:stream disconnected before completion
206
+ stderr:
207
+ Error: stream disconnected before completion
208
+ ```
209
+
210
+ 執行成功時,stderr 會放在「其他輸出」段落,不算錯誤。
211
+
212
+ ### 出錯時怎麼查
213
+
214
+ 1. run 停下時印出的摘要,或 `agentflowctl status <id>`,會列出失敗的階段、原因、最後一份 log,以及最近失敗的那一份。
215
+ 2. `agentflowctl logs <id> <編號>` 看那份 log 的錯誤段落。
216
+ 3. 解析結果看不出原因時,加 `--raw` 看原始輸出。
217
+ 4. 必要時直接在 worktree(`.agentflowctl/worktrees/<id>`)裡修正,再執行 `agentflowctl resume <id>`。
114
218
 
115
219
  ## 設定
116
220
 
117
- 專案根目錄的 `flow.config.json`。完整範例見 `examples/flow.config.json`。未提供的欄位使用內建預設(安裝指令、測試指令、檢查清單預設對應 Vite + TypeScript + Vitest)。
221
+ 專案根目錄的 `flow.config.json`。完整範例見 `examples/flow.config.json`。未提供的欄位使用內建預設;`install`、`test`、`checks` 沒寫時,會依專案現況偵測(見下方「專案指令的偵測」)。
118
222
 
119
223
  ```json
120
224
  {
@@ -123,6 +227,7 @@ agent 每次使用工具,都會印出完整的指令或主要參數,不截
123
227
  "tddSplit": true,
124
228
  "tieBreak": "proceed",
125
229
  "agents": {
230
+ "claude": { "adapter": "claude" },
126
231
  "codex": { "adapter": "codex", "model": "你要用的模型" },
127
232
  "aider": { "adapter": "command", "command": ["aider", "--yes-always", "--no-auto-commits", "--message", "{prompt}"] }
128
233
  }
@@ -131,7 +236,7 @@ agent 每次使用工具,都會印出完整的指令或主要參數,不截
131
236
 
132
237
  | 設定 | 預設 | 說明 |
133
238
  |---|---|---|
134
- | `cycle` | 自動偵測 | 輪替順序。同一家 CLI 可以登記成不同 agent,例如 `claude-fast` 與 `claude-strong` |
239
+ | `cycle` | 自動偵測 | 輪替順序。未設定時依 `agents` 的順序取已安裝的 CLI。同一家 CLI 可以登記成不同 agent,例如 `claude-fast` 與 `claude-strong` |
135
240
  | `fixStrategy` | `ring` | `ring`:審查意見交給審查者的下一位;`author`:交回最後作者 |
136
241
  | `tddSplit` | `true` | 測試與實作是否分開 |
137
242
  | `reviewQuorum` | `1` | 程式碼需要幾位不同審查者都 `approve` |
@@ -139,33 +244,49 @@ agent 每次使用工具,都會印出完整的指令或主要參數,不截
139
244
  | `planArbiter` | `true` | 計畫審查僵持時交付仲裁。關掉之後,僵持會直接讓 run 失敗 |
140
245
  | `tieBreak` | `proceed` | 兩家仲裁意見分歧時:`proceed` 繼續並記錄爭議;`stop` 停下 |
141
246
  | `maxAgentRuns` | `60` | 單一 run 最多執行幾次 agent |
142
- | `install` / `test` / `checks` | 見 `src/schemas.ts` | verify 階段實際執行的指令 |
143
- | `agents` | 內建 claude、codex、gemini | 覆寫內建 agent,或用 `command` adapter 接上其他 CLI |
144
- | `removedAgents` | `[]` | 移除的內建 agent,不會被自動偵測、不能放進輪替。通常用 `agent remove` 寫入 |
247
+ | `install` / `test` / `checks` | 依專案偵測 | 安裝、測試與 verify 階段實際執行的指令 |
248
+ | `agents` | `{}` | 可用的 agent,沒有內建的。每個都要指定 adapter(`claude`、`codex`、`gemini`,或用 `command` 接上其他 CLI) |
145
249
 
146
250
  verify 失敗(型別、lint、建置)一律交回最後作者。審查意見才依 `fixStrategy` 決定修正者。
147
251
 
252
+ ### 專案指令的偵測
253
+
254
+ `install`、`test`、`checks` 沒寫在 `flow.config.json` 時,每次讀設定都會依專案現況推出指令,不寫檔。有寫的欄位一律照你的設定。
255
+
256
+ - 套件管理器:先看 `package.json` 的 `packageManager`,再看 lockfile(`pnpm-lock.yaml`、`yarn.lock`、`bun.lock`/`bun.lockb`、`package-lock.json`),都沒有就用 npm。
257
+ - `install`:`pnpm install`、`yarn install`、`bun install` 或 `npm install --no-audit --no-fund`。不鎖 lockfile,因為實作時 agent 可能新增依賴。
258
+ - `checks`:typecheck、lint、test、build 四項。`package.json` 有對應的 script(`typecheck`/`type-check`、`lint`、`test`、`build`)就用 `<pm> run <script>`,否則用 `tsc --noEmit`、`eslint .`、`vitest run`、`vite build`,前面加上 `npx`、`pnpm exec`、`yarn` 或 `bunx`。
259
+ - `test`:`vitest run`,前綴同上。
260
+
261
+ `run` 建立 worktree 後會印出這次偵測到的指令:
262
+
263
+ ```
264
+ [f-xxxx] 🔧 依專案偵測指令:pnpm(依 package.json 的 packageManager)
265
+ [f-xxxx] install:pnpm install
266
+ [f-xxxx] checks.typecheck:pnpm run type-check
267
+ ```
268
+
148
269
  ### 用指令管理 agent
149
270
 
150
271
  `agents` 與 `cycle` 也可以用 `agent` 指令修改,不必手動編輯 JSON。每次寫入前都會先驗證整份設定:
151
272
 
152
273
  ```bash
153
- agentflowctl agent list # 內建與自訂 agent、是否已安裝、輪替位置
274
+ agentflowctl agent setup # 互動設定 claude、codex、gemini 與輪替順序
275
+ agentflowctl agent list # 設定的 agent、是否已安裝、輪替位置
154
276
  agentflowctl agent add claude-strong --adapter claude --model opus
155
277
  agentflowctl agent add aider --adapter command -- aider --yes-always --message {prompt}
156
278
  agentflowctl agent set codex --model 你要用的模型 --extra-arg=--search
157
279
  agentflowctl agent set aider --adapter gemini # 換 adapter
158
280
  agentflowctl agent remove aider
159
- agentflowctl agent remove gemini # 內建的也能移除
160
281
  agentflowctl agent cycle claude-strong,codex,gemini # 不帶參數時顯示目前的順序
161
282
  ```
162
283
 
163
284
  修改會連帶更新相關設定,並在終端機列出:
164
285
 
165
286
  - `set --adapter` 換 adapter 時,會清掉舊 adapter 的 `model`、`extraArgs`、`command`,這次有重新指定的除外。
166
- - `remove` 會一併從 `cycle` 移除。`cycle` 變空就刪除這個欄位,改回自動偵測。
167
- - 內建的 claude、codex、gemini 也能 `remove`:覆寫設定會一起刪掉,名稱記在 `removedAgents`,之後自動偵測會跳過它,也不能放進 `cycle`。要加回來用 `agent add gemini --adapter gemini`。
287
+ - `remove` 會一併從 `cycle` 移除。`cycle` 變空就刪除這個欄位,改回從 `agents` 自動偵測。
168
288
  - `--extra-arg` 可以重複指定,會整個取代原本的 `extraArgs`。參數以 `-` 開頭時,寫成 `--extra-arg=--sandbox`。
289
+ - `setup` 遇到已存在的名稱會先問要不要覆寫;不覆寫時保留原設定,但仍放進這次的輪替順序。在非互動式環境(CI、管線)裡請改用 `agent add`。`command` adapter 要自己寫指令,不在 `setup` 裡。
169
290
 
170
291
  已建立的 run 會沿用建立時的輪替順序,不受這些修改影響。
171
292
 
@@ -222,7 +343,7 @@ Agent 的最後回覆要附上 XML 中繼資料:
222
343
  </result>
223
344
  ```
224
345
 
225
- `blocked` 與 `concerns` 會印在終端機上,完整回覆留在 log。這份中繼資料只給人看;缺少或格式錯誤都不影響流程,是否通過仍由上表的程式檢查決定。
346
+ `blocked` 與 `concerns` 會印在終端機上,完整回覆留在 log,可用 `agentflowctl logs` 查看。這份中繼資料只給人看;缺少或格式錯誤都不影響流程,是否通過仍由上表的程式檢查決定。
226
347
 
227
348
  ## Adapter
228
349
 
@@ -237,11 +358,7 @@ Codex 沙箱預設不能連網,所以建立 worktree 時會先跑 `install`。
237
358
 
238
359
  各家讀的專案說明檔不同:Claude Code 讀 `CLAUDE.md`,Codex 讀 `AGENTS.md`,Gemini 讀 `GEMINI.md`。把專案慣例寫在 `AGENTS.md`,另外兩個檔案各用一行引用它。agentflowctl 的 prompt 在 `prompts/`,不依賴任何一家的 skills 或 plugins。
239
360
 
240
- ## 登入、額度與代打
241
-
242
- 只支援各家 CLI 的訂閱登入。
243
-
244
- 執行 agent 時,一律從子程序環境移除 `ANTHROPIC_API_KEY`、`ANTHROPIC_AUTH_TOKEN`、`CODEX_API_KEY`、`OPENAI_API_KEY`、`GEMINI_API_KEY`、`GOOGLE_API_KEY`,避免環境裡的 key 蓋過訂閱登入。`doctor` 發現這些變數時會提醒。專案指令(install、test、build)不受影響。
361
+ ## 額度與代打
245
362
 
246
363
  上限是執行次數(`maxAgentRuns`,預設 60),不是金額。`status` 會列出各 agent 的執行次數與 token 數。
247
364
 
@@ -266,7 +383,7 @@ agentflowctl 本身只依賴 Node.js 與 git。專案指令透過系統 shell
266
383
  | 環境 | 適合的用法 |
267
384
  |---|---|
268
385
  | 自己的電腦 | 自己的專案、自己寫的需求。剛開始可以加 `--manual-plan`,確認審查品質後再拿掉 |
269
- | 容器、遠端開發機 | 無人值守。先在該環境內完成各家 CLI 的訂閱登入 |
386
+ | 容器、遠端開發機 | 無人值守。先在該環境內完成各家 CLI 的登入 |
270
387
  | Claude Code、Codex 裡面 | 讓它們用 shell 執行 `npx agentflowctl` |
271
388
 
272
389
  沒有容器隔離時,verify 會在你的電腦上執行 agent 寫出來的程式碼。Gemini 在無人值守時是 yolo 模式。處理外部 issue,或需求文字不是你自己寫的,放到可丟棄的環境。AI 審查計畫擋不住夾在需求裡的指示。
@@ -279,8 +396,11 @@ src/
279
396
  engine.ts 狀態機與各階段
280
397
  roles.ts 輪替規則(含計畫修正者與仲裁者)
281
398
  runner.ts 執行 agent、正規化結果、執行專案指令
399
+ logs.ts log 檔名、檔頭檔尾、列表與解析
282
400
  agents/ claude、codex、gemini、command
401
+ setup.ts agent setup 互動精靈
283
402
  git.ts worktree 與 git 操作
403
+ cleanup.ts clean:移除 worktree 與 run 紀錄
284
404
  store.ts 狀態、用量、代打紀錄
285
405
  tasks.ts 任務 DAG
286
406
  schemas.ts zod schema
@@ -1,20 +1,10 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { z } from "zod";
3
- import { builtinAgents, DEFAULT_CYCLE } from "./agents/index.js";
4
3
  import { AgentDef, RepoConfig } from "./schemas.js";
5
4
  /** 這些欄位的意義取決於 adapter,換 adapter 時要清掉 */
6
5
  const ADAPTER_FIELDS = ["model", "extraArgs", "command"];
7
- const isBuiltin = (name) => DEFAULT_CYCLE.includes(name);
8
6
  const agentsOf = (cfg) => ({ ...(cfg.agents ?? {}) });
9
- const removedOf = (cfg) => (cfg.removedAgents ?? []);
10
- const isDefined = (cfg, name) => builtinAgents(removedOf(cfg)).includes(name) || name in agentsOf(cfg);
11
- /** 寫回 removedAgents;清單變空就刪掉欄位 */
12
- function withRemoved(cfg, removed) {
13
- const next = { ...cfg, removedAgents: removed };
14
- if (!removed.length)
15
- delete next.removedAgents;
16
- return next;
17
- }
7
+ const isDefined = (cfg, name) => name in agentsOf(cfg);
18
8
  /** 只留下有值的欄位,驗證後回傳 */
19
9
  function buildAgent(base, patch) {
20
10
  const next = { ...base };
@@ -37,10 +27,7 @@ export function addAgent(cfg, name, def) {
37
27
  if (isDefined(cfg, name))
38
28
  throw new Error(`agent ${name} 已存在,要修改請用 agent set`);
39
29
  const { adapter, ...patch } = def;
40
- const next = { ...cfg, agents: { ...agentsOf(cfg), [name]: buildAgent({ adapter }, patch) } };
41
- // 加回先前移除的內建 agent
42
- const removed = removedOf(cfg);
43
- return { cfg: removed.includes(name) ? withRemoved(next, removed.filter((n) => n !== name)) : next, changes: [] };
30
+ return { cfg: { ...cfg, agents: { ...agentsOf(cfg), [name]: buildAgent({ adapter }, patch) } }, changes: [] };
44
31
  }
45
32
  export function setAgent(cfg, name, patch) {
46
33
  if (!isDefined(cfg, name))
@@ -49,8 +36,7 @@ export function setAgent(cfg, name, patch) {
49
36
  throw new Error("沒有要修改的欄位(--adapter、--model、--extra-arg 或 -- <command>)");
50
37
  }
51
38
  const agents = agentsOf(cfg);
52
- // 內建 agent 第一次修改時,新增一筆覆寫設定
53
- const base = { ...(agents[name] ?? { adapter: name }) };
39
+ const base = { ...agents[name] };
54
40
  const changes = [];
55
41
  if (patch.adapter !== undefined && patch.adapter !== base.adapter) {
56
42
  const cleared = ADAPTER_FIELDS.filter((f) => base[f] !== undefined && patch[f] === undefined);
@@ -66,10 +52,7 @@ export function removeAgent(cfg, name) {
66
52
  throw new Error(`未定義的 agent:${name}`);
67
53
  const agents = agentsOf(cfg);
68
54
  delete agents[name];
69
- // 內建 agent 沒有設定也存在,要記在 removedAgents 才算移除;覆寫設定一併刪掉
70
- let next = { ...cfg, agents };
71
- if (isBuiltin(name))
72
- next = withRemoved(next, [...removedOf(cfg), name]);
55
+ const next = { ...cfg, agents };
73
56
  const changes = [];
74
57
  const cycle = cfg.cycle;
75
58
  if (cycle?.includes(name)) {
@@ -80,7 +63,7 @@ export function removeAgent(cfg, name) {
80
63
  }
81
64
  else {
82
65
  delete next.cycle;
83
- changes.push("輪替順序因此變空,已刪除 cycle,改回自動偵測已安裝的 CLI");
66
+ changes.push("輪替順序因此變空,已刪除 cycle,改回從 agents 自動偵測已安裝的 CLI");
84
67
  }
85
68
  }
86
69
  return { cfg: next, changes };
@@ -3,8 +3,4 @@ import { codex } from "./codex.js";
3
3
  import { command } from "./command.js";
4
4
  import { gemini } from "./gemini.js";
5
5
  export const ADAPTERS = { claude, codex, gemini, command };
6
- /** 沒有設定時,依序偵測這些已安裝的 CLI 組成輪替順序 */
7
- export const DEFAULT_CYCLE = ["claude", "codex", "gemini"];
8
- /** 扣掉設定裡 removedAgents 之後,仍可使用的內建 agent */
9
- export const builtinAgents = (removed = []) => DEFAULT_CYCLE.filter((n) => !removed.includes(n));
10
6
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,54 @@
1
+ import { existsSync, readdirSync, rmSync } from "node:fs";
2
+ import { git, removeWorktree } from "./git.js";
3
+ import { projectRoot, runDir, runsDir, worktreeDir, worktreesDir } from "./paths.js";
4
+ import { getRun } from "./store.js";
5
+ /**
6
+ * 移除一個 run 的 worktree 與紀錄,分支保留。
7
+ * 不需要 state.json:中斷在建立 worktree 之後、寫入紀錄之前留下的孤兒也能清。
8
+ * 回傳是否真的找到並移除了東西。
9
+ */
10
+ export async function cleanRun(id) {
11
+ // id 會拼進要遞迴刪除的路徑,擋掉空字串、..、斜線
12
+ if (!/^[\w-]+$/.test(id))
13
+ throw new Error(`不合法的 run id:${id}`);
14
+ const root = projectRoot();
15
+ const wt = worktreeDir(id);
16
+ const found = existsSync(wt) || existsSync(runDir(id));
17
+ if (existsSync(wt)) {
18
+ // git 不認得這個資料夾時(登記已被 prune、或 worktree add 做到一半)改成直接刪
19
+ await removeWorktree(root, wt).catch(() => rmSync(wt, { recursive: true, force: true }));
20
+ }
21
+ rmSync(runDir(id), { recursive: true, force: true });
22
+ // 清掉資料夾已不存在的 worktree 登記,否則同名分支之後無法再 checkout
23
+ const before = await git(root, "worktree", "list", "--porcelain");
24
+ await git(root, "worktree", "prune");
25
+ const pruned = before !== (await git(root, "worktree", "list", "--porcelain"));
26
+ return found || pruned;
27
+ }
28
+ /** 已結束、可以安全清掉的階段;其他階段可能還在跑,或要 resume/approve */
29
+ const FINISHED = ["done", "failed"];
30
+ /**
31
+ * `clean --all` 要清的 run:已結束的,加上沒有 state.json 的孤兒(stage 為 undefined)。
32
+ * 讀不懂的 state.json 不算孤兒,保留給使用者自己判斷。
33
+ */
34
+ export function cleanableRuns() {
35
+ const ids = new Set();
36
+ for (const dir of [runsDir(), worktreesDir()])
37
+ if (existsSync(dir))
38
+ for (const id of readdirSync(dir))
39
+ ids.add(id);
40
+ const out = [];
41
+ for (const id of [...ids].sort()) {
42
+ let stage;
43
+ try {
44
+ stage = getRun(id)?.stage;
45
+ }
46
+ catch {
47
+ continue;
48
+ }
49
+ if (stage === undefined || FINISHED.includes(stage))
50
+ out.push({ id, stage });
51
+ }
52
+ return out;
53
+ }
54
+ //# sourceMappingURL=cleanup.js.map
package/dist/cli.js CHANGED
@@ -1,15 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from "commander";
3
- import { existsSync, readFileSync, readdirSync, rmSync } from "node:fs";
3
+ import { readFileSync } from "node:fs";
4
4
  import { join } from "node:path";
5
- import { builtinAgents } from "./agents/index.js";
5
+ import { stdin, stdout } from "node:process";
6
+ import { createInterface } from "node:readline/promises";
7
+ import { config } from "./config.js";
6
8
  import { advance, loadRepoConfig } from "./engine.js";
7
- import { API_KEY_VARS, probeAgent, resolveAgent, runCommand } from "./runner.js";
8
- import { addWorktree, git, removeWorktree } from "./git.js";
9
- import { flowDir, logDir, projectRoot, runDir, worktreeDir } from "./paths.js";
9
+ import { probeAgent, resolveAgent, runCommand } from "./runner.js";
10
+ import { addWorktree, git } from "./git.js";
11
+ import { cleanableRuns, cleanRun } from "./cleanup.js";
12
+ import { describeDetected, detectProjectDefaults } from "./detect.js";
13
+ import { CMD_AGENT, listLogs, localTime, logMark, nextLogFile, renderLog } from "./logs.js";
14
+ import { flowDir, logDir, projectRoot, worktreeDir } from "./paths.js";
10
15
  import { TaskList } from "./schemas.js";
11
16
  import { agentRuns, getRun, listRuns, listSubstitutions, saveRun, usageByAgent } from "./store.js";
12
17
  import { readJsonFile } from "./util.js";
18
+ import { runSetup, SETUP_ADAPTERS } from "./setup.js";
13
19
  import { addAgent, readRawConfig, removeAgent, setAgent, setCycle, writeRawConfig } from "./agentConfig.js";
14
20
  function mustGetRun(id) {
15
21
  const run = getRun(id);
@@ -40,7 +46,14 @@ function printSummary(run) {
40
46
  if (run.stage === "failed") {
41
47
  console.log(`失敗於 ${run.failedStage ?? "?"}`);
42
48
  console.log(`原因 ${run.failureReason ?? "?"}`);
43
- console.log(`\n可檢查 ${logDir(run.id)},必要時直接在 worktree 裡修正,再執行 agentflowctl resume ${run.id}`);
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}`);
44
57
  }
45
58
  if (run.stage === "paused") {
46
59
  console.log(`暫停於 ${run.pausedStage ?? "?"}`);
@@ -61,17 +74,26 @@ async function resolveCycle(flag) {
61
74
  }
62
75
  return wanted;
63
76
  }
77
+ // 沒有內建 agent:只從 agents 裡定義的挑出已安裝的
78
+ const defined = Object.keys(cfg.agents);
79
+ if (!defined.length)
80
+ throw new Error("還沒有設定任何 agent,請先用 agentflowctl agent setup 互動設定,或用 agent add <name> --adapter <adapter> 新增");
64
81
  const found = [];
65
- for (const name of builtinAgents(cfg.removedAgents))
82
+ for (const name of defined)
66
83
  if (await probeAgent(resolveAgent(cfg, name)))
67
84
  found.push(name);
68
85
  if (!found.length)
69
- throw new Error(`沒有偵測到任何 agent CLI(${builtinAgents(cfg.removedAgents).join("、") || "內建 agent 都已移除"}),可用 agentflowctl doctor 檢查`);
86
+ throw new Error(`設定的 agent(${defined.join("、")})都沒有偵測到已安裝的 CLI,可用 agentflowctl doctor 檢查`);
70
87
  return found;
71
88
  }
72
89
  const program = new Command()
73
90
  .name("agentflowctl")
74
- .description("在專案資料夾內執行的 Agent 開發流程:規格 → 計畫 → TDD 實作 → 驗證 → 審查 → PR");
91
+ .description("在專案資料夾內執行的 Agent 開發流程:規格 → 計畫 → TDD 實作 → 驗證 → 審查 → PR")
92
+ .option("-v, --verbose", "執行時印出 agent 的文字、工具呼叫與專案指令(預設只印階段進度)")
93
+ .hook("preAction", (cmd) => {
94
+ if (cmd.opts().verbose)
95
+ config.verbose = true;
96
+ });
75
97
  program
76
98
  .command("run")
77
99
  .description("在目前的專案建立並執行新的 flow")
@@ -95,12 +117,9 @@ program
95
117
  console.log(`[${id}] 🌿 從 ${base} 建立 worktree(分支 ${branch})`);
96
118
  await addWorktree(root, worktreeDir(id), base, branch);
97
119
  console.log(`[${id}] 🤝 agent 輪替順序:${cycle.join(" → ")}`);
98
- // 先裝好相依套件:有些 agent 的沙箱不能連網,無法自己安裝
99
120
  const cfg = loadRepoConfig();
100
- const install = await runCommand({ runId: id, cwd: worktreeDir(id), logFile: join(logDir(id), "install.log") }, cfg.install);
101
- if (!install.ok)
102
- console.log(`[${id}] ⚠️ 安裝相依套件失敗,稍後 verify 階段會再試一次(見 ${join(logDir(id), "install.log")})`);
103
121
  const now = new Date().toISOString();
122
+ // worktree 一建好就寫入紀錄:之後在任何地方中斷,都能用 resume 接續或用 clean 清掉
104
123
  const run = saveRun({
105
124
  id,
106
125
  baseBranch: base,
@@ -116,6 +135,12 @@ program
116
135
  createdAt: now,
117
136
  updatedAt: now,
118
137
  });
138
+ for (const line of describeDetected(readRawConfig(configPath()), detectProjectDefaults(root)))
139
+ console.log(`[${id}] ${line}`);
140
+ // 先裝好相依套件:有些 agent 的沙箱不能連網,無法自己安裝
141
+ const install = await runCommand({ runId: id, cwd: worktreeDir(id), logFile: nextLogFile(logDir(id), "setup", "install", CMD_AGENT), stage: "setup", step: "install" }, cfg.install);
142
+ if (!install.ok)
143
+ console.log(`[${id}] ⚠️ 安裝相依套件失敗,稍後 verify 階段會再試一次(agentflowctl logs ${id} ${install.seq})`);
119
144
  await drive(run);
120
145
  });
121
146
  program
@@ -152,14 +177,29 @@ program
152
177
  console.log(`已取消 ${id}`);
153
178
  });
154
179
  program
155
- .command("clean <id>")
156
- .description("移除 run 的 worktree 與紀錄(分支會保留)")
157
- .action(async (id) => {
158
- mustGetRun(id);
159
- if (existsSync(worktreeDir(id)))
160
- await removeWorktree(projectRoot(), worktreeDir(id));
161
- rmSync(runDir(id), { recursive: true, force: true });
162
- console.log(`已清除 ${id},分支仍保留,不需要時可用 git branch -D 刪除`);
180
+ .command("clean [id]")
181
+ .description("移除 run 的 worktree 與紀錄(分支會保留);--all 清掉所有已結束的 run 與中斷留下的孤兒 worktree")
182
+ .option("--all", "清除所有 done、failed 的 run,以及沒有紀錄的 worktree;進行中、暫停、等待核准的不動", false)
183
+ .action(async (id, opts) => {
184
+ if (opts.all === Boolean(id))
185
+ throw new Error("請指定 run id,或使用 --all(兩者擇一)");
186
+ if (id) {
187
+ if (!(await cleanRun(id)))
188
+ throw new Error(`找不到 run:${id}`);
189
+ console.log(`已清除 ${id},分支仍保留,不需要時可用 git branch -D 刪除`);
190
+ return;
191
+ }
192
+ const targets = cleanableRuns();
193
+ if (!targets.length) {
194
+ await git(projectRoot(), "worktree", "prune");
195
+ console.log("沒有可清除的 run");
196
+ return;
197
+ }
198
+ for (const t of targets) {
199
+ await cleanRun(t.id);
200
+ console.log(`已清除 ${t.id}(${t.stage ?? "沒有紀錄,可能是建立時中斷"})`);
201
+ }
202
+ console.log(`\n共清除 ${targets.length} 個,分支仍保留,不需要時可用 git branch -D 刪除`);
163
203
  });
164
204
  program
165
205
  .command("status <id>")
@@ -207,26 +247,25 @@ function applyEdit(edit, done) {
207
247
  const agent = program.command("agent").description("管理 agent 與 adapter 設定(寫入 flow.config.json)");
208
248
  agent
209
249
  .command("list")
210
- .description("列出內建與自訂 agent、是否已安裝、在輪替中的位置")
250
+ .description("列出設定的 agent、是否已安裝、在輪替中的位置")
211
251
  .action(async () => {
212
252
  const cfg = loadRepoConfig();
213
253
  const cycle = await resolveCycle().catch(() => cfg.cycle ?? []);
214
- const names = [...new Set([...builtinAgents(cfg.removedAgents), ...Object.keys(cfg.agents)])];
254
+ const names = Object.keys(cfg.agents);
255
+ if (!names.length)
256
+ console.log("還沒有設定任何 agent,請用 agent setup 互動設定,或用 agent add <name> --adapter <adapter> 新增");
215
257
  for (const name of names) {
216
258
  const def = resolveAgent(cfg, name);
217
259
  const ok = await probeAgent(def);
218
260
  const pos = cycle.indexOf(name);
219
- const kind = builtinAgents(cfg.removedAgents).includes(name) ? (name in cfg.agents ? "內建(已覆寫)" : "內建") : "自訂";
220
261
  const detail = [
221
262
  `adapter=${def.adapter}`,
222
263
  def.model && `model=${def.model}`,
223
264
  def.extraArgs.length && `extraArgs=${def.extraArgs.join(" ")}`,
224
265
  def.command && `command=${def.command.join(" ")}`,
225
266
  ].filter(Boolean);
226
- console.log(`${ok ? "✅" : "❌"} ${name.padEnd(14)} ${kind.padEnd(8)} ${pos >= 0 ? `輪替 #${pos + 1}` : "不在輪替"} ${detail.join(" ")}`);
267
+ console.log(`${ok ? "✅" : "❌"} ${name.padEnd(14)} ${pos >= 0 ? `輪替 #${pos + 1}` : "不在輪替"} ${detail.join(" ")}`);
227
268
  }
228
- if (cfg.removedAgents.length)
229
- console.log(`\n已移除的內建 agent:${cfg.removedAgents.join("、")}(可用 agent add <name> --adapter <name> 加回)`);
230
269
  console.log(`
231
270
  輪替順序:${cycle.length ? cycle.join(" → ") : "(沒有可用的 agent)"}${cfg.cycle ? "" : "(自動偵測)"}`);
232
271
  });
@@ -250,7 +289,7 @@ agent
250
289
  });
251
290
  agent
252
291
  .command("remove <name>")
253
- .description("刪除 agent(含內建的 claude、codex、gemini),一併從輪替移除;內建的可用 agent add 加回")
292
+ .description("刪除 agent,一併從輪替移除")
254
293
  .action((name) => applyEdit((cfg) => removeAgent(cfg, name), `已移除 ${name}`));
255
294
  agent
256
295
  .command("cycle [names]")
@@ -269,12 +308,36 @@ agent
269
308
  console.log(`⚠️ ${n} 目前找不到可執行的 CLI,run 會失敗,請先安裝或用 agent set 修正`);
270
309
  }
271
310
  });
272
- program
273
- .command("doctor")
274
- .description("檢查可用的 agent CLI 與目前的輪替設定")
311
+ agent
312
+ .command("setup")
313
+ .description("互動式設定:偵測已安裝的 claude、codex、gemini,逐一選擇要不要加入並設定輪替順序")
275
314
  .action(async () => {
315
+ if (!stdin.isTTY)
316
+ throw new Error("agent setup 需要互動式終端機,請改用 agent add");
317
+ const detected = {};
318
+ for (const a of SETUP_ADAPTERS)
319
+ detected[a] = await probeAgent({ adapter: a, extraArgs: [] });
320
+ const rl = createInterface({ input: stdin, output: stdout });
321
+ let edit;
322
+ try {
323
+ edit = await runSetup(readRawConfig(configPath()), { ask: (q) => rl.question(q), detected, log: (l) => console.log(l) });
324
+ }
325
+ finally {
326
+ rl.close();
327
+ }
328
+ if (!edit)
329
+ return;
330
+ const result = edit;
331
+ applyEdit(() => result, "設定完成");
332
+ console.log("");
333
+ await doctor();
334
+ });
335
+ /** 檢查設定的 agent 是否已安裝,印出輪替順序與主要設定 */
336
+ async function doctor() {
276
337
  const cfg = loadRepoConfig();
277
- const names = [...new Set([...builtinAgents(cfg.removedAgents), ...Object.keys(cfg.agents)])];
338
+ const names = Object.keys(cfg.agents);
339
+ if (!names.length)
340
+ console.log("還沒有設定任何 agent,請用 agent setup 互動設定,或用 agent add <name> --adapter <adapter> 新增");
278
341
  for (const name of names) {
279
342
  const def = resolveAgent(cfg, name);
280
343
  const ok = await probeAgent(def);
@@ -286,15 +349,11 @@ program
286
349
  catch (e) {
287
350
  console.log(`\n${e.message}`);
288
351
  }
289
- const leaked = API_KEY_VARS.filter((k) => process.env[k]);
290
- console.log("\n登入方式:訂閱登入(執行 agent 時會移除 API key)");
291
- if (leaked.length) {
292
- console.log(`⚠️ 環境中有 ${leaked.join("、")},agentflowctl 執行 agent 時會移除,但你自己直接執行 CLI 時仍可能改走 API 計費`);
293
- }
294
- console.log(`單一 run 的 agent 執行上限:${cfg.maxAgentRuns} 次`);
352
+ console.log(`\n單一 run 的 agent 執行上限:${cfg.maxAgentRuns} 次`);
295
353
  console.log(`修正策略:${cfg.fixStrategy} 測試與實作分開:${cfg.tddSplit ? "是" : "否"}`);
296
354
  console.log(`程式碼審查人數:${cfg.reviewQuorum} 計畫審查人數:${cfg.planReviewQuorum} 計畫仲裁:${cfg.planArbiter ? "開啟" : "關閉"}`);
297
- });
355
+ }
356
+ program.command("doctor").description("檢查可用的 agent CLI 與目前的輪替設定").action(doctor);
298
357
  program
299
358
  .command("list")
300
359
  .description("列出這個專案的所有 run")
@@ -305,18 +364,29 @@ program
305
364
  }
306
365
  });
307
366
  program
308
- .command("logs <id>")
309
- .description("列出 log 檔,或顯示最新一份")
310
- .option("--latest", "顯示最新一份 log 的內容", false)
311
- .action((id, opts) => {
367
+ .command("logs <id> [seq]")
368
+ .description("列出 log;指定編號(或 --latest)時顯示解析後的內容,最後附上錯誤整理")
369
+ .option("--latest", "顯示最新一份 log", false)
370
+ .option("--raw", "顯示原始內容(agent 的 JSON 行)", false)
371
+ .action((id, seq, opts) => {
312
372
  mustGetRun(id);
313
- const dir = logDir(id);
314
- const files = existsSync(dir) ? readdirSync(dir).sort() : [];
315
- if (!files.length)
373
+ const logs = listLogs(logDir(id));
374
+ if (!logs.length)
316
375
  return console.log("還沒有 log");
317
- if (!opts.latest)
318
- return files.forEach((f) => console.log(join(dir, f)));
319
- console.log(readFileSync(join(dir, files.at(-1)), "utf8"));
376
+ if (!seq && !opts.latest) {
377
+ console.log(" # 結果 階段 步驟 agent 開始時間");
378
+ for (const e of logs) {
379
+ const h = e.header;
380
+ 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
+ }
382
+ console.log(`\n查看內容:agentflowctl logs ${id} <編號>(加 --raw 看原始 JSON)`);
383
+ return;
384
+ }
385
+ const entry = seq ? logs.find((e) => e.seq === Number(seq)) : logs.at(-1);
386
+ if (!entry)
387
+ throw new Error(`找不到 log #${seq}(共 ${logs.length} 份,可用 agentflowctl logs ${id} 列出)`);
388
+ const text = readFileSync(entry.file, "utf8");
389
+ console.log(opts.raw ? text : renderLog(text, entry.file));
320
390
  });
321
391
  program.parseAsync().catch((err) => {
322
392
  console.error(`錯誤:${err.message}`);
package/dist/config.js CHANGED
@@ -5,5 +5,7 @@ export const config = {
5
5
  maxTurns: Number(process.env.AGENTFLOWCTL_MAX_TURNS ?? 80),
6
6
  /** 同一個關卡連續失敗幾次後停止 */
7
7
  maxAttempts: Number(process.env.AGENTFLOWCTL_MAX_ATTEMPTS ?? 3),
8
+ /** 終端機是否印出 agent 的文字、工具呼叫與專案指令;預設安靜,-v 或 AGENTFLOWCTL_VERBOSE=1 開啟 */
9
+ verbose: process.env.AGENTFLOWCTL_VERBOSE === "1",
8
10
  };
9
11
  //# sourceMappingURL=config.js.map
package/dist/detect.js ADDED
@@ -0,0 +1,82 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { RepoConfig } from "./schemas.js";
4
+ const LOCKFILES = [
5
+ ["pnpm-lock.yaml", "pnpm"],
6
+ ["yarn.lock", "yarn"],
7
+ ["bun.lock", "bun"],
8
+ ["bun.lockb", "bun"],
9
+ ["package-lock.json", "npm"],
10
+ ];
11
+ /** 不鎖 lockfile:implement 階段 agent 可能新增依賴 */
12
+ const INSTALL = {
13
+ npm: RepoConfig.parse({}).install,
14
+ pnpm: "pnpm install",
15
+ yarn: "yarn install",
16
+ bun: "bun install",
17
+ };
18
+ /** 執行專案內 bin 的前綴,取代預設指令裡的 npx */
19
+ const EXEC = { npm: "npx", pnpm: "pnpm exec", yarn: "yarn", bun: "bunx" };
20
+ /** 每項檢查會找的 script 名稱,依序取第一個存在的 */
21
+ const CHECK_SCRIPTS = {
22
+ typecheck: ["typecheck", "type-check"],
23
+ lint: ["lint"],
24
+ test: ["test"],
25
+ build: ["build"],
26
+ };
27
+ function readPackageJson(root) {
28
+ try {
29
+ const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
30
+ return pkg && typeof pkg === "object" ? pkg : {};
31
+ }
32
+ catch {
33
+ return {};
34
+ }
35
+ }
36
+ function detectManager(root, pkg) {
37
+ if (typeof pkg.packageManager === "string") {
38
+ const name = pkg.packageManager.split("@")[0];
39
+ if (name === "npm" || name === "pnpm" || name === "yarn" || name === "bun") {
40
+ return { manager: name, source: "package.json 的 packageManager" };
41
+ }
42
+ }
43
+ for (const [file, manager] of LOCKFILES) {
44
+ if (existsSync(join(root, file)))
45
+ return { manager, source: file };
46
+ }
47
+ return { manager: "npm", source: "預設" };
48
+ }
49
+ const withExec = (cmd, manager) => cmd.replace(/^npx /, `${EXEC[manager]} `);
50
+ export function detectProjectDefaults(root) {
51
+ const pkg = readPackageJson(root);
52
+ const { manager, source } = detectManager(root, pkg);
53
+ const defaults = RepoConfig.parse({});
54
+ const scripts = pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
55
+ const checks = defaults.checks.map(({ name, cmd }) => {
56
+ const script = CHECK_SCRIPTS[name]?.find((s) => typeof scripts[s] === "string");
57
+ return { name, cmd: script ? `${manager} run ${script}` : withExec(cmd, manager) };
58
+ });
59
+ return { manager, source, install: INSTALL[manager], test: withExec(defaults.test, manager), checks };
60
+ }
61
+ /** 補上原始設定裡沒寫的 install、test、checks;不是物件就原樣回傳,交給 schema 報錯 */
62
+ export function withProjectDefaults(raw, detected) {
63
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
64
+ return raw;
65
+ const { install, test, checks } = detected;
66
+ return { install, test, checks, ...raw };
67
+ }
68
+ /** run 開始時印出的說明:只列出這次用了偵測結果的欄位 */
69
+ export function describeDetected(raw, detected) {
70
+ const lines = [];
71
+ if (!("install" in raw))
72
+ lines.push(` install:${detected.install}`);
73
+ if (!("test" in raw))
74
+ lines.push(` test:${detected.test}`);
75
+ if (!("checks" in raw))
76
+ lines.push(...detected.checks.map((c) => ` checks.${c.name}:${c.cmd}`));
77
+ if (!lines.length)
78
+ return [];
79
+ const why = detected.source === "預設" ? "預設" : `依 ${detected.source}`;
80
+ return [`🔧 依專案偵測指令:${detected.manager}(${why})`, ...lines];
81
+ }
82
+ //# sourceMappingURL=detect.js.map
package/dist/engine.js CHANGED
@@ -1,8 +1,11 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { z } from "zod";
3
4
  import { config } from "./config.js";
5
+ import { detectProjectDefaults, withProjectDefaults } from "./detect.js";
4
6
  import { changedFiles, commitAll, discardChanges, git, headCommit, resetTo } from "./git.js";
5
7
  import { flowDir, logDir, projectRoot, runDir, worktreeDir } from "./paths.js";
8
+ import { CMD_AGENT, nextLogFile } from "./logs.js";
6
9
  import { exec } from "./proc.js";
7
10
  import { arbiterPanel, availableAgent, fixAgent, planAgent, planFixAgent, reviewers, specAgent, taskAgents } from "./roles.js";
8
11
  import { resolveAgent, runAgent, runCommand } from "./runner.js";
@@ -12,11 +15,11 @@ import { orderTasks } from "./tasks.js";
12
15
  import { readJsonFile, renderPrompt, tail } from "./util.js";
13
16
  // ───────────────────────── 共用工具 ─────────────────────────
14
17
  const info = (run, msg) => console.log(`[${run.id}] ${msg}`);
18
+ const logHint = (run, seq) => `(agentflowctl logs ${run.id} ${seq})`;
15
19
  const flowFile = (run, name) => join(flowDir(run.id), name);
16
20
  const to = (run, stage) => ({ ...run, stage });
17
- function target(run, name) {
18
- const stamp = new Date().toISOString().replace(/[:.]/g, "-");
19
- return { runId: run.id, cwd: worktreeDir(run.id), logFile: join(logDir(run.id), `${stamp}-${name}.log`) };
21
+ function target(run, step, agent) {
22
+ return { runId: run.id, cwd: worktreeDir(run.id), logFile: nextLogFile(logDir(run.id), run.stage, step, agent), stage: run.stage, step };
20
23
  }
21
24
  /** 額度用完而必須停下:審查類步驟,或所有 agent 的額度都用完 */
22
25
  export class QuotaPause extends Error {
@@ -43,7 +46,7 @@ async function agentStep(run, planned, step, prompt, mode) {
43
46
  info(run, `🔁 ${agent} 額度已用完,${step} 由 ${sub} 代打${note ? `(注意:${note})` : ""}`);
44
47
  agent = sub;
45
48
  }
46
- const r = await runAgent(agent, resolveAgent(cfg, agent), target(run, `${step}-${agent}`), prompt);
49
+ const r = await runAgent(agent, resolveAgent(cfg, agent), target(run, step, agent), prompt);
47
50
  addUsage(run.id, { stage: step, agent, inputTokens: r.inputTokens, outputTokens: r.outputTokens });
48
51
  if (!r.quotaExhausted) {
49
52
  reportMeta(run, agent, r);
@@ -72,6 +75,7 @@ function readFeedback(run) {
72
75
  function retry(run, key, reason, backTo) {
73
76
  const n = (run.attempts[key] ?? 0) + 1;
74
77
  const attempts = { ...run.attempts, [key]: n };
78
+ mkdirSync(flowDir(run.id), { recursive: true });
75
79
  writeFileSync(flowFile(run, "feedback.md"), `# 前次嘗試未通過(第 ${n} 次)\n\n${reason}\n`);
76
80
  if (n >= config.maxAttempts) {
77
81
  return { ...run, attempts, stage: "failed", failedStage: backTo, failureReason: `${key} 連續失敗 ${n} 次:${tail(reason, 500)}` };
@@ -86,11 +90,14 @@ function succeed(run, key, next) {
86
90
  return { ...run, attempts, stage: next };
87
91
  }
88
92
  /** 設定檔放在主專案根目錄,未 commit 的修改也會生效 */
93
+ /** 讀取 flow.config.json;沒寫的 install、test、checks 依專案現況偵測 */
89
94
  export function loadRepoConfig() {
90
- const p = join(projectRoot(), "flow.config.json");
95
+ const root = projectRoot();
96
+ const detected = detectProjectDefaults(root);
97
+ const p = join(root, "flow.config.json");
91
98
  if (!existsSync(p))
92
- return RepoConfig.parse({});
93
- const r = readJsonFile(p, RepoConfig);
99
+ return RepoConfig.parse(withProjectDefaults({}, detected));
100
+ const r = readJsonFile(p, z.preprocess((raw) => withProjectDefaults(raw, detected), RepoConfig));
94
101
  if (!r.ok)
95
102
  throw new Error(r.error);
96
103
  return r.data;
@@ -351,7 +358,7 @@ async function implementStage(run) {
351
358
  await resetTo(repo, before);
352
359
  return retry(run, key, `沒有新增或修改任何符合 /${cfg.testPattern}/ 的測試檔。`, "implement");
353
360
  }
354
- const red = await runCommand(target(run, `${task.id}-red`), testCmd);
361
+ const red = await runCommand(target(run, `${task.id}-red`, CMD_AGENT), testCmd);
355
362
  if (red.ok) {
356
363
  await resetTo(repo, before);
357
364
  return retry(run, key, "測試在功能尚未實作前就全部通過,代表測試沒有驗證到新行為。請撰寫會因功能尚未實作而失敗的測試。", "implement");
@@ -376,9 +383,11 @@ async function implementStage(run) {
376
383
  await resetTo(repo, testsCommit);
377
384
  return retry(run, key, `實作階段不可修改測試檔,已還原你的變更:${touched.join(", ")}`, "implement");
378
385
  }
379
- const green = await runCommand(target(run, `${task.id}-green`), testCmd);
380
- if (!green.ok)
386
+ const green = await runCommand(target(run, `${task.id}-green`, CMD_AGENT), testCmd);
387
+ if (!green.ok) {
388
+ info(run, ` ✗ 測試仍未通過${logHint(run, green.seq)}`);
381
389
  return retry(run, key, `測試仍未通過:\n\n\`\`\`\n${tail(green.output)}\n\`\`\``, "implement");
390
+ }
382
391
  info(run, `🟢 [${progress}] 完成`);
383
392
  return {
384
393
  ...succeed(run, key, "implement"),
@@ -393,14 +402,15 @@ async function verifyStage(run) {
393
402
  info(run, "🔍 執行驗證");
394
403
  const cfg = loadRepoConfig();
395
404
  const results = [];
396
- const install = await runCommand(target(run, "verify-install"), cfg.install);
405
+ const install = await runCommand(target(run, "install", CMD_AGENT), cfg.install);
397
406
  if (!install.ok) {
407
+ info(run, ` ✗ install${logHint(run, install.seq)}`);
398
408
  results.push({ name: "install", ok: false, output: tail(install.output) });
399
409
  }
400
410
  else {
401
411
  for (const check of cfg.checks) {
402
- const r = await runCommand(target(run, `verify-${check.name}`), check.cmd);
403
- info(run, ` ${r.ok ? "✓" : "✗"} ${check.name}`);
412
+ const r = await runCommand(target(run, check.name, CMD_AGENT), check.cmd);
413
+ info(run, ` ${r.ok ? "✓" : "✗"} ${check.name}${r.ok ? "" : logHint(run, r.seq)}`);
404
414
  results.push({ name: check.name, ok: r.ok, output: tail(r.output, 3000) });
405
415
  }
406
416
  }
package/dist/logs.js ADDED
@@ -0,0 +1,173 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { ADAPTERS } from "./agents/index.js";
4
+ import { str, tryJson } from "./agents/types.js";
5
+ /**
6
+ * 每次執行 agent 或專案指令都寫成一份 log:
7
+ * - 檔名:<三位數序號>-<階段>-<步驟>-<agent>.log,專案指令的 agent 欄位是 cmd
8
+ * - 第一行:`# agentflowctl {...}`,記錄階段、步驟、agent、開始時間
9
+ * - 中間:agent 的原始 stdout(JSON 行)或指令輸出,原樣保存;stderr 接在 `[stderr]` 之後
10
+ * - 最後一行:`# exit {...}`,記錄結束碼與是否成功;沒有這行代表還在執行或被中斷
11
+ */
12
+ export const HEADER_PREFIX = "# agentflowctl ";
13
+ export const FOOTER_PREFIX = "# exit ";
14
+ export const STDERR_MARK = "[stderr]";
15
+ /** 專案指令(install、測試、checks)的 agent 欄位 */
16
+ export const CMD_AGENT = "cmd";
17
+ const segment = (s) => s.replace(/[\s/\\:*?"<>|]+/g, "_");
18
+ /** 下一份 log 的完整路徑;序號接在目錄裡最大的序號之後 */
19
+ export function nextLogFile(dir, stage, step, agent) {
20
+ const seqs = existsSync(dir) ? readdirSync(dir).map((f) => Number(f.match(/^(\d+)-/)?.[1] ?? 0)) : [];
21
+ const seq = Math.max(0, ...seqs) + 1;
22
+ return join(dir, `${String(seq).padStart(3, "0")}-${segment(stage)}-${segment(step)}-${segment(agent)}.log`);
23
+ }
24
+ export const logSeq = (file) => Number(file.split(/[\\/]/).at(-1)?.match(/^(\d+)-/)?.[1] ?? 0);
25
+ export const headerLine = (h) => `${HEADER_PREFIX}${JSON.stringify(h)}`;
26
+ export const footerLine = (f) => `${FOOTER_PREFIX}${JSON.stringify(f)}`;
27
+ function parseLog(text) {
28
+ const out = { body: [], stderr: [] };
29
+ let inStderr = false;
30
+ for (const line of text.split("\n")) {
31
+ if (line.startsWith(HEADER_PREFIX) && !out.header)
32
+ out.header = tryJson(line.slice(HEADER_PREFIX.length));
33
+ else if (line.startsWith(FOOTER_PREFIX))
34
+ out.footer = tryJson(line.slice(FOOTER_PREFIX.length));
35
+ else if (line === STDERR_MARK)
36
+ inStderr = true;
37
+ else
38
+ (inStderr ? out.stderr : out.body).push(line);
39
+ }
40
+ while (out.stderr.at(-1) === "")
41
+ out.stderr.pop();
42
+ while (out.body.at(-1) === "")
43
+ out.body.pop();
44
+ return out;
45
+ }
46
+ /** 依序號列出 run 的所有 log,只讀檔頭與檔尾 */
47
+ export function listLogs(dir) {
48
+ if (!existsSync(dir))
49
+ return [];
50
+ return readdirSync(dir)
51
+ .filter((f) => /^\d+-.*\.log$/.test(f))
52
+ .map((f) => {
53
+ const file = join(dir, f);
54
+ const { header, footer } = parseLog(readFileSync(file, "utf8"));
55
+ return { seq: logSeq(f), file, header, footer };
56
+ })
57
+ .sort((a, b) => a.seq - b.seq);
58
+ }
59
+ /** 執行結果:✓ 成功、✗ 失敗、… 沒有檔尾(還在執行或被中斷) */
60
+ export const logMark = (e) => (!e.footer ? "…" : e.footer.ok ? "✓" : "✗");
61
+ /** ISO 時間轉成本地時間 YYYY-MM-DD HH:mm:ss */
62
+ export function localTime(iso) {
63
+ const d = iso ? new Date(iso) : undefined;
64
+ if (!d || Number.isNaN(d.getTime()))
65
+ return "?";
66
+ const p = (n) => String(n).padStart(2, "0");
67
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
68
+ }
69
+ const clip = (s, max = 800) => (s.length <= max ? s : `${s.slice(0, max)}…(共 ${s.length} 字)`);
70
+ const indent = (s, pad = " ") => s.split("\n").join(`\n${pad}`);
71
+ /** 從沒被 adapter 認得的 JSON 事件裡找錯誤:頂層的 error 類事件算失敗,工具回報的錯誤只當作提示 */
72
+ function findError(ev) {
73
+ const pick = (o) => {
74
+ const err = o.error;
75
+ const nested = err && typeof err === "object" ? str(err.message) : undefined;
76
+ const content = Array.isArray(o.content)
77
+ ? o.content.map((c) => (c && typeof c === "object" ? str(c.text) : str(c))).filter(Boolean).join("\n")
78
+ : str(o.content);
79
+ return nested ?? str(err) ?? str(o.message) ?? (content || undefined) ?? str(o.result) ?? JSON.stringify(o);
80
+ };
81
+ if (ev.is_error === true || ev.error || /error|fail/i.test(str(ev.type) ?? "") || /error|fail/i.test(str(ev.subtype) ?? "")) {
82
+ return { message: clip(pick(ev)), fatal: true };
83
+ }
84
+ const walk = (v, depth) => {
85
+ if (!v || typeof v !== "object" || depth > 4)
86
+ return undefined;
87
+ if (!Array.isArray(v) && v.is_error === true)
88
+ return v;
89
+ for (const child of Object.values(v)) {
90
+ const hit = walk(child, depth + 1);
91
+ if (hit)
92
+ return hit;
93
+ }
94
+ return undefined;
95
+ };
96
+ const hit = walk(ev, 0);
97
+ return hit ? { message: clip(pick(hit)), fatal: false } : undefined;
98
+ }
99
+ function renderEvent(ev) {
100
+ switch (ev.kind) {
101
+ case "text":
102
+ return `💬 ${indent(ev.text.trim())}`;
103
+ case "tool":
104
+ return `🔧 ${ev.name}${ev.detail ? `: ${indent(ev.detail.trim())}` : ""}`;
105
+ case "usage":
106
+ return `📊 用量 input ${ev.inputTokens ?? "?"} / output ${ev.outputTokens ?? "?"} tokens`;
107
+ case "done":
108
+ return `🏁 ${ev.ok ? "完成" : "失敗"}${ev.summary ? `:${indent(ev.summary.trim())}` : ""}`;
109
+ }
110
+ }
111
+ /** 把一份 log 轉成人看得懂的版本;最後附上錯誤整理,讓失敗原因一眼可見 */
112
+ export function renderLog(text, file = "") {
113
+ const { header, footer, body, stderr } = parseLog(text);
114
+ const out = [];
115
+ const errors = [];
116
+ const title = header
117
+ ? `${header.stage} / ${header.step} / ${header.agent}${header.adapter && header.adapter !== header.agent ? `(adapter ${header.adapter})` : ""}`
118
+ : "(沒有檔頭)";
119
+ out.push(`${logSeq(file) ? `#${logSeq(file)} ` : ""}${title}`);
120
+ out.push(`開始 ${localTime(header?.startedAt)} ` +
121
+ (footer ? `結束 ${localTime(footer.endedAt)} 結束碼 ${footer.code} ${footer.ok ? "✓ 成功" : "✗ 失敗"}` : "… 沒有結束紀錄(還在執行或被中斷)"));
122
+ if (file)
123
+ out.push(`檔案 ${file}`);
124
+ out.push("");
125
+ const adapter = header?.adapter ? ADAPTERS[header.adapter] : undefined;
126
+ if (!adapter || header?.agent === CMD_AGENT) {
127
+ out.push(...body);
128
+ }
129
+ else {
130
+ let skipped = 0;
131
+ for (const line of body) {
132
+ if (!line.trim())
133
+ continue;
134
+ const events = adapter.parse(line);
135
+ if (events.length) {
136
+ for (const ev of events) {
137
+ const s = renderEvent(ev);
138
+ if (s)
139
+ out.push(s);
140
+ if (ev.kind === "done" && !ev.ok)
141
+ errors.push(`agent 回報失敗${ev.summary ? `:${indent(ev.summary.trim())}` : ""}`);
142
+ }
143
+ continue;
144
+ }
145
+ const json = tryJson(line);
146
+ if (!json) {
147
+ out.push(`📄 ${line}`);
148
+ continue;
149
+ }
150
+ const err = findError(json);
151
+ if (!err) {
152
+ skipped++;
153
+ continue;
154
+ }
155
+ out.push(`${err.fatal ? "❌" : "⚠️ "} ${indent(err.message)}`);
156
+ if (err.fatal)
157
+ errors.push(`錯誤事件:${indent(err.message)}`);
158
+ }
159
+ if (skipped)
160
+ out.push(`(另有 ${skipped} 行其他事件未顯示,可用 --raw 查看)`);
161
+ }
162
+ if (footer && footer.code !== 0)
163
+ errors.unshift(`結束碼 ${footer.code}`);
164
+ if (footer && !footer.ok && !errors.length)
165
+ errors.push("執行未通過(沒有更多錯誤訊息)");
166
+ const stderrText = stderr.length ? `stderr:\n ${indent(clip(stderr.join("\n"), 4000))}` : undefined;
167
+ if (errors.length)
168
+ out.push("", "── 錯誤 ──", ...errors, ...(stderrText ? [stderrText] : []));
169
+ else if (stderrText)
170
+ out.push("", "── 其他輸出 ──", stderrText);
171
+ return out.join("\n");
172
+ }
173
+ //# sourceMappingURL=logs.js.map
package/dist/paths.js CHANGED
@@ -14,9 +14,11 @@ export function projectRoot() {
14
14
  return root;
15
15
  }
16
16
  export const agentflowctlDir = () => join(projectRoot(), ".agentflowctl");
17
- export const runDir = (id) => join(agentflowctlDir(), "runs", id);
17
+ export const runsDir = () => join(agentflowctlDir(), "runs");
18
+ export const runDir = (id) => join(runsDir(), id);
18
19
  export const logDir = (id) => join(runDir(id), "logs");
19
20
  /** 每個 run 一個 git worktree,Agent 只在這裡工作,不碰你正在編輯的檔案 */
20
- export const worktreeDir = (id) => join(agentflowctlDir(), "worktrees", id);
21
+ export const worktreesDir = () => join(agentflowctlDir(), "worktrees");
22
+ export const worktreeDir = (id) => join(worktreesDir(), id);
21
23
  export const flowDir = (id) => join(worktreeDir(id), ".flow");
22
24
  //# sourceMappingURL=paths.js.map
package/dist/proc.js CHANGED
@@ -1,12 +1,9 @@
1
1
  import { spawn } from "node:child_process";
2
2
  export function exec(cmd, args, opts = {}) {
3
3
  return new Promise((resolve, reject) => {
4
- const env = { ...process.env, ...opts.env };
5
- for (const key of opts.unsetEnv ?? [])
6
- delete env[key];
7
4
  const child = spawn(cmd, args, {
8
5
  cwd: opts.cwd,
9
- env,
6
+ env: { ...process.env, ...opts.env },
10
7
  shell: opts.shell ?? false,
11
8
  });
12
9
  let stdout = "";
package/dist/runner.js CHANGED
@@ -1,20 +1,12 @@
1
1
  import { appendFileSync, mkdirSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { z } from "zod";
4
- import { ADAPTERS, builtinAgents } from "./agents/index.js";
5
- import { AgentDef } from "./schemas.js";
4
+ import { ADAPTERS } from "./agents/index.js";
6
5
  import { projectRoot, runDir } from "./paths.js";
6
+ import { config } from "./config.js";
7
+ import { CMD_AGENT, footerLine, headerLine, logSeq, STDERR_MARK } from "./logs.js";
7
8
  import { exec, execShell } from "./proc.js";
8
9
  import { tail } from "./util.js";
9
- /** 會讓各家 CLI 改走 API 計費的環境變數;只用訂閱登入,執行 agent 時一律移除 */
10
- export const API_KEY_VARS = [
11
- "ANTHROPIC_API_KEY",
12
- "ANTHROPIC_AUTH_TOKEN",
13
- "CODEX_API_KEY",
14
- "OPENAI_API_KEY",
15
- "GEMINI_API_KEY",
16
- "GOOGLE_API_KEY",
17
- ];
18
10
  /**
19
11
  * 判斷 agent 失敗是否因為方案額度或速率限制。
20
12
  * 各家的錯誤訊息會隨版本變動,這裡用寬鬆的樣式比對,且只在執行失敗時才檢查,避免誤判正常輸出。
@@ -62,17 +54,12 @@ export function formatToolLine(agent, tool) {
62
54
  const detail = tool.detail?.trim();
63
55
  return detail ? `${head}: ${detail.split("\n").join("\n ")}` : head;
64
56
  }
65
- /** 內建的 claude、codex、gemini 定義,可在 flow.config.json 覆寫或新增其他 agent */
57
+ /** 取出 flow.config.json 裡定義的 agent;沒有內建 agent */
66
58
  export function resolveAgent(cfg, name) {
67
- const custom = cfg.agents[name];
68
- if (custom)
69
- return custom;
70
- if (builtinAgents(cfg.removedAgents).includes(name)) {
71
- return AgentDef.parse({ adapter: name });
72
- }
73
- if (cfg.removedAgents.includes(name))
74
- throw new Error(`${name} 已從設定移除(removedAgents),要使用請先 agent add ${name} --adapter ${name}`);
75
- throw new Error(`未定義的 agent:${name}(請在 flow.config.json 的 agents 裡設定)`);
59
+ const def = cfg.agents[name];
60
+ if (!def)
61
+ throw new Error(`未定義的 agent:${name}(請先用 agent add ${name} --adapter <adapter> 新增)`);
62
+ return def;
76
63
  }
77
64
  /** 確認某個 agent 的 CLI 是否可以執行 */
78
65
  export async function probeAgent(def) {
@@ -96,7 +83,7 @@ export async function runAgent(name, def, t, prompt) {
96
83
  projectRoot: projectRoot(),
97
84
  command: def.command,
98
85
  });
99
- appendLog(t.logFile, `# agent=${name} adapter=${def.adapter} cmd=${inv.cmd}`);
86
+ appendLog(t.logFile, headerLine({ stage: t.stage, step: t.step, agent: name, adapter: def.adapter, startedAt: new Date().toISOString() }));
100
87
  let done;
101
88
  let lastText = "";
102
89
  let inputTokens = 0;
@@ -104,7 +91,6 @@ export async function runAgent(name, def, t, prompt) {
104
91
  const r = await exec(inv.cmd, inv.args, {
105
92
  cwd: t.cwd,
106
93
  env: inv.env,
107
- unsetEnv: API_KEY_VARS,
108
94
  input: inv.input,
109
95
  onStdoutLine: (line) => {
110
96
  if (!line.trim())
@@ -113,10 +99,12 @@ export async function runAgent(name, def, t, prompt) {
113
99
  for (const ev of adapter.parse(line)) {
114
100
  if (ev.kind === "text") {
115
101
  lastText = ev.text;
116
- console.log(` 💬 [${name}] ${ev.text.trim().split("\n")[0]?.slice(0, 110)}`);
102
+ if (config.verbose)
103
+ console.log(` 💬 [${name}] ${ev.text.trim().split("\n")[0]?.slice(0, 110)}`);
117
104
  }
118
105
  else if (ev.kind === "tool") {
119
- console.log(formatToolLine(name, ev));
106
+ if (config.verbose)
107
+ console.log(formatToolLine(name, ev));
120
108
  }
121
109
  else if (ev.kind === "usage") {
122
110
  inputTokens += ev.inputTokens ?? 0;
@@ -129,8 +117,11 @@ export async function runAgent(name, def, t, prompt) {
129
117
  },
130
118
  });
131
119
  if (r.stderr.trim())
132
- appendLog(t.logFile, `[stderr]\n${r.stderr}`);
120
+ appendLog(t.logFile, `${STDERR_MARK}\n${r.stderr}`);
133
121
  const ok = r.code === 0 && (done?.ok ?? true);
122
+ appendLog(t.logFile, footerLine({ code: r.code, ok, endedAt: new Date().toISOString() }));
123
+ if (!ok)
124
+ console.log(` ✗ ${name} 執行失敗(結束碼 ${r.code}),可用 agentflowctl logs ${t.runId} ${logSeq(t.logFile)} 查看`);
134
125
  const summary = done?.summary || lastText || tail(r.stdout, 2000) || tail(r.stderr, 2000);
135
126
  const quotaExhausted = !ok && isQuotaError(`${summary}\n${done?.summary ?? ""}\n${r.stderr}\n${tail(r.stdout, 4000)}`);
136
127
  const meta = parseResultMeta(summary) ?? parseResultMeta(lastText);
@@ -141,11 +132,15 @@ export async function runAgent(name, def, t, prompt) {
141
132
  * 注意:這些指令會執行 agent 寫出來的程式碼,而且不在任何沙箱內。
142
133
  */
143
134
  export async function runCommand(t, cmd) {
135
+ appendLog(t.logFile, headerLine({ stage: t.stage, step: t.step, agent: CMD_AGENT, startedAt: new Date().toISOString() }));
144
136
  appendLog(t.logFile, `$ ${cmd}`);
145
- console.log(` $ ${cmd.trim().split("\n").join("\n ")}`);
137
+ if (config.verbose)
138
+ console.log(` $ ${cmd.trim().split("\n").join("\n ")}`);
146
139
  const r = await execShell(cmd, { cwd: t.cwd });
147
140
  const output = `${r.stdout}\n${r.stderr}`.trim();
148
- appendLog(t.logFile, output);
149
- return { ok: r.code === 0, output };
141
+ if (output)
142
+ appendLog(t.logFile, output);
143
+ appendLog(t.logFile, footerLine({ code: r.code, ok: r.code === 0, endedAt: new Date().toISOString() }));
144
+ return { ok: r.code === 0, output, seq: logSeq(t.logFile) };
150
145
  }
151
146
  //# sourceMappingURL=runner.js.map
package/dist/schemas.js CHANGED
@@ -49,11 +49,9 @@ export const AgentDef = z.object({
49
49
  });
50
50
  /** 目標專案可選的 flow.config.json,預設值對應 Vite + TypeScript + Vitest 專案 */
51
51
  export const RepoConfig = z.object({
52
- /** 自訂或覆寫 agent;claude、codex、gemini 三個名稱內建 */
52
+ /** 可用的 agent;沒有內建,全部都要在這裡定義(通常用 agent add) */
53
53
  agents: z.record(z.string(), AgentDef).default({}),
54
- /** 移除的內建 agent:不再自動偵測,也不能放進輪替;用 agent add 加回 */
55
- removedAgents: z.array(z.string()).default([]),
56
- /** 輪替順序;未設定時自動偵測已安裝的 CLI */
54
+ /** 輪替順序;未設定時依 agents 的順序,取已安裝的 CLI */
57
55
  cycle: z.array(z.string()).min(1).optional(),
58
56
  /** review 後的修正由誰做:ring=輪到下一位;author=最後寫程式的 agent */
59
57
  fixStrategy: z.enum(["ring", "author"]).default("ring"),
package/dist/setup.js ADDED
@@ -0,0 +1,70 @@
1
+ import { addAgent, setAgent, setCycle } from "./agentConfig.js";
2
+ /**
3
+ * `agentflowctl agent setup` 的互動精靈。
4
+ * 問答與偵測結果由外部注入,這裡只負責流程;所有修改都在記憶體裡完成,確認後才交給呼叫端寫入。
5
+ */
6
+ export const SETUP_ADAPTERS = ["claude", "codex", "gemini"];
7
+ /** 回傳要寫入的設定;使用者沒選任何 agent 或最後沒確認時回傳 null */
8
+ export async function runSetup(initial, deps) {
9
+ const { detected, log } = deps;
10
+ const ask = async (q, def = "") => (await deps.ask(def ? `${q}(${def}):` : `${q}:`)).trim() || def;
11
+ const confirm = async (q, def) => {
12
+ const a = (await deps.ask(`${q}(${def ? "Y/n" : "y/N"}):`)).trim().toLowerCase();
13
+ return a ? a.startsWith("y") : def;
14
+ };
15
+ let cfg = initial;
16
+ const changes = [];
17
+ const chosen = [];
18
+ const existing = () => Object.keys((cfg.agents ?? {}));
19
+ for (const adapter of SETUP_ADAPTERS) {
20
+ log(`${detected[adapter] ? "✅" : "❌"} ${adapter}${detected[adapter] ? "" : "(沒有偵測到 CLI)"}`);
21
+ if (!(await confirm(` 加入 ${adapter}?`, detected[adapter])))
22
+ continue;
23
+ let name;
24
+ for (;;) {
25
+ name = await ask(" 名稱", adapter);
26
+ if (!/^[\w-]+$/.test(name))
27
+ log(" 名稱只能用英數字、底線與連字號");
28
+ else if (chosen.includes(name))
29
+ log(` ${name} 這次已經用過了`);
30
+ else
31
+ break;
32
+ }
33
+ const isNew = !existing().includes(name);
34
+ if (!isNew && !(await confirm(` ${name} 已存在,要覆寫嗎?`, false))) {
35
+ chosen.push(name);
36
+ continue;
37
+ }
38
+ const model = (await ask(" model(Enter 不指定)")) || undefined;
39
+ const edit = isNew ? addAgent(cfg, name, { adapter, model }) : setAgent(cfg, name, { adapter, model });
40
+ cfg = edit.cfg;
41
+ changes.push(...edit.changes);
42
+ chosen.push(name);
43
+ }
44
+ if (!chosen.length) {
45
+ log("沒有選擇任何 agent,設定沒有變更");
46
+ return null;
47
+ }
48
+ for (;;) {
49
+ const list = (await ask("輪替順序,用逗號分隔", chosen.join(","))).split(",").map((s) => s.trim()).filter(Boolean);
50
+ try {
51
+ cfg = setCycle(cfg, list).cfg;
52
+ break;
53
+ }
54
+ catch (e) {
55
+ log(` ${e.message}`);
56
+ }
57
+ }
58
+ const agents = cfg.agents;
59
+ log("\n即將寫入:");
60
+ for (const name of chosen)
61
+ log(` ${name.padEnd(14)} adapter=${agents[name].adapter}${agents[name].model ? ` model=${agents[name].model}` : ""}`);
62
+ log(` 輪替順序:${cfg.cycle.join(" → ")}`);
63
+ log(" 需要自訂指令的 CLI 請改用 agent add <name> --adapter command -- <指令>");
64
+ if (!(await confirm("寫入 flow.config.json?", true))) {
65
+ log("已取消,設定沒有變更");
66
+ return null;
67
+ }
68
+ return { cfg, changes };
69
+ }
70
+ //# sourceMappingURL=setup.js.map
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agentflowctl",
3
3
  "license": "MIT",
4
- "version": "0.3.0",
4
+ "version": "0.4.0",
5
5
  "description": "跨廠商 AI 開發 harness:Claude Code、Codex、Gemini 輪流實作、審查、修正",
6
6
  "keywords": [
7
7
  "ai",