agentflowctl 0.1.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/dist/engine.js ADDED
@@ -0,0 +1,540 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { config } from "./config.js";
4
+ import { changedFiles, commitAll, discardChanges, git, headCommit, resetTo } from "./git.js";
5
+ import { flowDir, logDir, projectRoot, runDir, worktreeDir } from "./paths.js";
6
+ import { exec } from "./proc.js";
7
+ import { arbiterPanel, availableAgent, fixAgent, planAgent, planFixAgent, reviewers, specAgent, taskAgents } from "./roles.js";
8
+ import { resolveAgent, runAgent, runCommand } from "./runner.js";
9
+ import { AcceptanceList, RepoConfig, ReviewResult, TaskList, } from "./schemas.js";
10
+ import { addCost, addSubstitution, agentRuns, getCost, saveRun } from "./store.js";
11
+ import { orderTasks } from "./tasks.js";
12
+ import { readJsonFile, renderPrompt, tail } from "./util.js";
13
+ // ───────────────────────── 共用工具 ─────────────────────────
14
+ const info = (run, msg) => console.log(`[${run.id}] ${msg}`);
15
+ const flowFile = (run, name) => join(flowDir(run.id), name);
16
+ 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`) };
20
+ }
21
+ /** 額度用完而必須停下:審查類步驟,或所有 agent 的額度都用完 */
22
+ export class QuotaPause extends Error {
23
+ constructor(message) {
24
+ super(message);
25
+ }
26
+ }
27
+ /** 這次執行中已確認額度用完的 agent;程序結束即清空,resume 時會重新嘗試 */
28
+ const exhausted = new Set();
29
+ async function agentStep(run, planned, step, prompt, mode) {
30
+ const cfg = loadRepoConfig();
31
+ const reset = mode.reset ?? (() => discardChanges(worktreeDir(run.id)));
32
+ let agent = planned;
33
+ for (;;) {
34
+ if (exhausted.has(agent)) {
35
+ if (mode.kind === "review") {
36
+ throw new QuotaPause(`${agent} 的額度已用完;${step} 是審查步驟,不由另一家代打`);
37
+ }
38
+ const sub = availableAgent(run.cycle, agent, [...exhausted]);
39
+ if (!sub)
40
+ throw new QuotaPause(`所有 agent 的額度都已用完(${[...exhausted].join("、")})`);
41
+ const note = step.endsWith("-code") && sub === run.lastTestsAuthor ? "測試與實作由同一家負責" : undefined;
42
+ addSubstitution(run.id, { step, planned, actual: sub, note });
43
+ info(run, `🔁 ${agent} 額度已用完,${step} 由 ${sub} 代打${note ? `(注意:${note})` : ""}`);
44
+ agent = sub;
45
+ }
46
+ const r = await runAgent(agent, resolveAgent(cfg, agent), target(run, `${step}-${agent}`), prompt, {
47
+ stripApiKeys: cfg.auth === "subscription",
48
+ });
49
+ addCost(run.id, { stage: step, agent, usd: r.costUsd, inputTokens: r.inputTokens, outputTokens: r.outputTokens });
50
+ if (!r.quotaExhausted)
51
+ return { r, agent };
52
+ info(run, `⛽ ${agent} 的額度已用完`);
53
+ exhausted.add(agent);
54
+ await reset();
55
+ // 迴圈回到開頭:review 會停下,write 會找代打
56
+ }
57
+ }
58
+ function readFeedback(run) {
59
+ const p = flowFile(run, "feedback.md");
60
+ return existsSync(p) ? readFileSync(p, "utf8") : "";
61
+ }
62
+ /** 關卡未通過:寫入 feedback.md 給下一次嘗試參考,超過上限就讓整個 run 失敗 */
63
+ function retry(run, key, reason, backTo) {
64
+ const n = (run.attempts[key] ?? 0) + 1;
65
+ const attempts = { ...run.attempts, [key]: n };
66
+ writeFileSync(flowFile(run, "feedback.md"), `# 前次嘗試未通過(第 ${n} 次)\n\n${reason}\n`);
67
+ if (n >= config.maxAttempts) {
68
+ return { ...run, attempts, stage: "failed", failedStage: backTo, failureReason: `${key} 連續失敗 ${n} 次:${tail(reason, 500)}` };
69
+ }
70
+ info(run, `⚠️ ${key} 未通過,重試(${n}/${config.maxAttempts})`);
71
+ return { ...run, attempts, stage: backTo };
72
+ }
73
+ function succeed(run, key, next) {
74
+ rmSync(flowFile(run, "feedback.md"), { force: true });
75
+ const attempts = { ...run.attempts };
76
+ delete attempts[key];
77
+ return { ...run, attempts, stage: next };
78
+ }
79
+ /** 設定檔放在主專案根目錄,未 commit 的修改也會生效 */
80
+ export function loadRepoConfig() {
81
+ const p = join(projectRoot(), "flow.config.json");
82
+ if (!existsSync(p))
83
+ return RepoConfig.parse({});
84
+ const r = readJsonFile(p, RepoConfig);
85
+ if (!r.ok)
86
+ throw new Error(r.error);
87
+ return r.data;
88
+ }
89
+ function loadOrderedTasks(run) {
90
+ const r = readJsonFile(flowFile(run, "tasks.ordered.json"), TaskList);
91
+ if (!r.ok)
92
+ throw new Error(r.error);
93
+ return r.data;
94
+ }
95
+ // ───────────────────────── 各階段 ─────────────────────────
96
+ async function specStage(run) {
97
+ const agent = specAgent(run.cycle);
98
+ info(run, `📝 產生規格(${agent})`);
99
+ const { r } = await agentStep(run, agent, "spec", renderPrompt("spec", { requirement: run.requirement }), { kind: "write" });
100
+ await discardChanges(worktreeDir(run.id)); // 這個階段只允許寫 .flow/
101
+ if (!r.ok)
102
+ return retry(run, "spec", `Agent 執行失敗:${r.summary}`, "spec");
103
+ if (!existsSync(flowFile(run, "spec.md")))
104
+ return retry(run, "spec", "缺少 .flow/spec.md", "spec");
105
+ const ac = readJsonFile(flowFile(run, "acceptance.json"), AcceptanceList);
106
+ if (!ac.ok)
107
+ return retry(run, "spec", ac.error, "spec");
108
+ const ids = ac.data.map((a) => a.id);
109
+ if (new Set(ids).size !== ids.length)
110
+ return retry(run, "spec", "驗收條件 id 有重複", "spec");
111
+ return succeed(run, "spec", "plan");
112
+ }
113
+ // ── 規格與計畫檔案:審查者與仲裁者只能讀,不能改 ──
114
+ const PLAN_FILES = ["spec.md", "acceptance.json", "plan.md", "tasks.json"];
115
+ function snapshotPlan(run) {
116
+ return Object.fromEntries(PLAN_FILES.map((f) => [f, existsSync(flowFile(run, f)) ? readFileSync(flowFile(run, f), "utf8") : undefined]));
117
+ }
118
+ /** 把被審查者動過的計畫檔案還原成審查前的內容 */
119
+ function restorePlan(run, snap) {
120
+ const changed = [];
121
+ for (const f of PLAN_FILES) {
122
+ const now = existsSync(flowFile(run, f)) ? readFileSync(flowFile(run, f), "utf8") : undefined;
123
+ if (now === snap[f])
124
+ continue;
125
+ changed.push(f);
126
+ if (snap[f] === undefined)
127
+ rmSync(flowFile(run, f), { force: true });
128
+ else
129
+ writeFileSync(flowFile(run, f), snap[f]);
130
+ }
131
+ return changed;
132
+ }
133
+ /** 計畫的確定性關卡:檔案齊全、格式正確、任務 DAG 合法且涵蓋所有驗收條件 */
134
+ function validatePlan(run) {
135
+ if (!existsSync(flowFile(run, "spec.md")))
136
+ return "缺少 .flow/spec.md";
137
+ if (!existsSync(flowFile(run, "plan.md")))
138
+ return "缺少 .flow/plan.md";
139
+ const ac = readJsonFile(flowFile(run, "acceptance.json"), AcceptanceList);
140
+ if (!ac.ok)
141
+ return ac.error;
142
+ const ids = ac.data.map((a) => a.id);
143
+ if (new Set(ids).size !== ids.length)
144
+ return "驗收條件 id 有重複";
145
+ const tasks = readJsonFile(flowFile(run, "tasks.json"), TaskList);
146
+ if (!tasks.ok)
147
+ return tasks.error;
148
+ return orderTasks(tasks.data, new Set(ids));
149
+ }
150
+ function acceptPlan(run, ordered) {
151
+ writeFileSync(flowFile(run, "tasks.ordered.json"), JSON.stringify(ordered, null, 2));
152
+ }
153
+ function announceTasks(run, ordered) {
154
+ const cfg = loadRepoConfig();
155
+ info(run, `📋 共 ${ordered.length} 個任務:${ordered.map((t) => t.id).join(" → ")}`);
156
+ ordered.forEach((t, i) => {
157
+ const a = taskAgents(run.cycle, i, cfg.tddSplit);
158
+ info(run, ` ${t.id} 測試:${a.tests} 實作:${a.code}`);
159
+ });
160
+ }
161
+ /** 計畫定案後:預設直接開始實作;--manual-plan 時才停下來等人 */
162
+ function planSettled(run, key) {
163
+ const next = run.autopilot ? "implement" : "awaiting_approval";
164
+ const ordered = loadOrderedTasks(run);
165
+ announceTasks(run, ordered);
166
+ if (!run.autopilot) {
167
+ info(run, `✋ 計畫已通過審查,請檢視 ${flowFile(run, "plan.md")},確認後執行 agentflowctl approve ${run.id}`);
168
+ }
169
+ return { ...succeed(run, key, next), taskIndex: 0, taskPhase: "tests" };
170
+ }
171
+ async function planStage(run) {
172
+ const agent = planAgent(run.cycle);
173
+ info(run, `🗺️ 拆解任務(${agent})`);
174
+ const cfg = loadRepoConfig();
175
+ const { r, agent: actual } = await agentStep(run, agent, "plan", renderPrompt("plan", { testPattern: cfg.testPattern }), { kind: "write" });
176
+ await discardChanges(worktreeDir(run.id));
177
+ if (!r.ok)
178
+ return retry(run, "plan", `Agent 執行失敗:${r.summary}`, "plan");
179
+ const ordered = validatePlan(run);
180
+ if (typeof ordered === "string")
181
+ return retry(run, "plan", ordered, "plan");
182
+ acceptPlan(run, ordered);
183
+ rmSync(flowFile(run, "plan-review-last.txt"), { force: true });
184
+ return { ...succeed(run, "plan", "plan_review"), planWriter: actual };
185
+ }
186
+ async function planReviewStage(run) {
187
+ const cfg = loadRepoConfig();
188
+ const author = run.planWriter ?? planAgent(run.cycle);
189
+ const panel = reviewers(run.cycle, author, cfg.planReviewQuorum);
190
+ const round = (run.attempts["plan-review"] ?? 0) + 1;
191
+ const issues = [];
192
+ const issueLines = []; // 只含意見內容、不含審查者名稱,用來偵測僵持
193
+ let firstObjector;
194
+ for (const reviewer of panel) {
195
+ info(run, `🧐 計畫審查第 ${round} 輪(${reviewer},作者 ${author})`);
196
+ const snap = snapshotPlan(run);
197
+ rmSync(flowFile(run, "plan-review.json"), { force: true });
198
+ const { r } = await agentStep(run, reviewer, "plan-review", renderPrompt("plan-review", { reviewer, author, requirement: run.requirement }), { kind: "review", reset: async () => { await discardChanges(worktreeDir(run.id)); restorePlan(run, snap); } });
199
+ await discardChanges(worktreeDir(run.id));
200
+ const tampered = restorePlan(run, snap);
201
+ if (tampered.length)
202
+ info(run, ` ↩️ 已還原審查者修改的檔案:${tampered.join(", ")}`);
203
+ if (!r.ok)
204
+ return retry(run, "plan-review-run", `Agent 執行失敗:${r.summary}`, "plan_review");
205
+ const review = readJsonFile(flowFile(run, "plan-review.json"), ReviewResult);
206
+ if (!review.ok)
207
+ return retry(run, "plan-review-run", review.error, "plan_review");
208
+ // 審查紀錄移到 worktree 外面:之後的仲裁者看不到是哪一家提的意見
209
+ mkdirSync(join(runDir(run.id), "reviews"), { recursive: true });
210
+ renameSync(flowFile(run, "plan-review.json"), join(runDir(run.id), "reviews", `plan-review-${round}-${reviewer}.json`));
211
+ if (review.data.verdict === "approve") {
212
+ info(run, ` ✓ ${reviewer} 核准計畫`);
213
+ continue;
214
+ }
215
+ info(run, ` ✗ ${reviewer} 要求修改計畫`);
216
+ firstObjector ??= reviewer;
217
+ const lines = review.data.items
218
+ .filter((i) => i.status !== "met")
219
+ .map((i) => `- **${i.criterion}**(${i.status}):${i.note}`);
220
+ issueLines.push(...lines);
221
+ issues.push(`### ${reviewer} 的意見\n\n${lines.join("\n")}`);
222
+ }
223
+ if (!firstObjector)
224
+ return planSettled(run, "plan-review");
225
+ const report = `計畫審查要求修改:\n\n${issues.join("\n\n")}`;
226
+ // 僵持偵測:意見和上一輪完全相同,代表修改沒有進展
227
+ const lastPath = flowFile(run, "plan-review-last.txt");
228
+ const fingerprint = [...issueLines].sort().join("\n");
229
+ const stalled = existsSync(lastPath) && readFileSync(lastPath, "utf8") === fingerprint;
230
+ writeFileSync(lastPath, fingerprint);
231
+ const exhausted = round >= config.maxAttempts;
232
+ if ((stalled || exhausted) && cfg.planArbiter) {
233
+ // 雙盲:帶有審查者名稱的 feedback.md 不留在 worktree,完整報告另存到 worktree 外
234
+ rmSync(flowFile(run, "feedback.md"), { force: true });
235
+ mkdirSync(join(runDir(run.id), "reviews"), { recursive: true });
236
+ writeFileSync(join(runDir(run.id), "reviews", "dispute-full.md"), report);
237
+ // 給仲裁者的爭議清單不含任何模型名稱
238
+ writeFileSync(flowFile(run, "dispute.md"), `# 尚未解決的審查意見\n\n${[...new Set(issueLines)].join("\n")}\n`);
239
+ info(run, `⚖️ 計畫審查${stalled ? "意見沒有變化" : `已達 ${round} 輪`},交付仲裁`);
240
+ return arbitratePlan({ ...run, planReviewer: firstObjector });
241
+ }
242
+ return { ...retry(run, "plan-review", report, "plan_fix"), planReviewer: firstObjector };
243
+ }
244
+ async function planFixStage(run) {
245
+ const cfg = loadRepoConfig();
246
+ const agent = planFixAgent(run.cycle, { strategy: cfg.fixStrategy, planWriter: run.planWriter, planReviewer: run.planReviewer });
247
+ info(run, `✏️ 依 ${run.planReviewer ?? "審查者"} 的意見修改計畫(${agent})`);
248
+ const feedback = readFeedback(run);
249
+ const snap = snapshotPlan(run);
250
+ const { r, agent: actual } = await agentStep(run, agent, "plan-fix", renderPrompt("plan-fix", { requirement: run.requirement, testPattern: cfg.testPattern }), { kind: "write", reset: async () => { await discardChanges(worktreeDir(run.id)); restorePlan(run, snap); } });
251
+ await discardChanges(worktreeDir(run.id)); // 只允許改 .flow/
252
+ if (!r.ok) {
253
+ restorePlan(run, snap);
254
+ return retry(run, "plan-fix", `${feedback}\n\n(上次修改時 Agent 執行失敗:${r.summary})`, "plan_fix");
255
+ }
256
+ const ordered = validatePlan(run);
257
+ if (typeof ordered === "string") {
258
+ restorePlan(run, snap);
259
+ return retry(run, "plan-fix", `${feedback}\n\n另外,修改後的計畫沒有通過格式檢查,已還原:\n${ordered}`, "plan_fix");
260
+ }
261
+ acceptPlan(run, ordered);
262
+ writeFileSync(flowFile(run, "feedback.md"), feedback); // 保留審查意見,讓下一輪審查者知道上次提了什麼
263
+ const attempts = { ...run.attempts };
264
+ delete attempts["plan-fix"];
265
+ return { ...run, attempts, stage: "plan_review", planWriter: actual };
266
+ }
267
+ /**
268
+ * 仲裁:有第三方就由第三方判斷;只有兩家時,兩家各自在全新 context 中雙盲判斷,
269
+ * 並且看不到誰是作者、誰是審查者。結果依 tieBreak 決定分歧時怎麼辦。
270
+ */
271
+ async function arbitratePlan(run) {
272
+ const cfg = loadRepoConfig();
273
+ const panel = arbiterPanel(run.cycle, run.planWriter, run.planReviewer);
274
+ const mode = panel.length > 1 ? "雙盲交叉仲裁" : "第三方仲裁";
275
+ const verdicts = [];
276
+ for (const arbiter of panel) {
277
+ info(run, `⚖️ ${mode}(${arbiter})`);
278
+ const snap = snapshotPlan(run);
279
+ rmSync(flowFile(run, "plan-arbiter.json"), { force: true });
280
+ const { r } = await agentStep(run, arbiter, "plan-arbiter", renderPrompt("plan-arbiter", { requirement: run.requirement }), {
281
+ kind: "review",
282
+ reset: async () => { await discardChanges(worktreeDir(run.id)); restorePlan(run, snap); },
283
+ });
284
+ await discardChanges(worktreeDir(run.id));
285
+ restorePlan(run, snap);
286
+ const result = r.ok ? readJsonFile(flowFile(run, "plan-arbiter.json"), ReviewResult) : undefined;
287
+ if (result?.ok) {
288
+ mkdirSync(join(runDir(run.id), "reviews"), { recursive: true });
289
+ renameSync(flowFile(run, "plan-arbiter.json"), join(runDir(run.id), "reviews", `plan-arbiter-${arbiter}.json`));
290
+ }
291
+ const verdict = result?.ok ? result.data.verdict : "abstain";
292
+ const notes = result?.ok ? result.data.items.map((i) => `- ${i.criterion}:${i.note}`) : [`- 未產生有效裁決(${r.ok ? "輸出格式錯誤" : r.summary})`];
293
+ info(run, ` ${verdict === "approve" ? "✓" : verdict === "abstain" ? "…" : "✗"} ${arbiter}:${verdict === "approve" ? "可以執行" : verdict === "abstain" ? "未裁決" : "不可執行"}`);
294
+ verdicts.push({ arbiter, verdict, notes });
295
+ }
296
+ rmSync(flowFile(run, "dispute.md"), { force: true });
297
+ const approvals = verdicts.filter((v) => v.verdict === "approve").length;
298
+ const unanimous = approvals === verdicts.length;
299
+ const decision = unanimous ? "proceed" : approvals === 0 ? "stop" : cfg.tieBreak;
300
+ const summary = unanimous
301
+ ? `${mode}一致核准`
302
+ : approvals === 0
303
+ ? `${mode}沒有任何一方核准`
304
+ : `${mode}意見分歧,依 tieBreak=${cfg.tieBreak} ${cfg.tieBreak === "proceed" ? "繼續實作" : "停止"}`;
305
+ const record = verdicts.map((v) => `### ${v.arbiter}(${v.verdict})\n\n${v.notes.join("\n")}`).join("\n\n");
306
+ writeFileSync(flowFile(run, "plan.md"), `${readFileSync(flowFile(run, "plan.md"), "utf8")}\n\n## 仲裁紀錄\n\n**結果:${summary}**\n\n${record}\n`);
307
+ if (decision === "proceed") {
308
+ info(run, ` → ${summary}`);
309
+ return planSettled(run, "plan-review");
310
+ }
311
+ return { ...run, stage: "failed", failedStage: "plan_review", failureReason: `${summary},需要人工決定(見 plan.md 的仲裁紀錄)` };
312
+ }
313
+ async function implementStage(run) {
314
+ const tasks = loadOrderedTasks(run);
315
+ const task = tasks[run.taskIndex];
316
+ if (!task) {
317
+ info(run, "✅ 所有任務完成");
318
+ return to(run, "verify");
319
+ }
320
+ const cfg = loadRepoConfig();
321
+ const repo = worktreeDir(run.id);
322
+ const testRe = new RegExp(cfg.testPattern);
323
+ const testCmd = `${cfg.install} && ${cfg.test}`;
324
+ const progress = `${run.taskIndex + 1}/${tasks.length} ${task.id} ${task.title}`;
325
+ const taskJson = JSON.stringify(task, null, 2);
326
+ const agents = taskAgents(run.cycle, run.taskIndex, cfg.tddSplit);
327
+ // ── 紅燈:只寫測試,而且測試必須失敗 ──
328
+ if (run.taskPhase === "tests") {
329
+ const key = `${task.id}:tests`;
330
+ info(run, `🧪 [${progress}] 撰寫測試(${agents.tests})`);
331
+ const before = await headCommit(repo);
332
+ const { r, agent: testsAuthor } = await agentStep(run, agents.tests, `${task.id}-tests`, renderPrompt("implement-tests", { task: taskJson, testPattern: cfg.testPattern, testCmd }), { kind: "write", reset: () => resetTo(repo, before) });
333
+ if (!r.ok) {
334
+ await resetTo(repo, before);
335
+ return retry(run, key, `Agent 執行失敗:${r.summary}`, "implement");
336
+ }
337
+ const commit = await commitAll(repo, `test(${task.id}): ${task.title} [${testsAuthor}]`);
338
+ if (!commit)
339
+ return retry(run, key, "沒有任何檔案變更,這個階段必須撰寫測試。", "implement");
340
+ const changed = await changedFiles(repo, before, commit);
341
+ if (!changed.some((f) => testRe.test(f))) {
342
+ await resetTo(repo, before);
343
+ return retry(run, key, `沒有新增或修改任何符合 /${cfg.testPattern}/ 的測試檔。`, "implement");
344
+ }
345
+ const red = await runCommand(target(run, `${task.id}-red`), testCmd);
346
+ if (red.ok) {
347
+ await resetTo(repo, before);
348
+ return retry(run, key, "測試在功能尚未實作前就全部通過,代表測試沒有驗證到新行為。請撰寫會因功能尚未實作而失敗的測試。", "implement");
349
+ }
350
+ writeFileSync(flowFile(run, "red-output.txt"), red.output);
351
+ info(run, `🔴 [${progress}] 測試如預期失敗`);
352
+ return { ...succeed(run, key, "implement"), taskPhase: "code", testsCommit: commit, lastTestsAuthor: testsAuthor };
353
+ }
354
+ // ── 綠燈:實作到測試通過,而且不可動測試 ──
355
+ const key = `${task.id}:code`;
356
+ const testsCommit = run.testsCommit;
357
+ if (!testsCommit)
358
+ throw new Error("缺少 testsCommit,狀態不一致");
359
+ info(run, `🛠️ [${progress}] 實作(${agents.code},測試由 ${run.lastTestsAuthor ?? agents.tests} 撰寫)`);
360
+ const redOutput = existsSync(flowFile(run, "red-output.txt")) ? readFileSync(flowFile(run, "red-output.txt"), "utf8") : "";
361
+ const { r, agent: codeAuthor } = await agentStep(run, agents.code, `${task.id}-code`, renderPrompt("implement-code", { task: taskJson, testCmd, redOutput: tail(redOutput, 3000) }), { kind: "write", reset: () => resetTo(repo, testsCommit) });
362
+ if (!r.ok)
363
+ return retry(run, key, `Agent 執行失敗:${r.summary}`, "implement");
364
+ await commitAll(repo, `feat(${task.id}): ${task.title} [${codeAuthor}]`);
365
+ const touched = (await changedFiles(repo, testsCommit, await headCommit(repo))).filter((f) => testRe.test(f));
366
+ if (touched.length) {
367
+ await resetTo(repo, testsCommit);
368
+ return retry(run, key, `實作階段不可修改測試檔,已還原你的變更:${touched.join(", ")}`, "implement");
369
+ }
370
+ const green = await runCommand(target(run, `${task.id}-green`), testCmd);
371
+ if (!green.ok)
372
+ return retry(run, key, `測試仍未通過:\n\n\`\`\`\n${tail(green.output)}\n\`\`\``, "implement");
373
+ info(run, `🟢 [${progress}] 完成`);
374
+ return {
375
+ ...succeed(run, key, "implement"),
376
+ taskIndex: run.taskIndex + 1,
377
+ taskPhase: "tests",
378
+ testsCommit: undefined,
379
+ lastTestsAuthor: undefined,
380
+ lastWriter: codeAuthor,
381
+ };
382
+ }
383
+ async function verifyStage(run) {
384
+ info(run, "🔍 執行驗證");
385
+ const cfg = loadRepoConfig();
386
+ const results = [];
387
+ const install = await runCommand(target(run, "verify-install"), cfg.install);
388
+ if (!install.ok) {
389
+ results.push({ name: "install", ok: false, output: tail(install.output) });
390
+ }
391
+ else {
392
+ for (const check of cfg.checks) {
393
+ const r = await runCommand(target(run, `verify-${check.name}`), check.cmd);
394
+ info(run, ` ${r.ok ? "✓" : "✗"} ${check.name}`);
395
+ results.push({ name: check.name, ok: r.ok, output: tail(r.output, 3000) });
396
+ }
397
+ }
398
+ writeFileSync(flowFile(run, "verify.json"), JSON.stringify(results, null, 2));
399
+ const failed = results.filter((r) => !r.ok);
400
+ if (failed.length === 0)
401
+ return succeed(run, "verify", "review");
402
+ const report = failed.map((f) => `## ${f.name} 失敗\n\n\`\`\`\n${f.output}\n\`\`\``).join("\n\n");
403
+ return { ...retry(run, "verify", report, "fix"), fixSource: "verify" };
404
+ }
405
+ async function fixStage(run) {
406
+ const cfg = loadRepoConfig();
407
+ const source = run.fixSource ?? "verify";
408
+ const agent = fixAgent(run.cycle, { source, strategy: cfg.fixStrategy, lastWriter: run.lastWriter, lastReviewer: run.lastReviewer });
409
+ const why = source === "review" ? `依 ${run.lastReviewer ?? "reviewer"} 的審查意見` : "修正驗證錯誤";
410
+ info(run, `🩹 ${why}(${agent})`);
411
+ const repo = worktreeDir(run.id);
412
+ const feedback = readFeedback(run);
413
+ const before = await headCommit(repo);
414
+ const { r, agent: actual } = await agentStep(run, agent, "fix", renderPrompt("fix", { testPattern: cfg.testPattern }), {
415
+ kind: "write",
416
+ reset: () => resetTo(repo, before),
417
+ });
418
+ if (!r.ok)
419
+ return retry(run, "fix", `${feedback}\n\n(上次修正時 Agent 執行失敗:${r.summary})`, "fix");
420
+ await commitAll(repo, `fix: ${why} [${actual}]`);
421
+ const testRe = new RegExp(cfg.testPattern);
422
+ const deleted = (await changedFiles(repo, before, await headCommit(repo), "D")).filter((f) => testRe.test(f));
423
+ if (deleted.length) {
424
+ await resetTo(repo, before);
425
+ return retry(run, "fix", `${feedback}\n\n另外:不可刪除測試檔來讓檢查通過,已還原:${deleted.join(", ")}`, "fix");
426
+ }
427
+ // 修正者成為新的作者,下一輪審查會換成別人
428
+ return { ...to(run, "verify"), lastWriter: actual };
429
+ }
430
+ async function reviewStage(run) {
431
+ const cfg = loadRepoConfig();
432
+ const repo = worktreeDir(run.id);
433
+ const panel = reviewers(run.cycle, run.lastWriter, cfg.reviewQuorum);
434
+ writeFileSync(flowFile(run, "diff.patch"), await git(repo, "diff", `${run.baseBranch}...HEAD`));
435
+ const authors = [...new Set((await git(repo, "log", "--format=%s", `${run.baseBranch}..HEAD`)).match(/\[[^\]]+\]$/gm) ?? [])]
436
+ .map((s) => s.slice(1, -1));
437
+ const issues = [];
438
+ let firstObjector;
439
+ for (const reviewer of panel) {
440
+ info(run, `👀 程式碼審查(${reviewer})`);
441
+ rmSync(flowFile(run, "review.json"), { force: true });
442
+ const { r } = await agentStep(run, reviewer, "review", renderPrompt("review", { reviewer, authors: authors.join("、") || "未知" }), {
443
+ kind: "review",
444
+ });
445
+ await discardChanges(repo); // 審查者不可改程式碼
446
+ if (!r.ok)
447
+ return retry(run, "review-run", `Agent 執行失敗:${r.summary}`, "review");
448
+ const review = readJsonFile(flowFile(run, "review.json"), ReviewResult);
449
+ if (!review.ok)
450
+ return retry(run, "review-run", review.error, "review");
451
+ renameSync(flowFile(run, "review.json"), flowFile(run, `review-${reviewer}.json`));
452
+ if (review.data.verdict === "approve") {
453
+ info(run, ` ✓ ${reviewer} 核准`);
454
+ continue;
455
+ }
456
+ info(run, ` ✗ ${reviewer} 要求修改`);
457
+ firstObjector ??= reviewer;
458
+ issues.push(`### ${reviewer} 的意見\n\n` +
459
+ review.data.items
460
+ .filter((i) => i.status !== "met")
461
+ .map((i) => `- **${i.criterion}**(${i.status}):${i.note}`)
462
+ .join("\n"));
463
+ }
464
+ if (!firstObjector)
465
+ return succeed(run, "review", "pr");
466
+ return {
467
+ ...retry(run, "review", `程式碼審查要求修改:\n\n${issues.join("\n\n")}`, "fix"),
468
+ fixSource: "review",
469
+ lastReviewer: firstObjector,
470
+ };
471
+ }
472
+ async function prStage(run) {
473
+ const repo = worktreeDir(run.id);
474
+ const remotes = (await git(repo, "remote")).split("\n").filter(Boolean);
475
+ if (!remotes.includes("origin")) {
476
+ info(run, `✅ 沒有 origin,分支 ${run.branch} 已在本機 repo 中,可以直接檢視或合併`);
477
+ return to(run, "done");
478
+ }
479
+ info(run, "🚀 推送分支");
480
+ await git(repo, "push", "-u", "origin", run.branch);
481
+ const title = run.requirement.split("\n")[0].slice(0, 72);
482
+ const spec = existsSync(flowFile(run, "spec.md")) ? readFileSync(flowFile(run, "spec.md"), "utf8") : "";
483
+ const bodyPath = join(runDir(run.id), "pr-body.md");
484
+ writeFileSync(bodyPath, `> 由 agentflowctl 自動產生(run: ${run.id},參與的 agent:${run.cycle.join("、")},花費約 $${getCost(run.id).toFixed(2)})\n\n${spec}`);
485
+ const r = await exec("gh", ["pr", "create", "--head", run.branch, "--base", run.baseBranch, "--title", title, "--body-file", bodyPath], { cwd: repo });
486
+ if (r.code !== 0) {
487
+ info(run, `⚠️ gh pr create 失敗,分支已推送,請手動建立 PR:${r.stderr.trim()}`);
488
+ return to(run, "done");
489
+ }
490
+ return { ...to(run, "done"), prUrl: r.stdout.trim() };
491
+ }
492
+ const STAGES = {
493
+ spec: specStage,
494
+ plan: planStage,
495
+ plan_review: planReviewStage,
496
+ plan_fix: planFixStage,
497
+ implement: implementStage,
498
+ verify: verifyStage,
499
+ fix: fixStage,
500
+ review: reviewStage,
501
+ pr: prStage,
502
+ };
503
+ /** 一路推進到需要人介入(awaiting_approval、paused)或結束(done / failed)為止;每一步都寫回檔案,中斷後可接續 */
504
+ export async function advance(initial) {
505
+ let run = initial;
506
+ for (;;) {
507
+ if (["done", "failed", "awaiting_approval", "paused"].includes(run.stage))
508
+ return run;
509
+ const stage = run.stage;
510
+ const runs = agentRuns(run.id);
511
+ if (runs >= run.maxAgentRuns) {
512
+ return saveRun({
513
+ ...run,
514
+ stage: "failed",
515
+ failedStage: stage,
516
+ failureReason: `已執行 agent ${runs} 次,達到上限 ${run.maxAgentRuns}(可用 resume --max-agent-runs 調高)`,
517
+ });
518
+ }
519
+ const cost = getCost(run.id);
520
+ if (run.budgetUsd !== undefined && cost >= run.budgetUsd) {
521
+ return saveRun({
522
+ ...run,
523
+ stage: "failed",
524
+ failedStage: stage,
525
+ failureReason: `估計花費 $${cost.toFixed(2)},超過預算 $${run.budgetUsd}`,
526
+ });
527
+ }
528
+ try {
529
+ run = saveRun(await STAGES[stage](run));
530
+ }
531
+ catch (err) {
532
+ if (err instanceof QuotaPause) {
533
+ info(run, `⏸️ 暫停:${err.message}`);
534
+ return saveRun({ ...run, stage: "paused", pausedStage: stage, pauseReason: err.message });
535
+ }
536
+ return saveRun({ ...run, stage: "failed", failedStage: stage, failureReason: err.message });
537
+ }
538
+ }
539
+ }
540
+ //# sourceMappingURL=engine.js.map
package/dist/git.js ADDED
@@ -0,0 +1,59 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { isAbsolute, join } from "node:path";
4
+ import { exec } from "./proc.js";
5
+ /**
6
+ * agentflowctl 執行 git 時一律停用 hooks 與 fsmonitor。
7
+ * Agent 現在直接在你的電腦上執行,理論上可以寫入 .git;
8
+ * 這樣至少確保 agentflowctl 自己的 git 操作不會觸發 repo 內的任何程式。
9
+ */
10
+ const NO_HOOKS = join(tmpdir(), "agentflowctl-no-hooks"); // 空目錄,各平台通用(Windows 沒有 /dev/null)
11
+ mkdirSync(NO_HOOKS, { recursive: true });
12
+ const SAFE = ["-c", `core.hooksPath=${NO_HOOKS}`, "-c", "core.fsmonitor=false"];
13
+ const AUTHOR = ["-c", "user.name=agentflowctl", "-c", "user.email=agentflowctl@localhost"];
14
+ export async function git(repo, ...args) {
15
+ const r = await exec("git", [...SAFE, "-C", repo, ...args]);
16
+ if (r.code !== 0)
17
+ throw new Error(`git ${args.join(" ")} 失敗:${r.stderr.trim()}`);
18
+ return r.stdout.trim();
19
+ }
20
+ /** 把路徑加進所有 worktree 共用的 info/exclude(只加一次) */
21
+ export async function excludePaths(root, patterns) {
22
+ let common = await git(root, "rev-parse", "--git-common-dir");
23
+ if (!isAbsolute(common))
24
+ common = join(root, common);
25
+ const file = join(common, "info", "exclude");
26
+ const current = existsSync(file) ? readFileSync(file, "utf8") : "";
27
+ const missing = patterns.filter((p) => !current.split("\n").includes(p));
28
+ if (missing.length)
29
+ appendFileSync(file, `\n${missing.join("\n")}\n`);
30
+ }
31
+ /** 從基底分支建立新的 worktree 與分支,共用同一份 git 物件,不需要重新 clone */
32
+ export async function addWorktree(root, dest, base, branch) {
33
+ await excludePaths(root, [".agentflowctl/", ".flow/"]);
34
+ await git(root, "worktree", "add", "-b", branch, dest, base);
35
+ }
36
+ export async function removeWorktree(root, dest) {
37
+ await git(root, "worktree", "remove", "--force", dest);
38
+ }
39
+ export const headCommit = (repo) => git(repo, "rev-parse", "HEAD");
40
+ /** 有變更才 commit,回傳新的 commit hash;沒有變更回傳 undefined */
41
+ export async function commitAll(repo, message) {
42
+ await git(repo, "add", "-A");
43
+ if (!(await git(repo, "status", "--porcelain")))
44
+ return undefined;
45
+ await git(repo, ...AUTHOR, "commit", "-m", message);
46
+ return headCommit(repo);
47
+ }
48
+ /** 回到指定 commit 並清掉未追蹤檔案(被 exclude 或 .gitignore 的檔案不受影響) */
49
+ export async function resetTo(repo, commit) {
50
+ await git(repo, "reset", "--hard", commit);
51
+ await git(repo, "clean", "-fd");
52
+ }
53
+ export const discardChanges = (repo) => resetTo(repo, "HEAD");
54
+ export async function changedFiles(repo, from, to, filter) {
55
+ const args = ["diff", "--name-only", ...(filter ? [`--diff-filter=${filter}`] : []), from, to];
56
+ const out = await git(repo, ...args);
57
+ return out ? out.split("\n") : [];
58
+ }
59
+ //# sourceMappingURL=git.js.map
package/dist/paths.js ADDED
@@ -0,0 +1,22 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { join } from "node:path";
3
+ let root;
4
+ /** 目前所在的 git 專案根目錄;agentflowctl 的所有資料都放在 <root>/.agentflowctl/ */
5
+ export function projectRoot() {
6
+ if (!root) {
7
+ try {
8
+ root = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8" }).trim();
9
+ }
10
+ catch {
11
+ throw new Error("請在 git 專案資料夾內執行 agentflowctl");
12
+ }
13
+ }
14
+ return root;
15
+ }
16
+ export const agentflowctlDir = () => join(projectRoot(), ".agentflowctl");
17
+ export const runDir = (id) => join(agentflowctlDir(), "runs", id);
18
+ export const logDir = (id) => join(runDir(id), "logs");
19
+ /** 每個 run 一個 git worktree,Agent 只在這裡工作,不碰你正在編輯的檔案 */
20
+ export const worktreeDir = (id) => join(agentflowctlDir(), "worktrees", id);
21
+ export const flowDir = (id) => join(worktreeDir(id), ".flow");
22
+ //# sourceMappingURL=paths.js.map
package/dist/proc.js ADDED
@@ -0,0 +1,40 @@
1
+ import { spawn } from "node:child_process";
2
+ export function exec(cmd, args, opts = {}) {
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
+ const child = spawn(cmd, args, {
8
+ cwd: opts.cwd,
9
+ env,
10
+ shell: opts.shell ?? false,
11
+ });
12
+ let stdout = "";
13
+ let stderr = "";
14
+ let pending = "";
15
+ child.stdout.on("data", (d) => {
16
+ const s = d.toString();
17
+ stdout += s;
18
+ if (!opts.onStdoutLine)
19
+ return;
20
+ pending += s;
21
+ const lines = pending.split("\n");
22
+ pending = lines.pop() ?? "";
23
+ lines.forEach((line) => opts.onStdoutLine?.(line));
24
+ });
25
+ child.stderr.on("data", (d) => {
26
+ stderr += d.toString();
27
+ });
28
+ child.on("error", reject);
29
+ child.on("close", (code) => {
30
+ if (opts.onStdoutLine && pending)
31
+ opts.onStdoutLine(pending);
32
+ resolve({ code: code ?? 1, stdout, stderr });
33
+ });
34
+ child.stdin.on("error", () => { }); // 子程序不讀 stdin 時忽略 EPIPE
35
+ child.stdin.end(opts.input ?? "");
36
+ });
37
+ }
38
+ /** 以系統 shell 執行一整行指令(用於 install、test、build 等專案指令) */
39
+ export const execShell = (command, opts = {}) => exec(command, [], { ...opts, shell: true });
40
+ //# sourceMappingURL=proc.js.map