agentflowctl 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -99,10 +99,11 @@ agentflowctl run --req-file ./req.md --cycle codex,claude --max-agent-runs 40
99
99
  agentflowctl run --req "..." --manual-plan # 計畫通過 AI 審查後,仍停下來等你確認
100
100
 
101
101
  agentflowctl approve f-xxxx # 搭配 --manual-plan
102
- agentflowctl status f-xxxx # 階段、任務進度、各 agent 用量、代打紀錄
102
+ agentflowctl status f-xxxx # 階段、上一步結果、未結交接事項、下一步指令、任務進度、各 agent 用量、代打紀錄
103
103
  agentflowctl list
104
104
  agentflowctl logs f-xxxx # 列出每一份 log 的編號、結果、階段、步驟、agent
105
105
  agentflowctl logs f-xxxx 7 # 解析第 7 份 log,最後附上錯誤整理(--latest 看最新一份)
106
+ agentflowctl logs f-xxxx 7 --full # 完整顯示多行指令與絕對路徑
106
107
  agentflowctl logs f-xxxx 7 --raw # 原始內容(agent 的 JSON 行)
107
108
  agentflowctl resume f-xxxx # 從暫停、Ctrl-C 或失敗處接續
108
109
  agentflowctl cancel f-xxxx
@@ -182,14 +183,14 @@ agentflowctl clean --all # 清掉所有已結束的 run 與中斷留
182
183
  | 標記 | 內容 |
183
184
  |---|---|
184
185
  | 💬 | agent 的完整文字,不截斷 |
185
- | 🔧 | 工具呼叫與完整參數 |
186
+ | 🔧 | 工具呼叫。只顯示第一行,多行時註明共幾行;去掉 `/bin/zsh -lc '…'` 這類 shell 包裝,worktree 內的絕對路徑改成相對路徑 |
186
187
  | 📊 | token 用量 |
187
- | 🏁 | 最後結果 |
188
+ | 🏁 | 最後結果。與最後一則 💬 相同時不再重印 |
188
189
  | ⚠️ | 工具回報的錯誤。agent 通常會自己換方法繼續,所以不列進錯誤整理 |
189
190
  | ❌ | adapter 不認得的錯誤事件 |
190
191
  | 📄 | 不是 JSON 的輸出行 |
191
192
 
192
- adapter 不認得、也看不出錯誤跡象的 JSON 行不會顯示,只列出行數,要看全部請加 `--raw`。專案指令的 log 本來就是純文字,會原樣顯示。
193
+ 要看完整的工具內容與重複的最後結果,加 `--full`。adapter 不認得、也看不出錯誤跡象的 JSON 行不會顯示,只列出行數,要看全部請加 `--raw`。專案指令的 log 本來就是純文字,會原樣顯示。
193
194
 
194
195
  最後一段「錯誤」整理出結束碼、agent 回報的失敗、錯誤事件與 stderr:
195
196
 
@@ -199,7 +200,7 @@ adapter 不認得、也看不出錯誤跡象的 JSON 行不會顯示,只列出
199
200
  檔案 /repo/.agentflowctl/runs/f-xxxx/logs/003-plan-plan-codex.log
200
201
 
201
202
  💬 先讀 spec.md 與 acceptance.json
202
- 🔧 shell: bash -lc 'cat .flow/spec.md'
203
+ 🔧 shell: cat .flow/spec.md
203
204
  🏁 失敗:stream disconnected before completion
204
205
 
205
206
  ── 錯誤 ──
@@ -211,9 +212,33 @@ stderr:
211
212
 
212
213
  執行成功時,stderr 會放在「其他輸出」段落,不算錯誤。
213
214
 
215
+ ### 停下來時的結果與下一步
216
+
217
+ run 因 Ctrl-C、失敗、額度暫停或等待核准而停下時,終端機會直接印出三段;`agentflowctl status <id>` 也會印同樣的內容:
218
+
219
+ - **結果**:被中斷、還沒有結束紀錄的步驟,以及上一步的結果。agent 的步驟取回覆 `<result>` 的摘要與疑慮;失敗時優先顯示失敗的那一步,並附上結束碼、錯誤事件、stderr 或指令輸出的最後幾行。
220
+ - **未結交接事項**:交接紀錄裡還沒結案的 action 事項(open 或 proposed_resolved)。
221
+ - **下一步**:依狀態列出可以執行的指令,例如 `logs`、`cd` 到 worktree、`resume`、`cancel`、`approve`。
222
+
223
+ ```
224
+ ── 結果 ──
225
+ 中斷於 #20 plan_review / plan-review / codex(沒有結束紀錄,resume 時會重跑這一步)
226
+ #19 plan_fix / plan-fix / claude ✓
227
+ 摘要:接受三條審查意見,拆分 T-3、T-5
228
+ 疑慮:T-15 可能仍太大
229
+
230
+ ── 未結交接事項 ──
231
+ [plan] c9c730b280e87178 T-3、T-5 各混合多個獨立行為(proposed_resolved)
232
+
233
+ ── 下一步 ──
234
+ agentflowctl logs f-xxxx 19 看上一步的完整 log
235
+ agentflowctl resume f-xxxx 從 plan_review 接續
236
+ agentflowctl cancel f-xxxx 放棄這個 run
237
+ ```
238
+
214
239
  ### 出錯時怎麼查
215
240
 
216
- 1. run 停下時印出的摘要,或 `agentflowctl status <id>`,會列出失敗的階段、原因、最後一份 log,以及最近失敗的那一份。
241
+ 1. 先看 run 停下時印出的「結果」與「下一步」,或執行 `agentflowctl status <id>`。
217
242
  2. `agentflowctl logs <id> <編號>` 看那份 log 的錯誤段落。
218
243
  3. 解析結果看不出原因時,加 `--raw` 看原始輸出。
219
244
  4. 必要時直接在 worktree(`.agentflowctl/worktrees/<id>`)裡修正,再執行 `agentflowctl resume <id>`。
@@ -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
 
@@ -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", "完整顯示工具內容(多行指令、絕對路徑)與重複的最後回覆", 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";
@@ -284,9 +285,9 @@ async function planReviewStage(run) {
284
285
  firstObjector ??= reviewer;
285
286
  const lines = review.data.items
286
287
  .filter((i) => i.status !== "met")
287
- .map((i) => `- **${i.criterion}**(${i.status}):${i.note}`);
288
+ .map((i) => reviewIssue(i.criterion, i.status, i.note));
288
289
  issueLines.push(...lines);
289
- issues.push(`### ${reviewer} 的意見\n\n${lines.join("\n")}`);
290
+ issues.push(opinion(reviewer, lines));
290
291
  }
291
292
  if (!firstObjector)
292
293
  return planSettled(run, "plan-review");
@@ -375,9 +376,10 @@ async function arbitratePlan(run) {
375
376
  mkdirSync(join(runDir(run.id), "reviews"), { recursive: true });
376
377
  renameSync(flowFile(run, "plan-arbiter.json"), join(runDir(run.id), "reviews", `plan-arbiter-${arbitrationRound}-${arbiter}.json`));
377
378
  const verdict = result.data.verdict;
378
- const notes = result.data.items.map((i) => `- ${i.criterion}:${i.note}`);
379
+ const notes = result.data.items.map((i) => `<issue criterion="${escapeXml(i.criterion)}">${escapeXml(i.note)}</issue>`);
380
+ const markdownNotes = result.data.items.map((i) => `- ${i.criterion}:${i.note}`);
379
381
  info(run, ` ${verdict === "approve" ? "✓" : "✗"} ${arbiter}:${verdict === "approve" ? "可以執行" : "不可執行"}`);
380
- verdicts.push({ arbiter, verdict, notes });
382
+ verdicts.push({ arbiter, verdict, notes, markdownNotes });
381
383
  }
382
384
  rmSync(flowFile(run, "dispute.md"), { force: true });
383
385
  const approvals = verdicts.filter((v) => v.verdict === "approve").length;
@@ -390,16 +392,16 @@ async function arbitratePlan(run) {
390
392
  : approvals === 0
391
393
  ? `${mode}沒有任何一方核准${decision === "revise" ? ",交回計畫修訂" : ""}`
392
394
  : `${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");
395
+ const feedbackRecord = verdicts.map((v) => opinion(v.arbiter, v.notes, v.verdict)).join("\n\n");
394
396
  if (decision === "revise") {
395
397
  const attempts = { ...run.attempts, "plan-arbitration": arbitrationRound };
396
398
  delete attempts["plan-review"];
397
399
  rmSync(flowFile(run, "plan-review-last.txt"), { force: true });
398
- writeFileSync(flowFile(run, "feedback.md"), `# 仲裁要求修訂(第 ${arbitrationRound} 次)\n\n${summary}\n\n${record}\n`);
400
+ writeFileSync(flowFile(run, "feedback.md"), `# 仲裁要求修訂(第 ${arbitrationRound} 次)\n\n${summary}\n\n${feedbackRecord}\n`);
399
401
  info(run, ` → ${summary}`);
400
402
  return { ...run, attempts, stage: "plan_fix" };
401
403
  }
402
- writeFileSync(flowFile(run, "plan.md"), `${readFileSync(flowFile(run, "plan.md"), "utf8")}\n\n## 仲裁紀錄\n\n**結果:${summary}**\n\n${record}\n`);
404
+ writeFileSync(flowFile(run, "plan.md"), `${readFileSync(flowFile(run, "plan.md"), "utf8")}\n\n## 仲裁紀錄\n\n**結果:${summary}**\n\n${verdicts.map((v) => `### ${v.arbiter}(${v.verdict})\n\n${v.markdownNotes.join("\n")}`).join("\n\n")}\n`);
403
405
  if (decision === "proceed") {
404
406
  info(run, ` → ${summary}`);
405
407
  return planSettled(run, "plan-review");
@@ -582,11 +584,9 @@ async function reviewStage(run) {
582
584
  }
583
585
  info(run, ` ✗ ${reviewer} 要求修改`);
584
586
  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"));
587
+ issues.push(opinion(reviewer, review.data.items
588
+ .filter((i) => i.status !== "met")
589
+ .map((i) => reviewIssue(i.criterion, i.status, i.note))));
590
590
  }
591
591
  if (!firstObjector)
592
592
  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,35 +97,57 @@ 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
+ /** 去掉 codex 這類 CLI 包在指令外面的 `/bin/zsh -lc '...'` */
103
+ function unwrapShell(cmd) {
104
+ const m = cmd.match(/^\/bin\/(?:ba|z)?sh\s+-l?c\s+(['"])([\s\S]*)\1$/);
105
+ if (!m)
106
+ return cmd;
107
+ const [, quote, inner] = m;
108
+ // 單引號包裝裡的 '"'"' 或 '\'' 是被跳脫的單引號,內容太複雜時保留原樣
109
+ if (quote === "'" && inner.includes("'"))
110
+ return cmd;
111
+ if (quote === '"' && /(^|[^\\])"/.test(inner))
112
+ return cmd;
113
+ return inner;
114
+ }
115
+ /** 精簡顯示的工具內容:只留第一行,並註明原本有幾行 */
116
+ function compactDetail(detail) {
117
+ const lines = relativeToWorktree(unwrapShell(detail.trim())).split("\n");
118
+ const first = clip(lines[0], 200);
119
+ return lines.length > 1 ? `${first} …(共 ${lines.length} 行)` : first;
120
+ }
121
+ function renderEvent(ev, full, lastText) {
100
122
  switch (ev.kind) {
101
123
  case "text":
102
124
  return `💬 ${indent(ev.text.trim())}`;
103
- case "tool":
104
- return `🔧 ${ev.name}${ev.detail ? `: ${indent(ev.detail.trim())}` : ""}`;
125
+ case "tool": {
126
+ const detail = ev.detail?.trim();
127
+ if (!detail)
128
+ return `🔧 ${ev.name}`;
129
+ return `🔧 ${ev.name}: ${full ? indent(detail) : compactDetail(detail)}`;
130
+ }
105
131
  case "usage":
106
132
  return `📊 用量 input ${ev.inputTokens ?? "?"} / output ${ev.outputTokens ?? "?"} tokens`;
107
- case "done":
108
- return `🏁 ${ev.ok ? "完成" : "失敗"}${ev.summary ? `:${indent(ev.summary.trim())}` : ""}`;
133
+ case "done": {
134
+ const summary = ev.summary?.trim();
135
+ // 最後一則回覆通常就是 summary,精簡模式不再重印一次
136
+ const show = summary && (full || summary !== lastText);
137
+ return `🏁 ${ev.ok ? "完成" : "失敗"}${show ? `:${indent(summary)}` : ""}`;
138
+ }
109
139
  }
110
140
  }
111
- /** 把一份 log 轉成人看得懂的版本;最後附上錯誤整理,讓失敗原因一眼可見 */
112
- export function renderLog(text, file = "") {
141
+ function analyzeLog(text, full) {
113
142
  const { header, footer, body, stderr } = parseLog(text);
114
- const out = [];
143
+ const lines = [];
115
144
  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("");
145
+ let lastText;
146
+ let doneSummary;
125
147
  const adapter = header?.adapter ? ADAPTERS[header.adapter] : undefined;
126
148
  if (!adapter || header?.agent === CMD_AGENT) {
127
- out.push(...body);
149
+ lines.push(...body);
150
+ lastText = body.slice(-15).join("\n").trim() || undefined;
128
151
  }
129
152
  else {
130
153
  let skipped = 0;
@@ -134,9 +157,13 @@ export function renderLog(text, file = "") {
134
157
  const events = adapter.parse(line);
135
158
  if (events.length) {
136
159
  for (const ev of events) {
137
- const s = renderEvent(ev);
160
+ const s = renderEvent(ev, full, lastText);
138
161
  if (s)
139
- out.push(s);
162
+ lines.push(s);
163
+ if (ev.kind === "text")
164
+ lastText = ev.text.trim();
165
+ if (ev.kind === "done")
166
+ doneSummary = ev.summary?.trim() || undefined;
140
167
  if (ev.kind === "done" && !ev.ok)
141
168
  errors.push(`agent 回報失敗${ev.summary ? `:${indent(ev.summary.trim())}` : ""}`);
142
169
  }
@@ -144,7 +171,7 @@ export function renderLog(text, file = "") {
144
171
  }
145
172
  const json = tryJson(line);
146
173
  if (!json) {
147
- out.push(`📄 ${line}`);
174
+ lines.push(`📄 ${line}`);
148
175
  continue;
149
176
  }
150
177
  const err = findError(json);
@@ -152,22 +179,51 @@ export function renderLog(text, file = "") {
152
179
  skipped++;
153
180
  continue;
154
181
  }
155
- out.push(`${err.fatal ? "❌" : "⚠️ "} ${indent(err.message)}`);
182
+ lines.push(`${err.fatal ? "❌" : "⚠️ "} ${indent(err.message)}`);
156
183
  if (err.fatal)
157
184
  errors.push(`錯誤事件:${indent(err.message)}`);
158
185
  }
159
186
  if (skipped)
160
- out.push(`(另有 ${skipped} 行其他事件未顯示,可用 --raw 查看)`);
187
+ lines.push(`(另有 ${skipped} 行其他事件未顯示,可用 --raw 查看)`);
161
188
  }
162
189
  if (footer && footer.code !== 0)
163
190
  errors.unshift(`結束碼 ${footer.code}`);
164
191
  if (footer && !footer.ok && !errors.length)
165
192
  errors.push("執行未通過(沒有更多錯誤訊息)");
166
193
  const stderrText = stderr.length ? `stderr:\n ${indent(clip(stderr.join("\n"), 4000))}` : undefined;
194
+ return { header, footer, lines, errors, stderrText, lastText, doneSummary };
195
+ }
196
+ const logTitle = (header) => header
197
+ ? `${header.stage} / ${header.step} / ${header.agent}${header.adapter && header.adapter !== header.agent ? `(adapter ${header.adapter})` : ""}`
198
+ : "(沒有檔頭)";
199
+ /** 把一份 log 轉成人看得懂的版本;最後附上錯誤整理,讓失敗原因一眼可見。預設精簡工具內容,opts.full 時完整顯示 */
200
+ export function renderLog(text, file = "", opts = {}) {
201
+ const { header, footer, lines, errors, stderrText } = analyzeLog(text, opts.full ?? false);
202
+ const out = [];
203
+ out.push(`${logSeq(file) ? `#${logSeq(file)} ` : ""}${logTitle(header)}`);
204
+ out.push(`開始 ${localTime(header?.startedAt)} ` +
205
+ (footer ? `結束 ${localTime(footer.endedAt)} 結束碼 ${footer.code} ${footer.ok ? "✓ 成功" : "✗ 失敗"}` : "… 沒有結束紀錄(還在執行或被中斷)"));
206
+ if (file)
207
+ out.push(`檔案 ${file}`);
208
+ out.push("", ...lines);
167
209
  if (errors.length)
168
210
  out.push("", "── 錯誤 ──", ...errors, ...(stderrText ? [stderrText] : []));
169
211
  else if (stderrText)
170
212
  out.push("", "── 其他輸出 ──", stderrText);
171
213
  return out.join("\n");
172
214
  }
215
+ /** 一份 log 的結果摘要:給 run 停下時直接印在終端機,不用再另外查 log */
216
+ export function summarizeLog(text) {
217
+ const a = analyzeLog(text, false);
218
+ const reply = a.doneSummary ?? a.lastText;
219
+ const meta = reply ? parseResultMeta(reply) ?? (a.lastText ? parseResultMeta(a.lastText) : undefined) : undefined;
220
+ const failed = a.footer ? !a.footer.ok : false;
221
+ return {
222
+ title: logTitle(a.header),
223
+ ok: a.footer?.ok,
224
+ meta,
225
+ text: meta ? undefined : reply && clip(reply, 600),
226
+ errors: failed ? [...a.errors, ...(a.stderrText ? [a.stderrText] : [])] : [],
227
+ };
228
+ }
173
229
  //# 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/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.7.0",
5
5
  "description": "跨廠商 AI 開發 harness:Claude Code、Codex、Gemini 輪流實作、審查、修正",
6
6
  "keywords": [
7
7
  "ai",