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 +33 -7
- package/dist/cli.js +18 -15
- package/dist/engine.js +12 -12
- package/dist/feedback.js +15 -0
- package/dist/logs.js +79 -23
- package/dist/resultMeta.js +24 -0
- package/dist/runner.js +2 -23
- package/dist/stopReport.js +79 -0
- package/package.json +1 -1
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 #
|
|
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:
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
64
|
-
|
|
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) =>
|
|
288
|
+
.map((i) => reviewIssue(i.criterion, i.status, i.note));
|
|
288
289
|
issueLines.push(...lines);
|
|
289
|
-
issues.push(
|
|
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) =>
|
|
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
|
|
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${
|
|
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${
|
|
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(
|
|
586
|
-
|
|
587
|
-
|
|
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");
|
package/dist/feedback.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** agent 提供的文字必須跳脫,才不會打斷意見的 XML 邊界。 */
|
|
2
|
+
export function escapeXml(value) {
|
|
3
|
+
return value.replace(/[&<>"']/g, (char) => ({
|
|
4
|
+
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
export function renderLog(text, file = "") {
|
|
141
|
+
function analyzeLog(text, full) {
|
|
113
142
|
const { header, footer, body, stderr } = parseLog(text);
|
|
114
|
-
const
|
|
143
|
+
const lines = [];
|
|
115
144
|
const errors = [];
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|