agentflowctl 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -99,10 +99,11 @@ agentflowctl run --req-file ./req.md --cycle codex,claude --max-agent-runs 40
99
99
  agentflowctl run --req "..." --manual-plan # 計畫通過 AI 審查後,仍停下來等你確認
100
100
 
101
101
  agentflowctl approve f-xxxx # 搭配 --manual-plan
102
- agentflowctl status f-xxxx # 階段、任務進度、各 agent 用量、代打紀錄
102
+ agentflowctl status f-xxxx # 階段、上一步結果、未結交接事項、下一步指令、任務進度、各 agent 用量、代打紀錄
103
103
  agentflowctl list
104
104
  agentflowctl logs f-xxxx # 列出每一份 log 的編號、結果、階段、步驟、agent
105
105
  agentflowctl logs f-xxxx 7 # 解析第 7 份 log,最後附上錯誤整理(--latest 看最新一份)
106
+ agentflowctl logs f-xxxx 7 --full # 逐條顯示 shell 指令,完整顯示多行內容與絕對路徑
106
107
  agentflowctl logs f-xxxx 7 --raw # 原始內容(agent 的 JSON 行)
107
108
  agentflowctl resume f-xxxx # 從暫停、Ctrl-C 或失敗處接續
108
109
  agentflowctl cancel f-xxxx
@@ -182,14 +183,14 @@ agentflowctl clean --all # 清掉所有已結束的 run 與中斷留
182
183
  | 標記 | 內容 |
183
184
  |---|---|
184
185
  | 💬 | agent 的完整文字,不截斷 |
185
- | 🔧 | 工具呼叫與完整參數 |
186
+ | 🔧 | 工具呼叫。連續的 shell 指令(多半是讀檔、搜尋)收成一行 `🔧 shell 指令 ×N`;其他工具只顯示第一行,多行時註明共幾行,worktree 內的絕對路徑改成相對路徑 |
186
187
  | 📊 | token 用量 |
187
- | 🏁 | 最後結果 |
188
+ | 🏁 | 最後結果。與最後一則 💬 相同時不再重印 |
188
189
  | ⚠️ | 工具回報的錯誤。agent 通常會自己換方法繼續,所以不列進錯誤整理 |
189
190
  | ❌ | adapter 不認得的錯誤事件 |
190
191
  | 📄 | 不是 JSON 的輸出行 |
191
192
 
192
- adapter 不認得、也看不出錯誤跡象的 JSON 行不會顯示,只列出行數,要看全部請加 `--raw`。專案指令的 log 本來就是純文字,會原樣顯示。
193
+ 要逐條看 shell 指令、完整的工具內容與重複的最後結果,加 `--full`。adapter 不認得、也看不出錯誤跡象的 JSON 行不會顯示,只列出行數,要看全部請加 `--raw`。專案指令的 log 本來就是純文字,會原樣顯示。
193
194
 
194
195
  最後一段「錯誤」整理出結束碼、agent 回報的失敗、錯誤事件與 stderr:
195
196
 
@@ -199,7 +200,7 @@ adapter 不認得、也看不出錯誤跡象的 JSON 行不會顯示,只列出
199
200
  檔案 /repo/.agentflowctl/runs/f-xxxx/logs/003-plan-plan-codex.log
200
201
 
201
202
  💬 先讀 spec.md 與 acceptance.json
202
- 🔧 shell: bash -lc 'cat .flow/spec.md'
203
+ 🔧 shell 指令 ×1(--full 查看)
203
204
  🏁 失敗:stream disconnected before completion
204
205
 
205
206
  ── 錯誤 ──
@@ -211,9 +212,33 @@ stderr:
211
212
 
212
213
  執行成功時,stderr 會放在「其他輸出」段落,不算錯誤。
213
214
 
215
+ ### 停下來時的結果與下一步
216
+
217
+ run 因 Ctrl-C、失敗、額度暫停或等待核准而停下時,終端機會直接印出三段;`agentflowctl status <id>` 也會印同樣的內容:
218
+
219
+ - **結果**:被中斷、還沒有結束紀錄的步驟,以及上一步的結果。agent 的步驟取回覆 `<result>` 的摘要與疑慮;失敗時優先顯示失敗的那一步,並附上結束碼、錯誤事件、stderr 或指令輸出的最後幾行。
220
+ - **未結交接事項**:交接紀錄裡還沒結案的 action 事項(open 或 proposed_resolved)。
221
+ - **下一步**:依狀態列出可以執行的指令,例如 `logs`、`cd` 到 worktree、`resume`、`cancel`、`approve`。
222
+
223
+ ```
224
+ ── 結果 ──
225
+ 中斷於 #20 plan_review / plan-review / codex(沒有結束紀錄,resume 時會重跑這一步)
226
+ #19 plan_fix / plan-fix / claude ✓
227
+ 摘要:接受三條審查意見,拆分 T-3、T-5
228
+ 疑慮:T-15 可能仍太大
229
+
230
+ ── 未結交接事項 ──
231
+ [plan] c9c730b280e87178 T-3、T-5 各混合多個獨立行為(proposed_resolved)
232
+
233
+ ── 下一步 ──
234
+ agentflowctl logs f-xxxx 19 看上一步的完整 log
235
+ agentflowctl resume f-xxxx 從 plan_review 接續
236
+ agentflowctl cancel f-xxxx 放棄這個 run
237
+ ```
238
+
214
239
  ### 出錯時怎麼查
215
240
 
216
- 1. run 停下時印出的摘要,或 `agentflowctl status <id>`,會列出失敗的階段、原因、最後一份 log,以及最近失敗的那一份。
241
+ 1. 先看 run 停下時印出的「結果」與「下一步」,或執行 `agentflowctl status <id>`。
217
242
  2. `agentflowctl logs <id> <編號>` 看那份 log 的錯誤段落。
218
243
  3. 解析結果看不出原因時,加 `--raw` 看原始輸出。
219
244
  4. 必要時直接在 worktree(`.agentflowctl/worktrees/<id>`)裡修正,再執行 `agentflowctl resume <id>`。
@@ -298,9 +323,9 @@ agentflowctl agent cycle claude-strong,codex,gemini # 不帶參數時顯
298
323
 
299
324
  人工確認計畫是為了擋住方向錯了還一路做下去。預設用三層機制取代它;加上 `--manual-plan` 時,三層都過了仍會停下來等你。
300
325
 
301
- **格式與覆蓋率。** 每次撰寫或修改計畫之後,都要重新通過 zod、任務相依、無循環、每條驗收條件都有任務負責。沒過就還原。
326
+ **格式與覆蓋率。** 每次撰寫或修改計畫之後,都要重新通過 zod、任務相依、無循環、每條驗收條件都有任務負責,而且每個任務最多對應兩條驗收條件(一次只做一件事,最多兩件)。沒過就還原。
302
327
 
303
- **跨模型審查。** 審查看需求覆蓋、驗收條件能不能測、任務大小與技術方向。審查者只能寫意見。若改了規格或計畫,檔案會被還原。修改者要在 `plan.md` 的「審查回應」逐條回覆;不同意要寫理由。
328
+ **跨模型審查。** 審查看需求覆蓋、驗收條件能不能測且一條只寫一個行為、任務是否只做一件事(最多兩件)與技術方向。審查者只能寫意見。若改了規格或計畫,檔案會被還原。修改者要在 `plan.md` 的「審查回應」逐條回覆;不同意要寫理由。
304
329
 
305
330
  **僵持時仲裁。** 兩種情況會觸發:這輪審查意見和上一輪一樣,或已達重試上限。仲裁者只判斷一件事:照這份計畫實作,能不能滿足需求。
306
331
 
@@ -310,7 +335,7 @@ agentflowctl agent cycle claude-strong,codex,gemini # 不帶參數時顯
310
335
  | 兩家 | 兩家各自在全新 context 裡判斷 | 都核准就繼續;都不核准就依裁決意見修訂並重新審查;分歧依 `tieBreak` |
311
336
  | 一家 | 同一家 | 由它自己仲裁 |
312
337
 
313
- 兩家時的仲裁是雙盲的。仲裁者只看計畫,以及一份不含模型名稱的爭議清單(`.flow/dispute.md`)。帶有名稱的審查紀錄移到 worktree 以外。`tieBreak` 預設 `proceed`,因為後面還有測試紅燈、綠燈、verify 與程式碼審查。
338
+ 兩家時的仲裁是雙盲的。仲裁者只看計畫,以及一份不含審查者名稱的爭議清單(`.flow/dispute.md`)。爭議清單用 `<issue>` 包住每則意見;給修訂者的 `.flow/feedback.md` 則用 `<opinion author="…">` 包住每位審查者的意見,避免意見內文與外層結構混淆。帶有名稱的審查紀錄移到 worktree 以外。`tieBreak` 預設 `proceed`,因為後面還有測試紅燈、綠燈、verify 與程式碼審查。
314
339
 
315
340
  計畫定案或仲裁最終停止時,裁決與每位仲裁者的理由附在 `plan.md` 最後的「仲裁紀錄」。需再修訂時,裁決理由寫進 `.flow/feedback.md`,供修訂者處理;重新審查會從第一輪計數。原始審查與每輪仲裁紀錄在 `.agentflowctl/runs/<id>/reviews/`。兩家都要求修改時不因仲裁輪數而直接失敗;整個 run 仍受 `maxAgentRuns` 限制。
316
341
 
@@ -321,7 +346,7 @@ agentflowctl agent cycle claude-strong,codex,gemini # 不帶參數時顯
321
346
  | 階段 | 負責的 agent | 程式認定通過的條件 | 失敗時 |
322
347
  |---|---|---|---|
323
348
  | spec | 隨機一位 | 檔案存在、zod 驗證、id 不重複 | 重試 |
324
- | plan | 與 spec 同一位 | zod、相依存在、無循環、每條驗收條件都有任務 | 重試 |
349
+ | plan | 與 spec 同一位 | zod、相依存在、無循環、每條驗收條件都有任務、每個任務最多兩條驗收條件 | 重試 |
325
350
  | plan_review | 計畫作者以外隨機挑(可多位,不重複) | 所有審查者都 `approve` | 進入 plan_fix |
326
351
  | plan_fix | 依 `fixStrategy` | 修改後仍通過 plan 的格式與 DAG 檢查 | 還原並重試 |
327
352
  | 仲裁 | 見上一節 | 一致核准;分歧依 `tieBreak` | 兩家都不核准時進入 plan_fix 再審查;第三方不核准或 `tieBreak: stop` 時失敗 |
@@ -411,6 +436,7 @@ src/
411
436
  roles.ts 角色分配規則(含計畫修正者與仲裁者)
412
437
  runner.ts 執行 agent、正規化結果、執行專案指令
413
438
  logs.ts log 檔名、檔頭檔尾、列表與解析
439
+ stopReport.ts run 停下時的結果、未結交接事項與下一步指令
414
440
  agents/ claude、codex、gemini、command
415
441
  setup.ts agent setup 互動精靈
416
442
  git.ts worktree 與 git 操作
package/dist/cli.js CHANGED
@@ -15,6 +15,8 @@ import { flowDir, logDir, projectRoot, worktreeDir } from "./paths.js";
15
15
  import { TaskList } from "./schemas.js";
16
16
  import { agentRuns, getRun, listRuns, listSubstitutions, saveRun, usageByAgent } from "./store.js";
17
17
  import { readJsonFile } from "./util.js";
18
+ import { openActions, readHandoff } from "./handoff.js";
19
+ import { stopReport } from "./stopReport.js";
18
20
  import { runSetup, SETUP_ADAPTERS } from "./setup.js";
19
21
  import { addAgent, readRawConfig, removeAgent, setAgent, setCycle, writeRawConfig } from "./agentConfig.js";
20
22
  function mustGetRun(id) {
@@ -26,12 +28,13 @@ function mustGetRun(id) {
26
28
  async function drive(run) {
27
29
  // Ctrl-C 會同時送給子程序(claude、測試指令),狀態已經寫在 state.json,之後可用 resume 接續
28
30
  process.once("SIGINT", () => {
29
- console.log(`\n已中斷,之後可用 agentflowctl resume ${run.id} 接續`);
31
+ console.log("\n已中斷");
32
+ printSummary(getRun(run.id) ?? run, true);
30
33
  process.exit(130);
31
34
  });
32
35
  printSummary(await advance(run));
33
36
  }
34
- function printSummary(run) {
37
+ function printSummary(run, interrupted = false) {
35
38
  console.log("");
36
39
  console.log(`run ${run.id}`);
37
40
  console.log(`階段 ${run.stage}`);
@@ -46,22 +49,21 @@ function printSummary(run) {
46
49
  if (run.stage === "failed") {
47
50
  console.log(`失敗於 ${run.failedStage ?? "?"}`);
48
51
  console.log(`原因 ${run.failureReason ?? "?"}`);
49
- const logs = listLogs(logDir(run.id));
50
- const last = logs.at(-1);
51
- const lastFailed = logs.filter((e) => e.footer && !e.footer.ok).at(-1);
52
- if (last)
53
- console.log(`最後的 log #${last.seq} ${logMark(last)}(agentflowctl logs ${run.id} ${last.seq})`);
54
- if (lastFailed && lastFailed !== last)
55
- console.log(`最近失敗的 log #${lastFailed.seq}(agentflowctl logs ${run.id} ${lastFailed.seq})`);
56
- console.log(`\n必要時直接在 worktree 裡修正,再執行 agentflowctl resume ${run.id}`);
57
52
  }
58
53
  if (run.stage === "paused") {
59
54
  console.log(`暫停於 ${run.pausedStage ?? "?"}`);
60
55
  console.log(`原因 ${run.pauseReason ?? "?"}`);
61
- console.log(`\n額度恢復後執行 agentflowctl resume ${run.id}`);
62
56
  }
63
- if (run.stage === "awaiting_approval")
64
- console.log(`\n確認計畫後執行 agentflowctl approve ${run.id}`);
57
+ const report = stopReport({
58
+ run,
59
+ interrupted,
60
+ logs: listLogs(logDir(run.id)),
61
+ read: (file) => readFileSync(file, "utf8"),
62
+ open: openActions(readHandoff(run.id)),
63
+ worktree: worktreeDir(run.id),
64
+ });
65
+ for (const line of report)
66
+ console.log(line);
65
67
  }
66
68
  /** 決定參與的 agent:指令參數 > flow.config.json > 自動偵測已安裝的 CLI */
67
69
  async function resolveCycle(flag) {
@@ -367,6 +369,7 @@ program
367
369
  .command("logs <id> [seq]")
368
370
  .description("列出 log;指定編號(或 --latest)時顯示解析後的內容,最後附上錯誤整理")
369
371
  .option("--latest", "顯示最新一份 log", false)
372
+ .option("--full", "逐條顯示 shell 指令,完整顯示工具內容(多行指令、絕對路徑)與重複的最後回覆", false)
370
373
  .option("--raw", "顯示原始內容(agent 的 JSON 行)", false)
371
374
  .action((id, seq, opts) => {
372
375
  mustGetRun(id);
@@ -379,14 +382,14 @@ program
379
382
  const h = e.header;
380
383
  console.log(`${String(e.seq).padStart(3)} ${logMark(e).padEnd(4)} ${(h?.stage ?? "?").padEnd(12)} ${(h?.step ?? "?").padEnd(19)} ${(h?.agent ?? "?").padEnd(10)} ${localTime(h?.startedAt)}`);
381
384
  }
382
- console.log(`\n查看內容:agentflowctl logs ${id} <編號>(加 --raw 看原始 JSON)`);
385
+ console.log(`\n查看內容:agentflowctl logs ${id} <編號>(加 --full 看完整工具內容、--raw 看原始 JSON)`);
383
386
  return;
384
387
  }
385
388
  const entry = seq ? logs.find((e) => e.seq === Number(seq)) : logs.at(-1);
386
389
  if (!entry)
387
390
  throw new Error(`找不到 log #${seq}(共 ${logs.length} 份,可用 agentflowctl logs ${id} 列出)`);
388
391
  const text = readFileSync(entry.file, "utf8");
389
- console.log(opts.raw ? text : renderLog(text, entry.file));
392
+ console.log(opts.raw ? text : renderLog(text, entry.file, { full: opts.full }));
390
393
  });
391
394
  program.parseAsync().catch((err) => {
392
395
  console.error(`錯誤:${err.message}`);
package/dist/engine.js CHANGED
@@ -4,6 +4,7 @@ import { z } from "zod";
4
4
  import { config } from "./config.js";
5
5
  import { arbitrationDecision } from "./arbitration.js";
6
6
  import { detectProjectDefaults, withProjectDefaults } from "./detect.js";
7
+ import { escapeXml, opinion, reviewIssue } from "./feedback.js";
7
8
  import { changedFiles, commitAll, discardChanges, git, headCommit, resetTo } from "./git.js";
8
9
  import { acceptHandoff, openActions, prepareHandoff, previewHandoff, readHandoff, recoverHandoff, reviewHandoffGate, validateHandoffResponse } from "./handoff.js";
9
10
  import { flowDir, logDir, projectRoot, runDir, worktreeDir } from "./paths.js";
@@ -34,10 +35,13 @@ const exhausted = new Set();
34
35
  function handoffTarget(run) {
35
36
  return ["spec", "plan", "plan_review", "plan_fix"].includes(run.stage) ? "plan" : "code";
36
37
  }
37
- /** 同一輪重跑使用相同 key;重試次數或 panel 位置改變時使用新 key。 */
38
+ /**
39
+ * 每次執行 agent 都用新的 key。重試次數會在後面的輪次重複出現,只靠它組 key 會撞到先前已套用的呼叫,
40
+ * 讓這次的處置被當成重播而略過,所以加上這個 run 已執行 agent 的次數。
41
+ */
38
42
  function handoffKey(run, step, slot, agent) {
39
43
  const attempts = Object.entries(run.attempts).sort(([a], [b]) => a.localeCompare(b));
40
- return JSON.stringify([run.id, run.stage, step, run.taskIndex, run.taskPhase, attempts, slot, agent]);
44
+ return JSON.stringify([run.id, run.stage, step, run.taskIndex, run.taskPhase, attempts, slot, agent, agentRuns(run.id)]);
41
45
  }
42
46
  async function agentStep(run, planned, step, prompt, mode) {
43
47
  const cfg = loadRepoConfig();
@@ -284,9 +288,9 @@ async function planReviewStage(run) {
284
288
  firstObjector ??= reviewer;
285
289
  const lines = review.data.items
286
290
  .filter((i) => i.status !== "met")
287
- .map((i) => `- **${i.criterion}**(${i.status}):${i.note}`);
291
+ .map((i) => reviewIssue(i.criterion, i.status, i.note));
288
292
  issueLines.push(...lines);
289
- issues.push(`### ${reviewer} 的意見\n\n${lines.join("\n")}`);
293
+ issues.push(opinion(reviewer, lines));
290
294
  }
291
295
  if (!firstObjector)
292
296
  return planSettled(run, "plan-review");
@@ -375,9 +379,10 @@ async function arbitratePlan(run) {
375
379
  mkdirSync(join(runDir(run.id), "reviews"), { recursive: true });
376
380
  renameSync(flowFile(run, "plan-arbiter.json"), join(runDir(run.id), "reviews", `plan-arbiter-${arbitrationRound}-${arbiter}.json`));
377
381
  const verdict = result.data.verdict;
378
- const notes = result.data.items.map((i) => `- ${i.criterion}:${i.note}`);
382
+ const notes = result.data.items.map((i) => `<issue criterion="${escapeXml(i.criterion)}">${escapeXml(i.note)}</issue>`);
383
+ const markdownNotes = result.data.items.map((i) => `- ${i.criterion}:${i.note}`);
379
384
  info(run, ` ${verdict === "approve" ? "✓" : "✗"} ${arbiter}:${verdict === "approve" ? "可以執行" : "不可執行"}`);
380
- verdicts.push({ arbiter, verdict, notes });
385
+ verdicts.push({ arbiter, verdict, notes, markdownNotes });
381
386
  }
382
387
  rmSync(flowFile(run, "dispute.md"), { force: true });
383
388
  const approvals = verdicts.filter((v) => v.verdict === "approve").length;
@@ -390,16 +395,16 @@ async function arbitratePlan(run) {
390
395
  : approvals === 0
391
396
  ? `${mode}沒有任何一方核准${decision === "revise" ? ",交回計畫修訂" : ""}`
392
397
  : `${mode}意見分歧,依 tieBreak=${cfg.tieBreak} ${cfg.tieBreak === "proceed" ? "繼續實作" : "停止"}`;
393
- const record = verdicts.map((v) => `### ${v.arbiter}(${v.verdict})\n\n${v.notes.join("\n")}`).join("\n\n");
398
+ const feedbackRecord = verdicts.map((v) => opinion(v.arbiter, v.notes, v.verdict)).join("\n\n");
394
399
  if (decision === "revise") {
395
400
  const attempts = { ...run.attempts, "plan-arbitration": arbitrationRound };
396
401
  delete attempts["plan-review"];
397
402
  rmSync(flowFile(run, "plan-review-last.txt"), { force: true });
398
- writeFileSync(flowFile(run, "feedback.md"), `# 仲裁要求修訂(第 ${arbitrationRound} 次)\n\n${summary}\n\n${record}\n`);
403
+ writeFileSync(flowFile(run, "feedback.md"), `# 仲裁要求修訂(第 ${arbitrationRound} 次)\n\n${summary}\n\n${feedbackRecord}\n`);
399
404
  info(run, ` → ${summary}`);
400
405
  return { ...run, attempts, stage: "plan_fix" };
401
406
  }
402
- writeFileSync(flowFile(run, "plan.md"), `${readFileSync(flowFile(run, "plan.md"), "utf8")}\n\n## 仲裁紀錄\n\n**結果:${summary}**\n\n${record}\n`);
407
+ writeFileSync(flowFile(run, "plan.md"), `${readFileSync(flowFile(run, "plan.md"), "utf8")}\n\n## 仲裁紀錄\n\n**結果:${summary}**\n\n${verdicts.map((v) => `### ${v.arbiter}(${v.verdict})\n\n${v.markdownNotes.join("\n")}`).join("\n\n")}\n`);
403
408
  if (decision === "proceed") {
404
409
  info(run, ` → ${summary}`);
405
410
  return planSettled(run, "plan-review");
@@ -582,11 +587,9 @@ async function reviewStage(run) {
582
587
  }
583
588
  info(run, ` ✗ ${reviewer} 要求修改`);
584
589
  firstObjector ??= reviewer;
585
- issues.push(`### ${reviewer} 的意見\n\n` +
586
- review.data.items
587
- .filter((i) => i.status !== "met")
588
- .map((i) => `- **${i.criterion}**(${i.status}):${i.note}`)
589
- .join("\n"));
590
+ issues.push(opinion(reviewer, review.data.items
591
+ .filter((i) => i.status !== "met")
592
+ .map((i) => reviewIssue(i.criterion, i.status, i.note))));
590
593
  }
591
594
  if (!firstObjector)
592
595
  return succeed(run, "review", "pr");
@@ -0,0 +1,15 @@
1
+ /** agent 提供的文字必須跳脫,才不會打斷意見的 XML 邊界。 */
2
+ export function escapeXml(value) {
3
+ return value.replace(/[&<>"']/g, (char) => ({
4
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&apos;",
5
+ })[char]);
6
+ }
7
+ export function reviewIssue(criterion, status, note) {
8
+ return `<issue criterion="${escapeXml(criterion)}" status="${escapeXml(status)}">${escapeXml(note)}</issue>`;
9
+ }
10
+ export function opinion(author, issues, verdict) {
11
+ const name = escapeXml(author);
12
+ const outcome = verdict ? ` verdict="${escapeXml(verdict)}"` : "";
13
+ return `<opinion author="${name}"${outcome}>\n${issues.join("\n")}\n</opinion>`;
14
+ }
15
+ //# sourceMappingURL=feedback.js.map
package/dist/logs.js CHANGED
@@ -2,6 +2,7 @@ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { ADAPTERS } from "./agents/index.js";
4
4
  import { str, tryJson } from "./agents/types.js";
5
+ import { parseResultMeta } from "./resultMeta.js";
5
6
  /**
6
7
  * 每次執行 agent 或專案指令都寫成一份 log:
7
8
  * - 檔名:<三位數序號>-<階段>-<步驟>-<agent>.log,專案指令的 agent 欄位是 cmd
@@ -96,47 +97,76 @@ function findError(ev) {
96
97
  const hit = walk(ev, 0);
97
98
  return hit ? { message: clip(pick(hit)), fatal: false } : undefined;
98
99
  }
99
- function renderEvent(ev) {
100
+ /** worktree 絕對路徑改成相對路徑:`<worktree>/x` → `x`,單獨的 `<worktree>` → `.` */
101
+ const relativeToWorktree = (s) => s.replace(/[^\s'"=]*\/\.agentflowctl\/worktrees\/[^/\s'"]+(\/)?/g, (_m, slash) => (slash ? "" : "."));
102
+ /** 精簡顯示的工具內容:只留第一行,並註明原本有幾行 */
103
+ function compactDetail(detail) {
104
+ const lines = relativeToWorktree(detail.trim()).split("\n");
105
+ const first = clip(lines[0], 200);
106
+ return lines.length > 1 ? `${first} …(共 ${lines.length} 行)` : first;
107
+ }
108
+ /** 各家 CLI 執行 shell 指令的工具名稱:codex 的 shell、claude 的 Bash、gemini 的 run_shell_command */
109
+ const SHELL_TOOLS = new Set(["shell", "bash", "run_shell_command"]);
110
+ const isShellTool = (ev) => ev.kind === "tool" && SHELL_TOOLS.has(ev.name.toLowerCase());
111
+ function renderEvent(ev, full, lastText) {
100
112
  switch (ev.kind) {
101
113
  case "text":
102
114
  return `💬 ${indent(ev.text.trim())}`;
103
- case "tool":
104
- return `🔧 ${ev.name}${ev.detail ? `: ${indent(ev.detail.trim())}` : ""}`;
115
+ case "tool": {
116
+ const detail = ev.detail?.trim();
117
+ if (!detail)
118
+ return `🔧 ${ev.name}`;
119
+ return `🔧 ${ev.name}: ${full ? indent(detail) : compactDetail(detail)}`;
120
+ }
105
121
  case "usage":
106
122
  return `📊 用量 input ${ev.inputTokens ?? "?"} / output ${ev.outputTokens ?? "?"} tokens`;
107
- case "done":
108
- return `🏁 ${ev.ok ? "完成" : "失敗"}${ev.summary ? `:${indent(ev.summary.trim())}` : ""}`;
123
+ case "done": {
124
+ const summary = ev.summary?.trim();
125
+ // 最後一則回覆通常就是 summary,精簡模式不再重印一次
126
+ const show = summary && (full || summary !== lastText);
127
+ return `🏁 ${ev.ok ? "完成" : "失敗"}${show ? `:${indent(summary)}` : ""}`;
128
+ }
109
129
  }
110
130
  }
111
- /** 把一份 log 轉成人看得懂的版本;最後附上錯誤整理,讓失敗原因一眼可見 */
112
- export function renderLog(text, file = "") {
131
+ function analyzeLog(text, full) {
113
132
  const { header, footer, body, stderr } = parseLog(text);
114
- const out = [];
133
+ const lines = [];
115
134
  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("");
135
+ let lastText;
136
+ let doneSummary;
125
137
  const adapter = header?.adapter ? ADAPTERS[header.adapter] : undefined;
126
138
  if (!adapter || header?.agent === CMD_AGENT) {
127
- out.push(...body);
139
+ lines.push(...body);
140
+ lastText = body.slice(-15).join("\n").trim() || undefined;
128
141
  }
129
142
  else {
130
143
  let skipped = 0;
144
+ // 精簡模式下連續的 shell 指令(多半是讀檔、搜尋)收成一行,只留數量
145
+ let shells = 0;
146
+ const flushShells = () => {
147
+ if (shells)
148
+ lines.push(`🔧 shell 指令 ×${shells}(--full 查看)`);
149
+ shells = 0;
150
+ };
131
151
  for (const line of body) {
132
152
  if (!line.trim())
133
153
  continue;
134
154
  const events = adapter.parse(line);
135
155
  if (events.length) {
136
156
  for (const ev of events) {
137
- const s = renderEvent(ev);
157
+ if (!full && isShellTool(ev)) {
158
+ shells++;
159
+ continue;
160
+ }
161
+ if (ev.kind !== "usage")
162
+ flushShells();
163
+ const s = renderEvent(ev, full, lastText);
138
164
  if (s)
139
- out.push(s);
165
+ lines.push(s);
166
+ if (ev.kind === "text")
167
+ lastText = ev.text.trim();
168
+ if (ev.kind === "done")
169
+ doneSummary = ev.summary?.trim() || undefined;
140
170
  if (ev.kind === "done" && !ev.ok)
141
171
  errors.push(`agent 回報失敗${ev.summary ? `:${indent(ev.summary.trim())}` : ""}`);
142
172
  }
@@ -144,7 +174,8 @@ export function renderLog(text, file = "") {
144
174
  }
145
175
  const json = tryJson(line);
146
176
  if (!json) {
147
- out.push(`📄 ${line}`);
177
+ flushShells();
178
+ lines.push(`📄 ${line}`);
148
179
  continue;
149
180
  }
150
181
  const err = findError(json);
@@ -152,22 +183,53 @@ export function renderLog(text, file = "") {
152
183
  skipped++;
153
184
  continue;
154
185
  }
155
- out.push(`${err.fatal ? "❌" : "⚠️ "} ${indent(err.message)}`);
186
+ flushShells();
187
+ lines.push(`${err.fatal ? "❌" : "⚠️ "} ${indent(err.message)}`);
156
188
  if (err.fatal)
157
189
  errors.push(`錯誤事件:${indent(err.message)}`);
158
190
  }
191
+ flushShells();
159
192
  if (skipped)
160
- out.push(`(另有 ${skipped} 行其他事件未顯示,可用 --raw 查看)`);
193
+ lines.push(`(另有 ${skipped} 行其他事件未顯示,可用 --raw 查看)`);
161
194
  }
162
195
  if (footer && footer.code !== 0)
163
196
  errors.unshift(`結束碼 ${footer.code}`);
164
197
  if (footer && !footer.ok && !errors.length)
165
198
  errors.push("執行未通過(沒有更多錯誤訊息)");
166
199
  const stderrText = stderr.length ? `stderr:\n ${indent(clip(stderr.join("\n"), 4000))}` : undefined;
200
+ return { header, footer, lines, errors, stderrText, lastText, doneSummary };
201
+ }
202
+ const logTitle = (header) => header
203
+ ? `${header.stage} / ${header.step} / ${header.agent}${header.adapter && header.adapter !== header.agent ? `(adapter ${header.adapter})` : ""}`
204
+ : "(沒有檔頭)";
205
+ /** 把一份 log 轉成人看得懂的版本;最後附上錯誤整理,讓失敗原因一眼可見。預設精簡工具內容,opts.full 時完整顯示 */
206
+ export function renderLog(text, file = "", opts = {}) {
207
+ const { header, footer, lines, errors, stderrText } = analyzeLog(text, opts.full ?? false);
208
+ const out = [];
209
+ out.push(`${logSeq(file) ? `#${logSeq(file)} ` : ""}${logTitle(header)}`);
210
+ out.push(`開始 ${localTime(header?.startedAt)} ` +
211
+ (footer ? `結束 ${localTime(footer.endedAt)} 結束碼 ${footer.code} ${footer.ok ? "✓ 成功" : "✗ 失敗"}` : "… 沒有結束紀錄(還在執行或被中斷)"));
212
+ if (file)
213
+ out.push(`檔案 ${file}`);
214
+ out.push("", ...lines);
167
215
  if (errors.length)
168
216
  out.push("", "── 錯誤 ──", ...errors, ...(stderrText ? [stderrText] : []));
169
217
  else if (stderrText)
170
218
  out.push("", "── 其他輸出 ──", stderrText);
171
219
  return out.join("\n");
172
220
  }
221
+ /** 一份 log 的結果摘要:給 run 停下時直接印在終端機,不用再另外查 log */
222
+ export function summarizeLog(text) {
223
+ const a = analyzeLog(text, false);
224
+ const reply = a.doneSummary ?? a.lastText;
225
+ const meta = reply ? parseResultMeta(reply) ?? (a.lastText ? parseResultMeta(a.lastText) : undefined) : undefined;
226
+ const failed = a.footer ? !a.footer.ok : false;
227
+ return {
228
+ title: logTitle(a.header),
229
+ ok: a.footer?.ok,
230
+ meta,
231
+ text: meta ? undefined : reply && clip(reply, 600),
232
+ errors: failed ? [...a.errors, ...(a.stderrText ? [a.stderrText] : [])] : [],
233
+ };
234
+ }
173
235
  //# sourceMappingURL=logs.js.map
@@ -0,0 +1,24 @@
1
+ import { z } from "zod";
2
+ /** Agent 回覆結尾的 <result> 中繼資料(格式定義在各 prompt 的 <reply_format>) */
3
+ export const ResultMeta = z.object({
4
+ status: z.enum(["done", "blocked"]),
5
+ summary: z.string().default(""),
6
+ filesChanged: z.array(z.string()).default([]),
7
+ concerns: z.string().default(""),
8
+ });
9
+ const tagText = (xml, tag) => xml.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`))?.[1]?.trim();
10
+ /** 取回覆中最後一個 <result> 區塊;沒有或格式不合時回傳 undefined,不影響關卡判斷 */
11
+ export function parseResultMeta(text) {
12
+ const block = [...text.matchAll(/<result>([\s\S]*?)<\/result>/g)].at(-1)?.[1];
13
+ if (block === undefined)
14
+ return undefined;
15
+ const files = tagText(block, "files_changed") ?? "";
16
+ const parsed = ResultMeta.safeParse({
17
+ status: tagText(block, "status"),
18
+ summary: tagText(block, "summary"),
19
+ filesChanged: [...files.matchAll(/<file>([\s\S]*?)<\/file>/g)].map((m) => m[1].trim()).filter(Boolean),
20
+ concerns: tagText(block, "concerns"),
21
+ });
22
+ return parsed.success ? parsed.data : undefined;
23
+ }
24
+ //# sourceMappingURL=resultMeta.js.map
package/dist/runner.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { appendFileSync, mkdirSync } from "node:fs";
2
2
  import { dirname } from "node:path";
3
- import { z } from "zod";
4
3
  import { ADAPTERS } from "./agents/index.js";
5
4
  import { projectRoot, runDir } from "./paths.js";
6
5
  import { config } from "./config.js";
7
6
  import { CMD_AGENT, footerLine, headerLine, logSeq, STDERR_MARK } from "./logs.js";
8
7
  import { exec, execShell } from "./proc.js";
9
8
  import { tail } from "./util.js";
9
+ import { parseResultMeta } from "./resultMeta.js";
10
10
  /**
11
11
  * 判斷 agent 失敗是否因為方案額度或速率限制。
12
12
  * 各家的錯誤訊息會隨版本變動,這裡用寬鬆的樣式比對,且只在執行失敗時才檢查,避免誤判正常輸出。
@@ -22,28 +22,7 @@ const QUOTA_PATTERNS = [
22
22
  /\b429\b/,
23
23
  ];
24
24
  export const isQuotaError = (text) => QUOTA_PATTERNS.some((p) => p.test(text));
25
- /** Agent 回覆結尾的 <result> 中繼資料(格式定義在各 prompt 的 <reply_format>) */
26
- export const ResultMeta = z.object({
27
- status: z.enum(["done", "blocked"]),
28
- summary: z.string().default(""),
29
- filesChanged: z.array(z.string()).default([]),
30
- concerns: z.string().default(""),
31
- });
32
- const tagText = (xml, tag) => xml.match(new RegExp(`<${tag}>([\\s\\S]*?)</${tag}>`))?.[1]?.trim();
33
- /** 取回覆中最後一個 <result> 區塊;沒有或格式不合時回傳 undefined,不影響關卡判斷 */
34
- export function parseResultMeta(text) {
35
- const block = [...text.matchAll(/<result>([\s\S]*?)<\/result>/g)].at(-1)?.[1];
36
- if (block === undefined)
37
- return undefined;
38
- const files = tagText(block, "files_changed") ?? "";
39
- const parsed = ResultMeta.safeParse({
40
- status: tagText(block, "status"),
41
- summary: tagText(block, "summary"),
42
- filesChanged: [...files.matchAll(/<file>([\s\S]*?)<\/file>/g)].map((m) => m[1].trim()).filter(Boolean),
43
- concerns: tagText(block, "concerns"),
44
- });
45
- return parsed.success ? parsed.data : undefined;
46
- }
25
+ export { parseResultMeta, ResultMeta } from "./resultMeta.js";
47
26
  function appendLog(file, text) {
48
27
  mkdirSync(dirname(file), { recursive: true });
49
28
  appendFileSync(file, text.endsWith("\n") ? text : `${text}\n`);
@@ -0,0 +1,79 @@
1
+ import { join } from "node:path";
2
+ import { summarizeLog } from "./logs.js";
3
+ const pad = (s) => s.split("\n").join("\n ");
4
+ const clip = (s, max) => (s.length <= max ? s : `${s.slice(0, max)}…`);
5
+ /** 一份 log 的結果:標題、<result> 的摘要與疑慮;失敗時附上錯誤整理 */
6
+ function outcomeLines(entry, text) {
7
+ const s = summarizeLog(text);
8
+ const mark = s.ok === undefined ? "…" : s.ok ? "✓" : "✗";
9
+ const out = [` #${entry.seq} ${s.title} ${mark}`];
10
+ if (s.meta) {
11
+ if (s.meta.status === "blocked")
12
+ out.push(" 狀態:blocked");
13
+ if (s.meta.summary)
14
+ out.push(` 摘要:${pad(s.meta.summary)}`);
15
+ if (s.meta.concerns)
16
+ out.push(` 疑慮:${pad(s.meta.concerns)}`);
17
+ }
18
+ else if (s.text) {
19
+ out.push(` ${pad(s.text)}`);
20
+ }
21
+ for (const e of s.errors)
22
+ out.push(` ${pad(clip(e, 1500))}`);
23
+ return out;
24
+ }
25
+ /**
26
+ * run 停下來(Ctrl-C、失敗、暫停、等待核准)時要直接印在終端機的內容:
27
+ * 最近一步的結果、未結交接事項,以及接下來可以執行的指令。完成的 run 不印。
28
+ */
29
+ export function stopReport(i) {
30
+ const { run, logs } = i;
31
+ if (run.stage === "done")
32
+ return [];
33
+ const out = [];
34
+ const last = logs.at(-1);
35
+ const running = last && !last.footer ? last : undefined;
36
+ const finished = logs.filter((e) => e.footer);
37
+ const lastFailed = finished.filter((e) => !e.footer.ok).at(-1);
38
+ // 失敗時優先看失敗的那一步,其餘情況看最後完成的一步
39
+ const shown = run.stage === "failed" && lastFailed ? lastFailed : finished.at(-1);
40
+ if (running || shown) {
41
+ out.push("", "── 結果 ──");
42
+ if (running) {
43
+ const title = summarizeLog(i.read(running.file)).title;
44
+ out.push(` ${i.interrupted ? "中斷於" : "進行中"} #${running.seq} ${title}(沒有結束紀錄,resume 時會重跑這一步)`);
45
+ }
46
+ if (shown)
47
+ out.push(...outcomeLines(shown, i.read(shown.file)));
48
+ }
49
+ if (i.open.length) {
50
+ out.push("", "── 未結交接事項 ──");
51
+ for (const item of i.open)
52
+ out.push(` [${item.targetStage}] ${item.id} ${item.summary}(${item.status})`);
53
+ }
54
+ const cmd = (c, why) => ` ${c.padEnd(40)} ${why}`;
55
+ const actions = [];
56
+ if (run.stage === "failed") {
57
+ if (lastFailed)
58
+ actions.push(cmd(`agentflowctl logs ${run.id} ${lastFailed.seq}`, "看失敗步驟的完整 log"));
59
+ actions.push(cmd(`cd ${i.worktree}`, "需要時手動修正"));
60
+ actions.push(cmd(`agentflowctl resume ${run.id}`, `從 ${run.failedStage ?? "失敗的階段"} 重試`));
61
+ actions.push(cmd(`agentflowctl cancel ${run.id}`, "放棄這個 run"));
62
+ }
63
+ else if (run.stage === "paused") {
64
+ actions.push(cmd(`agentflowctl resume ${run.id}`, "額度恢復後接續"));
65
+ }
66
+ else if (run.stage === "awaiting_approval") {
67
+ actions.push(cmd(`less ${join(i.worktree, ".flow", "plan.md")}`, "檢視計畫"));
68
+ actions.push(cmd(`agentflowctl approve ${run.id}`, "核准並開始實作"));
69
+ }
70
+ else {
71
+ if (shown)
72
+ actions.push(cmd(`agentflowctl logs ${run.id} ${shown.seq}`, "看上一步的完整 log"));
73
+ actions.push(cmd(`agentflowctl resume ${run.id}`, `從 ${run.stage} 接續`));
74
+ actions.push(cmd(`agentflowctl cancel ${run.id}`, "放棄這個 run"));
75
+ }
76
+ out.push("", "── 下一步 ──", ...actions);
77
+ return out;
78
+ }
79
+ //# sourceMappingURL=stopReport.js.map
package/dist/tasks.js CHANGED
@@ -1,3 +1,5 @@
1
+ /** 一個任務最多做兩件事:對應的驗收條件超過這個數量就要再拆 */
2
+ export const MAX_TASK_ACCEPTANCE = 2;
1
3
  /**
2
4
  * 檢查任務清單並依相依關係排序(Kahn 演算法)。
3
5
  * 回傳排序後的任務,或回傳一段可以直接回饋給 Agent 的錯誤說明。
@@ -12,6 +14,9 @@ export function orderTasks(tasks, acceptanceIds) {
12
14
  }
13
15
  const covered = new Set();
14
16
  for (const t of tasks) {
17
+ if (t.acceptance.length > MAX_TASK_ACCEPTANCE) {
18
+ errors.push(`${t.id} 對應 ${t.acceptance.length} 條驗收條件,一個任務最多 ${MAX_TASK_ACCEPTANCE} 條,請拆成更小的任務`);
19
+ }
15
20
  for (const d of t.dependsOn)
16
21
  if (!ids.has(d))
17
22
  errors.push(`${t.id} 相依的 ${d} 不存在`);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agentflowctl",
3
3
  "license": "MIT",
4
- "version": "0.6.0",
4
+ "version": "0.8.0",
5
5
  "description": "跨廠商 AI 開發 harness:Claude Code、Codex、Gemini 輪流實作、審查、修正",
6
6
  "keywords": [
7
7
  "ai",
@@ -31,6 +31,7 @@
31
31
  <output_format>
32
32
  - acceptance.json 與 tasks.json 的格式必須維持不變(見檔案內現有內容)。
33
33
  - 每一條驗收條件都至少要有一個任務負責;`dependsOn` 不可有循環。
34
+ - 一個任務只做一件事,最多兩件:`acceptance` 最多列兩條驗收條件;驗收條件一條只描述一個行為。修改時若任務變大,請拆開,不要合併。
34
35
  - 測試檔名必須符合正規表示式 `{{testPattern}}`。
35
36
  </output_format>
36
37
 
@@ -32,8 +32,8 @@
32
32
 
33
33
  <review_focus>
34
34
  1. **需求覆蓋**:規格是否完整涵蓋原始需求?有沒有遺漏、誤解,或加入需求沒要求的範圍?
35
- 2. **驗收條件**:每一條是否具體、可以用自動化測試驗證?有沒有重要的邊界情況或錯誤處理沒被列入?
36
- 3. **任務拆解**:每個任務是否小到一次 TDD 循環就能完成,而且能寫出「實作前會失敗」的測試?相依順序是否合理?
35
+ 2. **驗收條件**:每一條是否具體、可以用自動化測試驗證,而且只描述一個行為?把多個行為寫在同一條的,要求拆開。有沒有重要的邊界情況或錯誤處理沒被列入?
36
+ 3. **任務拆解**:每個任務是否只做一件事(最多兩件),小到一次 TDD 循環就能完成,而且能寫出「實作前會失敗」的測試?任務太大、一次要動很多檔案或驗證很多行為的,要求拆成更小的任務。相依順序是否合理?
37
37
  4. **技術方向**:是否符合專案既有的架構與慣例?有沒有更簡單的做法,或明顯的風險?
38
38
 
39
39
  措辭、格式這類不影響實作結果的小問題,不需要要求修改。
package/prompts/plan.md CHANGED
@@ -46,7 +46,10 @@
46
46
  </output_format>
47
47
 
48
48
  <guidelines>
49
- - 每個任務是一個可獨立測試的垂直切片,小到一次 TDD 循環就能完成。
49
+ - 一個任務只做一件事,最多兩件:`acceptance` 最多列兩條驗收條件,超過就拆成多個任務(程式會檢查,超過會被退回)。
50
+ - 每個任務是一個可獨立測試的垂直切片,小到一次 TDD 循環就能完成;只動少數幾個檔案,測試只驗證一兩個行為。
51
+ - `title` 用一句話說出這件事;需要用「並且」「以及」串起來的,就是兩個任務。
52
+ - `description` 寫清楚要動哪些檔案、測試要驗證哪個行為,以及這個任務不做什麼。
50
53
  - 每個任務都必須能寫出「在實作前會失敗」的測試;純設定或重構類工作請併入相關任務。
51
54
  - 測試檔名必須符合正規表示式 `{{testPattern}}`。
52
55
  - 每一條驗收條件都至少要有一個任務負責;`dependsOn` 不可有循環。
package/prompts/spec.md CHANGED
@@ -28,6 +28,13 @@
28
28
  4. 撰寫 .flow/acceptance.json,每一條驗收條件都必須能用自動化測試驗證。
29
29
  </steps>
30
30
 
31
+ <guidelines>
32
+ - 驗收條件要寫細:一條只描述一個可觀察的行為(一個輸入或情境,對應一個預期結果)。
33
+ - 描述裡出現「並且」「同時」「以及」,或同時涵蓋成功與失敗路徑時,拆成多條。
34
+ - 邊界情況與錯誤處理各自獨立成一條,不要附在正常路徑那一條裡。
35
+ - 寧可多幾條小的,也不要少數幾條大的;後面每個任務最多只能對應兩條驗收條件。
36
+ </guidelines>
37
+
31
38
  <output_format>
32
39
  .flow/acceptance.json 的格式:
33
40