agentflowctl 0.4.0 → 0.6.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 +41 -27
- package/dist/agentConfig.js +4 -4
- package/dist/arbitration.js +12 -0
- package/dist/cli.js +17 -17
- package/dist/config.js +0 -2
- package/dist/engine.js +148 -36
- package/dist/handoff.js +109 -0
- package/dist/paths.js +1 -0
- package/dist/roles.js +70 -39
- package/dist/runner.js +2 -1
- package/dist/schemas.js +58 -3
- package/dist/setup.js +2 -2
- package/examples/flow.config.json +1 -0
- package/package.json +1 -1
- package/prompts/fix.md +11 -0
- package/prompts/implement-code.md +11 -0
- package/prompts/implement-tests.md +11 -0
- package/prompts/plan-arbiter.md +13 -1
- package/prompts/plan-fix.md +11 -0
- package/prompts/plan-review.md +12 -1
- package/prompts/plan.md +11 -0
- package/prompts/review.md +12 -1
- package/prompts/spec.md +11 -0
package/dist/engine.js
CHANGED
|
@@ -2,14 +2,16 @@ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { config } from "./config.js";
|
|
5
|
+
import { arbitrationDecision } from "./arbitration.js";
|
|
5
6
|
import { detectProjectDefaults, withProjectDefaults } from "./detect.js";
|
|
6
7
|
import { changedFiles, commitAll, discardChanges, git, headCommit, resetTo } from "./git.js";
|
|
8
|
+
import { acceptHandoff, openActions, prepareHandoff, previewHandoff, readHandoff, recoverHandoff, reviewHandoffGate, validateHandoffResponse } from "./handoff.js";
|
|
7
9
|
import { flowDir, logDir, projectRoot, runDir, worktreeDir } from "./paths.js";
|
|
8
10
|
import { CMD_AGENT, nextLogFile } from "./logs.js";
|
|
9
11
|
import { exec } from "./proc.js";
|
|
10
12
|
import { arbiterPanel, availableAgent, fixAgent, planAgent, planFixAgent, reviewers, specAgent, taskAgents } from "./roles.js";
|
|
11
13
|
import { resolveAgent, runAgent, runCommand } from "./runner.js";
|
|
12
|
-
import { AcceptanceList, RepoConfig, ReviewResult, TaskList, } from "./schemas.js";
|
|
14
|
+
import { AcceptanceList, ArbiterResult, RepoConfig, ReviewResult, TaskList, } from "./schemas.js";
|
|
13
15
|
import { addSubstitution, addUsage, agentRuns, saveRun } from "./store.js";
|
|
14
16
|
import { orderTasks } from "./tasks.js";
|
|
15
17
|
import { readJsonFile, renderPrompt, tail } from "./util.js";
|
|
@@ -29,6 +31,14 @@ export class QuotaPause extends Error {
|
|
|
29
31
|
}
|
|
30
32
|
/** 這次執行中已確認額度用完的 agent;程序結束即清空,resume 時會重新嘗試 */
|
|
31
33
|
const exhausted = new Set();
|
|
34
|
+
function handoffTarget(run) {
|
|
35
|
+
return ["spec", "plan", "plan_review", "plan_fix"].includes(run.stage) ? "plan" : "code";
|
|
36
|
+
}
|
|
37
|
+
/** 同一輪重跑使用相同 key;重試次數或 panel 位置改變時使用新 key。 */
|
|
38
|
+
function handoffKey(run, step, slot, agent) {
|
|
39
|
+
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]);
|
|
41
|
+
}
|
|
32
42
|
async function agentStep(run, planned, step, prompt, mode) {
|
|
33
43
|
const cfg = loadRepoConfig();
|
|
34
44
|
const reset = mode.reset ?? (() => discardChanges(worktreeDir(run.id)));
|
|
@@ -38,7 +48,7 @@ async function agentStep(run, planned, step, prompt, mode) {
|
|
|
38
48
|
if (mode.kind === "review") {
|
|
39
49
|
throw new QuotaPause(`${agent} 的額度已用完;${step} 是審查步驟,不由另一家代打`);
|
|
40
50
|
}
|
|
41
|
-
const sub = availableAgent(run.cycle, agent, [...exhausted]);
|
|
51
|
+
const sub = availableAgent(run.cycle, agent, [...exhausted], `${run.id}:${step}:sub`);
|
|
42
52
|
if (!sub)
|
|
43
53
|
throw new QuotaPause(`所有 agent 的額度都已用完(${[...exhausted].join("、")})`);
|
|
44
54
|
const note = step.endsWith("-code") && sub === run.lastTestsAuthor ? "測試與實作由同一家負責" : undefined;
|
|
@@ -46,11 +56,13 @@ async function agentStep(run, planned, step, prompt, mode) {
|
|
|
46
56
|
info(run, `🔁 ${agent} 額度已用完,${step} 由 ${sub} 代打${note ? `(注意:${note})` : ""}`);
|
|
47
57
|
agent = sub;
|
|
48
58
|
}
|
|
59
|
+
const callKey = handoffKey(run, step, mode.slot ?? 0, agent);
|
|
60
|
+
prepareHandoff(run.id, callKey, handoffTarget(run), mode.blind ?? false);
|
|
49
61
|
const r = await runAgent(agent, resolveAgent(cfg, agent), target(run, step, agent), prompt);
|
|
50
62
|
addUsage(run.id, { stage: step, agent, inputTokens: r.inputTokens, outputTokens: r.outputTokens });
|
|
51
63
|
if (!r.quotaExhausted) {
|
|
52
64
|
reportMeta(run, agent, r);
|
|
53
|
-
return { r, agent };
|
|
65
|
+
return { r, agent, step, callKey };
|
|
54
66
|
}
|
|
55
67
|
info(run, `⛽ ${agent} 的額度已用完`);
|
|
56
68
|
exhausted.add(agent);
|
|
@@ -58,6 +70,28 @@ async function agentStep(run, planned, step, prompt, mode) {
|
|
|
58
70
|
// 迴圈回到開頭:review 會停下,write 會找代打
|
|
59
71
|
}
|
|
60
72
|
}
|
|
73
|
+
/** 原有關卡已通過後才接受交接;失敗回覆不進入正式紀錄。 */
|
|
74
|
+
function finishHandoff(run, outcome, role, gate) {
|
|
75
|
+
const parsed = validateHandoffResponse(run.id);
|
|
76
|
+
if (!parsed.ok)
|
|
77
|
+
return parsed.error;
|
|
78
|
+
try {
|
|
79
|
+
const source = {
|
|
80
|
+
stage: run.stage, step: outcome.step, agent: outcome.agent, callKey: outcome.callKey,
|
|
81
|
+
};
|
|
82
|
+
const preview = previewHandoff(readHandoff(run.id), outcome.callKey, source, parsed.data, role);
|
|
83
|
+
if (gate) {
|
|
84
|
+
const error = reviewHandoffGate(preview, gate.target, gate.verdict);
|
|
85
|
+
if (error)
|
|
86
|
+
return error;
|
|
87
|
+
}
|
|
88
|
+
acceptHandoff(run.id, outcome.callKey, source, parsed.data, role);
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
return error.message;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
61
95
|
/** 印出回覆裡的 XML 中繼資料;只供人檢視,關卡仍由程式檢查決定 */
|
|
62
96
|
function reportMeta(run, agent, r) {
|
|
63
97
|
if (!r.meta)
|
|
@@ -110,9 +144,10 @@ function loadOrderedTasks(run) {
|
|
|
110
144
|
}
|
|
111
145
|
// ───────────────────────── 各階段 ─────────────────────────
|
|
112
146
|
async function specStage(run) {
|
|
113
|
-
const agent = specAgent(run.cycle);
|
|
147
|
+
const agent = specAgent(run.cycle, run.id);
|
|
114
148
|
info(run, `📝 產生規格(${agent})`);
|
|
115
|
-
const
|
|
149
|
+
const outcome = await agentStep(run, agent, "spec", renderPrompt("spec", { requirement: run.requirement }), { kind: "write" });
|
|
150
|
+
const { r } = outcome;
|
|
116
151
|
await discardChanges(worktreeDir(run.id)); // 這個階段只允許寫 .flow/
|
|
117
152
|
if (!r.ok)
|
|
118
153
|
return retry(run, "spec", `Agent 執行失敗:${r.summary}`, "spec");
|
|
@@ -124,6 +159,9 @@ async function specStage(run) {
|
|
|
124
159
|
const ids = ac.data.map((a) => a.id);
|
|
125
160
|
if (new Set(ids).size !== ids.length)
|
|
126
161
|
return retry(run, "spec", "驗收條件 id 有重複", "spec");
|
|
162
|
+
const handoffError = finishHandoff(run, outcome, "writer");
|
|
163
|
+
if (handoffError)
|
|
164
|
+
return retry(run, "spec", handoffError, "spec");
|
|
127
165
|
return succeed(run, "spec", "plan");
|
|
128
166
|
}
|
|
129
167
|
// ── 規格與計畫檔案:審查者與仲裁者只能讀,不能改 ──
|
|
@@ -170,40 +208,50 @@ function announceTasks(run, ordered) {
|
|
|
170
208
|
const cfg = loadRepoConfig();
|
|
171
209
|
info(run, `📋 共 ${ordered.length} 個任務:${ordered.map((t) => t.id).join(" → ")}`);
|
|
172
210
|
ordered.forEach((t, i) => {
|
|
173
|
-
const a = taskAgents(run.cycle, i, cfg.tddSplit);
|
|
211
|
+
const a = taskAgents(run.cycle, i, cfg.tddSplit, run.id);
|
|
174
212
|
info(run, ` ${t.id} 測試:${a.tests} 實作:${a.code}`);
|
|
175
213
|
});
|
|
176
214
|
}
|
|
177
215
|
/** 計畫定案後:預設直接開始實作;--manual-plan 時才停下來等人 */
|
|
178
216
|
function planSettled(run, key) {
|
|
217
|
+
const pending = openActions(readHandoff(run.id), "plan");
|
|
218
|
+
if (pending.length)
|
|
219
|
+
return retry(run, "plan-handoff", `計畫仍有未結交接事項:${pending.map((item) => item.id).join("、")}`, "plan_fix");
|
|
179
220
|
const next = run.autopilot ? "implement" : "awaiting_approval";
|
|
180
221
|
const ordered = loadOrderedTasks(run);
|
|
181
222
|
announceTasks(run, ordered);
|
|
182
223
|
if (!run.autopilot) {
|
|
183
224
|
info(run, `✋ 計畫已通過審查,請檢視 ${flowFile(run, "plan.md")},確認後執行 agentflowctl approve ${run.id}`);
|
|
184
225
|
}
|
|
185
|
-
|
|
226
|
+
const settled = succeed(run, key, next);
|
|
227
|
+
const attempts = { ...settled.attempts };
|
|
228
|
+
delete attempts["plan-arbitration"];
|
|
229
|
+
return { ...settled, attempts, taskIndex: 0, taskPhase: "tests" };
|
|
186
230
|
}
|
|
187
231
|
async function planStage(run) {
|
|
188
|
-
const agent = planAgent(run.cycle);
|
|
232
|
+
const agent = planAgent(run.cycle, run.id);
|
|
189
233
|
info(run, `🗺️ 拆解任務(${agent})`);
|
|
190
234
|
const cfg = loadRepoConfig();
|
|
191
|
-
const
|
|
235
|
+
const outcome = await agentStep(run, agent, "plan", renderPrompt("plan", { testPattern: cfg.testPattern }), { kind: "write" });
|
|
236
|
+
const { r, agent: actual } = outcome;
|
|
192
237
|
await discardChanges(worktreeDir(run.id));
|
|
193
238
|
if (!r.ok)
|
|
194
239
|
return retry(run, "plan", `Agent 執行失敗:${r.summary}`, "plan");
|
|
195
240
|
const ordered = validatePlan(run);
|
|
196
241
|
if (typeof ordered === "string")
|
|
197
242
|
return retry(run, "plan", ordered, "plan");
|
|
243
|
+
const handoffError = finishHandoff(run, outcome, "writer");
|
|
244
|
+
if (handoffError)
|
|
245
|
+
return retry(run, "plan", handoffError, "plan");
|
|
198
246
|
acceptPlan(run, ordered);
|
|
199
247
|
rmSync(flowFile(run, "plan-review-last.txt"), { force: true });
|
|
200
248
|
return { ...succeed(run, "plan", "plan_review"), planWriter: actual };
|
|
201
249
|
}
|
|
202
250
|
async function planReviewStage(run) {
|
|
203
251
|
const cfg = loadRepoConfig();
|
|
204
|
-
const author = run.planWriter ?? planAgent(run.cycle);
|
|
205
|
-
const panel = reviewers(run.cycle, author, cfg.planReviewQuorum);
|
|
252
|
+
const author = run.planWriter ?? planAgent(run.cycle, run.id);
|
|
206
253
|
const round = (run.attempts["plan-review"] ?? 0) + 1;
|
|
254
|
+
const panel = reviewers(run.cycle, author, cfg.planReviewQuorum, `${run.id}:plan-review:${round}`);
|
|
207
255
|
const issues = [];
|
|
208
256
|
const issueLines = []; // 只含意見內容、不含審查者名稱,用來偵測僵持
|
|
209
257
|
let firstObjector;
|
|
@@ -211,7 +259,8 @@ async function planReviewStage(run) {
|
|
|
211
259
|
info(run, `🧐 計畫審查第 ${round} 輪(${reviewer},作者 ${author})`);
|
|
212
260
|
const snap = snapshotPlan(run);
|
|
213
261
|
rmSync(flowFile(run, "plan-review.json"), { force: true });
|
|
214
|
-
const
|
|
262
|
+
const outcome = await agentStep(run, reviewer, "plan-review", renderPrompt("plan-review", { reviewer, author, requirement: run.requirement }), { kind: "review", slot: panel.indexOf(reviewer), reset: async () => { await discardChanges(worktreeDir(run.id)); restorePlan(run, snap); } });
|
|
263
|
+
const { r } = outcome;
|
|
215
264
|
await discardChanges(worktreeDir(run.id));
|
|
216
265
|
const tampered = restorePlan(run, snap);
|
|
217
266
|
if (tampered.length)
|
|
@@ -221,6 +270,9 @@ async function planReviewStage(run) {
|
|
|
221
270
|
const review = readJsonFile(flowFile(run, "plan-review.json"), ReviewResult);
|
|
222
271
|
if (!review.ok)
|
|
223
272
|
return retry(run, "plan-review-run", review.error, "plan_review");
|
|
273
|
+
const handoffError = finishHandoff(run, outcome, "reviewer", { target: "plan", verdict: review.data.verdict });
|
|
274
|
+
if (handoffError)
|
|
275
|
+
return retry(run, "plan-review-run", handoffError, "plan_review");
|
|
224
276
|
// 審查紀錄移到 worktree 外面:之後的仲裁者看不到是哪一家提的意見
|
|
225
277
|
mkdirSync(join(runDir(run.id), "reviews"), { recursive: true });
|
|
226
278
|
renameSync(flowFile(run, "plan-review.json"), join(runDir(run.id), "reviews", `plan-review-${round}-${reviewer}.json`));
|
|
@@ -259,11 +311,17 @@ async function planReviewStage(run) {
|
|
|
259
311
|
}
|
|
260
312
|
async function planFixStage(run) {
|
|
261
313
|
const cfg = loadRepoConfig();
|
|
262
|
-
const agent = planFixAgent(run.cycle, {
|
|
314
|
+
const agent = planFixAgent(run.cycle, {
|
|
315
|
+
strategy: cfg.fixStrategy,
|
|
316
|
+
planWriter: run.planWriter,
|
|
317
|
+
planReviewer: run.planReviewer,
|
|
318
|
+
seed: `${run.id}:plan-fix:${run.attempts["plan-review"] ?? 0}`,
|
|
319
|
+
});
|
|
263
320
|
info(run, `✏️ 依 ${run.planReviewer ?? "審查者"} 的意見修改計畫(${agent})`);
|
|
264
321
|
const feedback = readFeedback(run);
|
|
265
322
|
const snap = snapshotPlan(run);
|
|
266
|
-
const
|
|
323
|
+
const outcome = 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); } });
|
|
324
|
+
const { r, agent: actual } = outcome;
|
|
267
325
|
await discardChanges(worktreeDir(run.id)); // 只允許改 .flow/
|
|
268
326
|
if (!r.ok) {
|
|
269
327
|
restorePlan(run, snap);
|
|
@@ -274,6 +332,11 @@ async function planFixStage(run) {
|
|
|
274
332
|
restorePlan(run, snap);
|
|
275
333
|
return retry(run, "plan-fix", `${feedback}\n\n另外,修改後的計畫沒有通過格式檢查,已還原:\n${ordered}`, "plan_fix");
|
|
276
334
|
}
|
|
335
|
+
const handoffError = finishHandoff(run, outcome, "writer");
|
|
336
|
+
if (handoffError) {
|
|
337
|
+
restorePlan(run, snap);
|
|
338
|
+
return retry(run, "plan-fix", handoffError, "plan_fix");
|
|
339
|
+
}
|
|
277
340
|
acceptPlan(run, ordered);
|
|
278
341
|
writeFileSync(flowFile(run, "feedback.md"), feedback); // 保留審查意見,讓下一輪審查者知道上次提了什麼
|
|
279
342
|
const attempts = { ...run.attempts };
|
|
@@ -286,39 +349,56 @@ async function planFixStage(run) {
|
|
|
286
349
|
*/
|
|
287
350
|
async function arbitratePlan(run) {
|
|
288
351
|
const cfg = loadRepoConfig();
|
|
289
|
-
const
|
|
352
|
+
const arbitrationRound = (run.attempts["plan-arbitration"] ?? 0) + 1;
|
|
353
|
+
const panel = arbiterPanel(run.cycle, `${run.id}:arbiter`, run.planWriter, run.planReviewer);
|
|
290
354
|
const mode = panel.length > 1 ? "雙盲交叉仲裁" : "第三方仲裁";
|
|
291
355
|
const verdicts = [];
|
|
292
|
-
for (const arbiter of panel) {
|
|
356
|
+
for (const [slot, arbiter] of panel.entries()) {
|
|
293
357
|
info(run, `⚖️ ${mode}(${arbiter})`);
|
|
294
358
|
const snap = snapshotPlan(run);
|
|
295
359
|
rmSync(flowFile(run, "plan-arbiter.json"), { force: true });
|
|
296
|
-
const
|
|
297
|
-
kind: "review",
|
|
360
|
+
const outcome = await agentStep(run, arbiter, "plan-arbiter", renderPrompt("plan-arbiter", { requirement: run.requirement }), {
|
|
361
|
+
kind: "review", slot, blind: true,
|
|
298
362
|
reset: async () => { await discardChanges(worktreeDir(run.id)); restorePlan(run, snap); },
|
|
299
363
|
});
|
|
364
|
+
const { r } = outcome;
|
|
300
365
|
await discardChanges(worktreeDir(run.id));
|
|
301
366
|
restorePlan(run, snap);
|
|
302
|
-
const result = r.ok ? readJsonFile(flowFile(run, "plan-arbiter.json"),
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
367
|
+
const result = r.ok ? readJsonFile(flowFile(run, "plan-arbiter.json"), ArbiterResult) : undefined;
|
|
368
|
+
const handoffError = result?.ok ? finishHandoff(run, outcome, "reviewer", { target: "plan", verdict: result.data.verdict }) : undefined;
|
|
369
|
+
if (!result?.ok || handoffError) {
|
|
370
|
+
const outputError = !r.ok ? `Agent 執行失敗:${r.summary}` : !result ? "未產生有效裁決" : result.ok ? "未產生有效裁決" : result.error;
|
|
371
|
+
const reason = handoffError ?? outputError;
|
|
372
|
+
info(run, ` ⏸️ ${arbiter} 未產生有效裁決:${reason}`);
|
|
373
|
+
return { ...run, stage: "paused", pausedStage: "plan_review", pauseReason: `${arbiter} 仲裁未產生有效裁決:${reason}` };
|
|
306
374
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
375
|
+
mkdirSync(join(runDir(run.id), "reviews"), { recursive: true });
|
|
376
|
+
renameSync(flowFile(run, "plan-arbiter.json"), join(runDir(run.id), "reviews", `plan-arbiter-${arbitrationRound}-${arbiter}.json`));
|
|
377
|
+
const verdict = result.data.verdict;
|
|
378
|
+
const notes = result.data.items.map((i) => `- ${i.criterion}:${i.note}`);
|
|
379
|
+
info(run, ` ${verdict === "approve" ? "✓" : "✗"} ${arbiter}:${verdict === "approve" ? "可以執行" : "不可執行"}`);
|
|
310
380
|
verdicts.push({ arbiter, verdict, notes });
|
|
311
381
|
}
|
|
312
382
|
rmSync(flowFile(run, "dispute.md"), { force: true });
|
|
313
383
|
const approvals = verdicts.filter((v) => v.verdict === "approve").length;
|
|
314
384
|
const unanimous = approvals === verdicts.length;
|
|
315
|
-
const decision =
|
|
385
|
+
const decision = arbitrationDecision(verdicts.map((v) => v.verdict), cfg.tieBreak);
|
|
386
|
+
if (decision === "invalid")
|
|
387
|
+
throw new Error("仲裁結果缺少有效裁決");
|
|
316
388
|
const summary = unanimous
|
|
317
389
|
? `${mode}一致核准`
|
|
318
390
|
: approvals === 0
|
|
319
|
-
? `${mode}
|
|
391
|
+
? `${mode}沒有任何一方核准${decision === "revise" ? ",交回計畫修訂" : ""}`
|
|
320
392
|
: `${mode}意見分歧,依 tieBreak=${cfg.tieBreak} ${cfg.tieBreak === "proceed" ? "繼續實作" : "停止"}`;
|
|
321
393
|
const record = verdicts.map((v) => `### ${v.arbiter}(${v.verdict})\n\n${v.notes.join("\n")}`).join("\n\n");
|
|
394
|
+
if (decision === "revise") {
|
|
395
|
+
const attempts = { ...run.attempts, "plan-arbitration": arbitrationRound };
|
|
396
|
+
delete attempts["plan-review"];
|
|
397
|
+
rmSync(flowFile(run, "plan-review-last.txt"), { force: true });
|
|
398
|
+
writeFileSync(flowFile(run, "feedback.md"), `# 仲裁要求修訂(第 ${arbitrationRound} 次)\n\n${summary}\n\n${record}\n`);
|
|
399
|
+
info(run, ` → ${summary}`);
|
|
400
|
+
return { ...run, attempts, stage: "plan_fix" };
|
|
401
|
+
}
|
|
322
402
|
writeFileSync(flowFile(run, "plan.md"), `${readFileSync(flowFile(run, "plan.md"), "utf8")}\n\n## 仲裁紀錄\n\n**結果:${summary}**\n\n${record}\n`);
|
|
323
403
|
if (decision === "proceed") {
|
|
324
404
|
info(run, ` → ${summary}`);
|
|
@@ -339,13 +419,14 @@ async function implementStage(run) {
|
|
|
339
419
|
const testCmd = `${cfg.install} && ${cfg.test}`;
|
|
340
420
|
const progress = `${run.taskIndex + 1}/${tasks.length} ${task.id} ${task.title}`;
|
|
341
421
|
const taskJson = JSON.stringify(task, null, 2);
|
|
342
|
-
const agents = taskAgents(run.cycle, run.taskIndex, cfg.tddSplit);
|
|
422
|
+
const agents = taskAgents(run.cycle, run.taskIndex, cfg.tddSplit, run.id);
|
|
343
423
|
// ── 紅燈:只寫測試,而且測試必須失敗 ──
|
|
344
424
|
if (run.taskPhase === "tests") {
|
|
345
425
|
const key = `${task.id}:tests`;
|
|
346
426
|
info(run, `🧪 [${progress}] 撰寫測試(${agents.tests})`);
|
|
347
427
|
const before = await headCommit(repo);
|
|
348
|
-
const
|
|
428
|
+
const outcome = await agentStep(run, agents.tests, `${task.id}-tests`, renderPrompt("implement-tests", { task: taskJson, testPattern: cfg.testPattern, testCmd }), { kind: "write", reset: () => resetTo(repo, before) });
|
|
429
|
+
const { r, agent: testsAuthor } = outcome;
|
|
349
430
|
if (!r.ok) {
|
|
350
431
|
await resetTo(repo, before);
|
|
351
432
|
return retry(run, key, `Agent 執行失敗:${r.summary}`, "implement");
|
|
@@ -363,6 +444,11 @@ async function implementStage(run) {
|
|
|
363
444
|
await resetTo(repo, before);
|
|
364
445
|
return retry(run, key, "測試在功能尚未實作前就全部通過,代表測試沒有驗證到新行為。請撰寫會因功能尚未實作而失敗的測試。", "implement");
|
|
365
446
|
}
|
|
447
|
+
const handoffError = finishHandoff(run, outcome, "writer");
|
|
448
|
+
if (handoffError) {
|
|
449
|
+
await resetTo(repo, before);
|
|
450
|
+
return retry(run, key, handoffError, "implement");
|
|
451
|
+
}
|
|
366
452
|
writeFileSync(flowFile(run, "red-output.txt"), red.output);
|
|
367
453
|
info(run, `🔴 [${progress}] 測試如預期失敗`);
|
|
368
454
|
return { ...succeed(run, key, "implement"), taskPhase: "code", testsCommit: commit, lastTestsAuthor: testsAuthor };
|
|
@@ -374,7 +460,8 @@ async function implementStage(run) {
|
|
|
374
460
|
throw new Error("缺少 testsCommit,狀態不一致");
|
|
375
461
|
info(run, `🛠️ [${progress}] 實作(${agents.code},測試由 ${run.lastTestsAuthor ?? agents.tests} 撰寫)`);
|
|
376
462
|
const redOutput = existsSync(flowFile(run, "red-output.txt")) ? readFileSync(flowFile(run, "red-output.txt"), "utf8") : "";
|
|
377
|
-
const
|
|
463
|
+
const outcome = await agentStep(run, agents.code, `${task.id}-code`, renderPrompt("implement-code", { task: taskJson, testCmd, redOutput: tail(redOutput, 3000) }), { kind: "write", reset: () => resetTo(repo, testsCommit) });
|
|
464
|
+
const { r, agent: codeAuthor } = outcome;
|
|
378
465
|
if (!r.ok)
|
|
379
466
|
return retry(run, key, `Agent 執行失敗:${r.summary}`, "implement");
|
|
380
467
|
await commitAll(repo, `feat(${task.id}): ${task.title} [${codeAuthor}]`);
|
|
@@ -388,6 +475,11 @@ async function implementStage(run) {
|
|
|
388
475
|
info(run, ` ✗ 測試仍未通過${logHint(run, green.seq)}`);
|
|
389
476
|
return retry(run, key, `測試仍未通過:\n\n\`\`\`\n${tail(green.output)}\n\`\`\``, "implement");
|
|
390
477
|
}
|
|
478
|
+
const handoffError = finishHandoff(run, outcome, "writer");
|
|
479
|
+
if (handoffError) {
|
|
480
|
+
await resetTo(repo, testsCommit);
|
|
481
|
+
return retry(run, key, handoffError, "implement");
|
|
482
|
+
}
|
|
391
483
|
info(run, `🟢 [${progress}] 完成`);
|
|
392
484
|
return {
|
|
393
485
|
...succeed(run, key, "implement"),
|
|
@@ -424,16 +516,23 @@ async function verifyStage(run) {
|
|
|
424
516
|
async function fixStage(run) {
|
|
425
517
|
const cfg = loadRepoConfig();
|
|
426
518
|
const source = run.fixSource ?? "verify";
|
|
427
|
-
const agent = fixAgent(run.cycle, {
|
|
519
|
+
const agent = fixAgent(run.cycle, {
|
|
520
|
+
source,
|
|
521
|
+
strategy: cfg.fixStrategy,
|
|
522
|
+
lastWriter: run.lastWriter,
|
|
523
|
+
lastReviewer: run.lastReviewer,
|
|
524
|
+
seed: `${run.id}:fix:${run.attempts.review ?? 0}`,
|
|
525
|
+
});
|
|
428
526
|
const why = source === "review" ? `依 ${run.lastReviewer ?? "reviewer"} 的審查意見` : "修正驗證錯誤";
|
|
429
527
|
info(run, `🩹 ${why}(${agent})`);
|
|
430
528
|
const repo = worktreeDir(run.id);
|
|
431
529
|
const feedback = readFeedback(run);
|
|
432
530
|
const before = await headCommit(repo);
|
|
433
|
-
const
|
|
531
|
+
const outcome = await agentStep(run, agent, "fix", renderPrompt("fix", { testPattern: cfg.testPattern }), {
|
|
434
532
|
kind: "write",
|
|
435
533
|
reset: () => resetTo(repo, before),
|
|
436
534
|
});
|
|
535
|
+
const { r, agent: actual } = outcome;
|
|
437
536
|
if (!r.ok)
|
|
438
537
|
return retry(run, "fix", `${feedback}\n\n(上次修正時 Agent 執行失敗:${r.summary})`, "fix");
|
|
439
538
|
await commitAll(repo, `fix: ${why} [${actual}]`);
|
|
@@ -443,30 +542,39 @@ async function fixStage(run) {
|
|
|
443
542
|
await resetTo(repo, before);
|
|
444
543
|
return retry(run, "fix", `${feedback}\n\n另外:不可刪除測試檔來讓檢查通過,已還原:${deleted.join(", ")}`, "fix");
|
|
445
544
|
}
|
|
545
|
+
const handoffError = finishHandoff(run, outcome, "writer");
|
|
546
|
+
if (handoffError) {
|
|
547
|
+
await resetTo(repo, before);
|
|
548
|
+
return retry(run, "fix", handoffError, "fix");
|
|
549
|
+
}
|
|
446
550
|
// 修正者成為新的作者,下一輪審查會換成別人
|
|
447
551
|
return { ...to(run, "verify"), lastWriter: actual };
|
|
448
552
|
}
|
|
449
553
|
async function reviewStage(run) {
|
|
450
554
|
const cfg = loadRepoConfig();
|
|
451
555
|
const repo = worktreeDir(run.id);
|
|
452
|
-
const panel = reviewers(run.cycle, run.lastWriter, cfg.reviewQuorum);
|
|
556
|
+
const panel = reviewers(run.cycle, run.lastWriter, cfg.reviewQuorum, `${run.id}:review:${run.attempts.review ?? 0}`);
|
|
453
557
|
writeFileSync(flowFile(run, "diff.patch"), await git(repo, "diff", `${run.baseBranch}...HEAD`));
|
|
454
558
|
const authors = [...new Set((await git(repo, "log", "--format=%s", `${run.baseBranch}..HEAD`)).match(/\[[^\]]+\]$/gm) ?? [])]
|
|
455
559
|
.map((s) => s.slice(1, -1));
|
|
456
560
|
const issues = [];
|
|
457
561
|
let firstObjector;
|
|
458
|
-
for (const reviewer of panel) {
|
|
562
|
+
for (const [slot, reviewer] of panel.entries()) {
|
|
459
563
|
info(run, `👀 程式碼審查(${reviewer})`);
|
|
460
564
|
rmSync(flowFile(run, "review.json"), { force: true });
|
|
461
|
-
const
|
|
462
|
-
kind: "review",
|
|
565
|
+
const outcome = await agentStep(run, reviewer, "review", renderPrompt("review", { reviewer, authors: authors.join("、") || "未知" }), {
|
|
566
|
+
kind: "review", slot,
|
|
463
567
|
});
|
|
568
|
+
const { r } = outcome;
|
|
464
569
|
await discardChanges(repo); // 審查者不可改程式碼
|
|
465
570
|
if (!r.ok)
|
|
466
571
|
return retry(run, "review-run", `Agent 執行失敗:${r.summary}`, "review");
|
|
467
572
|
const review = readJsonFile(flowFile(run, "review.json"), ReviewResult);
|
|
468
573
|
if (!review.ok)
|
|
469
574
|
return retry(run, "review-run", review.error, "review");
|
|
575
|
+
const handoffError = finishHandoff(run, outcome, "reviewer", { target: "code", verdict: review.data.verdict });
|
|
576
|
+
if (handoffError)
|
|
577
|
+
return retry(run, "review-run", handoffError, "review");
|
|
470
578
|
renameSync(flowFile(run, "review.json"), flowFile(run, `review-${reviewer}.json`));
|
|
471
579
|
if (review.data.verdict === "approve") {
|
|
472
580
|
info(run, ` ✓ ${reviewer} 核准`);
|
|
@@ -489,6 +597,9 @@ async function reviewStage(run) {
|
|
|
489
597
|
};
|
|
490
598
|
}
|
|
491
599
|
async function prStage(run) {
|
|
600
|
+
const pending = openActions(readHandoff(run.id));
|
|
601
|
+
if (pending.length)
|
|
602
|
+
return { ...run, stage: "failed", failedStage: "pr", failureReason: `仍有未結交接事項:${pending.map((item) => item.id).join("、")}` };
|
|
492
603
|
const repo = worktreeDir(run.id);
|
|
493
604
|
const remotes = (await git(repo, "remote")).split("\n").filter(Boolean);
|
|
494
605
|
if (!remotes.includes("origin")) {
|
|
@@ -522,6 +633,7 @@ const STAGES = {
|
|
|
522
633
|
/** 一路推進到需要人介入(awaiting_approval、paused)或結束(done / failed)為止;每一步都寫回檔案,中斷後可接續 */
|
|
523
634
|
export async function advance(initial) {
|
|
524
635
|
let run = initial;
|
|
636
|
+
recoverHandoff(run.id);
|
|
525
637
|
for (;;) {
|
|
526
638
|
if (["done", "failed", "awaiting_approval", "paused"].includes(run.stage))
|
|
527
639
|
return run;
|
package/dist/handoff.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { flowDir, handoffPath, runDir } from "./paths.js";
|
|
6
|
+
import { HandoffLedger, HandoffResponse, HandoffSource } from "./schemas.js";
|
|
7
|
+
import { readJsonFile } from "./util.js";
|
|
8
|
+
const responsePath = (id) => join(flowDir(id), "handoff-response.json");
|
|
9
|
+
const receiptsDir = (id) => join(runDir(id), "handoff-receipts");
|
|
10
|
+
const keyHash = (key) => createHash("sha256").update(key).digest("hex").slice(0, 16);
|
|
11
|
+
const receiptPath = (id, key) => join(receiptsDir(id), `${keyHash(key)}.json`);
|
|
12
|
+
const Receipt = z.object({ callKey: z.string(), source: HandoffSource, response: HandoffResponse, role: z.enum(["writer", "reviewer"]) });
|
|
13
|
+
function writeAtomic(path, content) {
|
|
14
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
15
|
+
const tmp = `${path}.tmp`;
|
|
16
|
+
writeFileSync(tmp, content);
|
|
17
|
+
renameSync(tmp, path);
|
|
18
|
+
}
|
|
19
|
+
export function readHandoff(id) {
|
|
20
|
+
const path = handoffPath(id);
|
|
21
|
+
if (!existsSync(path))
|
|
22
|
+
return { version: 1, issues: [] };
|
|
23
|
+
return HandoffLedger.parse(JSON.parse(readFileSync(path, "utf8")));
|
|
24
|
+
}
|
|
25
|
+
export function openActions(ledger, target) {
|
|
26
|
+
return ledger.issues.filter((item) => item.kind === "action"
|
|
27
|
+
&& (item.status === "open" || item.status === "proposed_resolved")
|
|
28
|
+
&& (!target || item.targetStage === target));
|
|
29
|
+
}
|
|
30
|
+
export function reviewHandoffGate(ledger, target, verdict) {
|
|
31
|
+
if (verdict !== "approve")
|
|
32
|
+
return undefined;
|
|
33
|
+
const pending = openActions(ledger, target);
|
|
34
|
+
return pending.length ? `審查核准與未結交接事項矛盾:${pending.map((item) => item.id).join("、")}` : undefined;
|
|
35
|
+
}
|
|
36
|
+
export function previewHandoff(ledger, callKey, source, response, role) {
|
|
37
|
+
source = HandoffSource.parse(source);
|
|
38
|
+
response = HandoffResponse.parse(response);
|
|
39
|
+
if (source.callKey !== callKey)
|
|
40
|
+
throw new Error("交接呼叫識別碼不一致");
|
|
41
|
+
if (ledger.appliedCalls?.includes(callKey))
|
|
42
|
+
return ledger;
|
|
43
|
+
const now = new Date().toISOString();
|
|
44
|
+
const issues = ledger.issues.map((item) => ({ ...item }));
|
|
45
|
+
for (const [index, item] of response.newIssues.entries()) {
|
|
46
|
+
const id = createHash("sha256").update(`${callKey}:${index}`).digest("hex").slice(0, 16);
|
|
47
|
+
if (issues.some((existing) => existing.id === id))
|
|
48
|
+
throw new Error(`交接事項 id 重複:${id}`);
|
|
49
|
+
issues.push({ id, source, ...item, status: "open", updatedAt: now });
|
|
50
|
+
}
|
|
51
|
+
for (const disposition of response.dispositions) {
|
|
52
|
+
const issue = issues.find((item) => item.id === disposition.id);
|
|
53
|
+
if (!issue)
|
|
54
|
+
throw new Error(`找不到交接事項:${disposition.id}`);
|
|
55
|
+
if (issue.kind !== "action")
|
|
56
|
+
throw new Error(`參考資訊不可結案:${disposition.id}`);
|
|
57
|
+
if (issue.status === "resolved" || issue.status === "accepted")
|
|
58
|
+
throw new Error(`交接事項已結案:${disposition.id}`);
|
|
59
|
+
if (role === "writer" && disposition.status !== "proposed_resolved")
|
|
60
|
+
throw new Error("作者只能提出已修正,不能自行結案");
|
|
61
|
+
if (role === "reviewer" && disposition.status === "proposed_resolved")
|
|
62
|
+
throw new Error("審查者須明確結案或接受風險");
|
|
63
|
+
issue.status = disposition.status;
|
|
64
|
+
issue.resolution = { agent: source.agent, reason: disposition.reason, evidence: disposition.evidence };
|
|
65
|
+
issue.updatedAt = now;
|
|
66
|
+
}
|
|
67
|
+
return HandoffLedger.parse({ version: 1, issues, appliedCalls: [...(ledger.appliedCalls ?? []), callKey] });
|
|
68
|
+
}
|
|
69
|
+
export function mergeHandoff(id, callKey, source, response, role) {
|
|
70
|
+
const next = previewHandoff(readHandoff(id), callKey, source, response, role);
|
|
71
|
+
writeAtomic(handoffPath(id), JSON.stringify(next, null, 2));
|
|
72
|
+
return next;
|
|
73
|
+
}
|
|
74
|
+
/** 只把目前步驟需要處理的事項投影給 agent。 */
|
|
75
|
+
export function prepareHandoff(id, _callKey, target, blind) {
|
|
76
|
+
const items = readHandoff(id).issues.filter((item) => item.targetStage === target && (item.kind === "info" || item.status === "open" || item.status === "proposed_resolved"));
|
|
77
|
+
const lines = items.map((item) => {
|
|
78
|
+
const source = blind ? "" : `\n來源:${item.source.stage}/${item.source.agent}`;
|
|
79
|
+
const resolution = item.resolution ? `\n處理理由:${item.resolution.reason}\n處理證據:${item.resolution.evidence}` : "";
|
|
80
|
+
return `## ${item.id}:${item.summary}\n證據:${item.evidence}\n狀態:${item.status}${resolution}${source}`;
|
|
81
|
+
});
|
|
82
|
+
mkdirSync(flowDir(id), { recursive: true });
|
|
83
|
+
writeFileSync(join(flowDir(id), "handoff-context.md"), `# 待處理交接事項\n\n${lines.length ? lines.join("\n\n") : "目前沒有待處理事項。"}\n`);
|
|
84
|
+
rmSync(responsePath(id), { force: true });
|
|
85
|
+
}
|
|
86
|
+
export function validateHandoffResponse(id) {
|
|
87
|
+
return readJsonFile(responsePath(id), HandoffResponse);
|
|
88
|
+
}
|
|
89
|
+
/** 已通過原有關卡的回覆先記收據,再合併;中斷後可重播。 */
|
|
90
|
+
export function acceptHandoff(id, callKey, source, response, role) {
|
|
91
|
+
const checked = Receipt.parse({ callKey, source, response, role });
|
|
92
|
+
previewHandoff(readHandoff(id), callKey, source, response, role);
|
|
93
|
+
const path = receiptPath(id, callKey);
|
|
94
|
+
writeAtomic(path, JSON.stringify(checked));
|
|
95
|
+
mergeHandoff(id, callKey, source, response, role);
|
|
96
|
+
rmSync(path, { force: true });
|
|
97
|
+
}
|
|
98
|
+
export function recoverHandoff(id) {
|
|
99
|
+
const dir = receiptsDir(id);
|
|
100
|
+
if (!existsSync(dir))
|
|
101
|
+
return;
|
|
102
|
+
for (const name of readdirSync(dir).filter((f) => f.endsWith(".json"))) {
|
|
103
|
+
const path = join(dir, name);
|
|
104
|
+
const receipt = Receipt.parse(JSON.parse(readFileSync(path, "utf8")));
|
|
105
|
+
mergeHandoff(id, receipt.callKey, receipt.source, receipt.response, receipt.role);
|
|
106
|
+
rmSync(path);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=handoff.js.map
|
package/dist/paths.js
CHANGED
|
@@ -17,6 +17,7 @@ export const agentflowctlDir = () => join(projectRoot(), ".agentflowctl");
|
|
|
17
17
|
export const runsDir = () => join(agentflowctlDir(), "runs");
|
|
18
18
|
export const runDir = (id) => join(runsDir(), id);
|
|
19
19
|
export const logDir = (id) => join(runDir(id), "logs");
|
|
20
|
+
export const handoffPath = (id) => join(runDir(id), "handoff.json");
|
|
20
21
|
/** 每個 run 一個 git worktree,Agent 只在這裡工作,不碰你正在編輯的檔案 */
|
|
21
22
|
export const worktreesDir = () => join(agentflowctlDir(), "worktrees");
|
|
22
23
|
export const worktreeDir = (id) => join(worktreesDir(), id);
|