agentflowctl 0.3.0 → 0.5.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 +166 -42
- package/dist/agentConfig.js +8 -25
- package/dist/agents/index.js +0 -4
- package/dist/arbitration.js +10 -0
- package/dist/cleanup.js +54 -0
- package/dist/cli.js +130 -60
- package/dist/config.js +2 -2
- package/dist/detect.js +82 -0
- package/dist/engine.js +62 -28
- package/dist/logs.js +173 -0
- package/dist/paths.js +4 -2
- package/dist/proc.js +1 -4
- package/dist/roles.js +70 -39
- package/dist/runner.js +25 -29
- package/dist/schemas.js +10 -6
- package/dist/setup.js +70 -0
- package/examples/flow.config.json +1 -0
- package/package.json +1 -1
package/dist/engine.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
3
4
|
import { config } from "./config.js";
|
|
5
|
+
import { arbitrationDecision } from "./arbitration.js";
|
|
6
|
+
import { detectProjectDefaults, withProjectDefaults } from "./detect.js";
|
|
4
7
|
import { changedFiles, commitAll, discardChanges, git, headCommit, resetTo } from "./git.js";
|
|
5
8
|
import { flowDir, logDir, projectRoot, runDir, worktreeDir } from "./paths.js";
|
|
9
|
+
import { CMD_AGENT, nextLogFile } from "./logs.js";
|
|
6
10
|
import { exec } from "./proc.js";
|
|
7
11
|
import { arbiterPanel, availableAgent, fixAgent, planAgent, planFixAgent, reviewers, specAgent, taskAgents } from "./roles.js";
|
|
8
12
|
import { resolveAgent, runAgent, runCommand } from "./runner.js";
|
|
@@ -12,11 +16,11 @@ import { orderTasks } from "./tasks.js";
|
|
|
12
16
|
import { readJsonFile, renderPrompt, tail } from "./util.js";
|
|
13
17
|
// ───────────────────────── 共用工具 ─────────────────────────
|
|
14
18
|
const info = (run, msg) => console.log(`[${run.id}] ${msg}`);
|
|
19
|
+
const logHint = (run, seq) => `(agentflowctl logs ${run.id} ${seq})`;
|
|
15
20
|
const flowFile = (run, name) => join(flowDir(run.id), name);
|
|
16
21
|
const to = (run, stage) => ({ ...run, stage });
|
|
17
|
-
function target(run,
|
|
18
|
-
|
|
19
|
-
return { runId: run.id, cwd: worktreeDir(run.id), logFile: join(logDir(run.id), `${stamp}-${name}.log`) };
|
|
22
|
+
function target(run, step, agent) {
|
|
23
|
+
return { runId: run.id, cwd: worktreeDir(run.id), logFile: nextLogFile(logDir(run.id), run.stage, step, agent), stage: run.stage, step };
|
|
20
24
|
}
|
|
21
25
|
/** 額度用完而必須停下:審查類步驟,或所有 agent 的額度都用完 */
|
|
22
26
|
export class QuotaPause extends Error {
|
|
@@ -35,7 +39,7 @@ async function agentStep(run, planned, step, prompt, mode) {
|
|
|
35
39
|
if (mode.kind === "review") {
|
|
36
40
|
throw new QuotaPause(`${agent} 的額度已用完;${step} 是審查步驟,不由另一家代打`);
|
|
37
41
|
}
|
|
38
|
-
const sub = availableAgent(run.cycle, agent, [...exhausted]);
|
|
42
|
+
const sub = availableAgent(run.cycle, agent, [...exhausted], `${run.id}:${step}:sub`);
|
|
39
43
|
if (!sub)
|
|
40
44
|
throw new QuotaPause(`所有 agent 的額度都已用完(${[...exhausted].join("、")})`);
|
|
41
45
|
const note = step.endsWith("-code") && sub === run.lastTestsAuthor ? "測試與實作由同一家負責" : undefined;
|
|
@@ -43,7 +47,7 @@ async function agentStep(run, planned, step, prompt, mode) {
|
|
|
43
47
|
info(run, `🔁 ${agent} 額度已用完,${step} 由 ${sub} 代打${note ? `(注意:${note})` : ""}`);
|
|
44
48
|
agent = sub;
|
|
45
49
|
}
|
|
46
|
-
const r = await runAgent(agent, resolveAgent(cfg, agent), target(run,
|
|
50
|
+
const r = await runAgent(agent, resolveAgent(cfg, agent), target(run, step, agent), prompt);
|
|
47
51
|
addUsage(run.id, { stage: step, agent, inputTokens: r.inputTokens, outputTokens: r.outputTokens });
|
|
48
52
|
if (!r.quotaExhausted) {
|
|
49
53
|
reportMeta(run, agent, r);
|
|
@@ -72,6 +76,7 @@ function readFeedback(run) {
|
|
|
72
76
|
function retry(run, key, reason, backTo) {
|
|
73
77
|
const n = (run.attempts[key] ?? 0) + 1;
|
|
74
78
|
const attempts = { ...run.attempts, [key]: n };
|
|
79
|
+
mkdirSync(flowDir(run.id), { recursive: true });
|
|
75
80
|
writeFileSync(flowFile(run, "feedback.md"), `# 前次嘗試未通過(第 ${n} 次)\n\n${reason}\n`);
|
|
76
81
|
if (n >= config.maxAttempts) {
|
|
77
82
|
return { ...run, attempts, stage: "failed", failedStage: backTo, failureReason: `${key} 連續失敗 ${n} 次:${tail(reason, 500)}` };
|
|
@@ -86,11 +91,14 @@ function succeed(run, key, next) {
|
|
|
86
91
|
return { ...run, attempts, stage: next };
|
|
87
92
|
}
|
|
88
93
|
/** 設定檔放在主專案根目錄,未 commit 的修改也會生效 */
|
|
94
|
+
/** 讀取 flow.config.json;沒寫的 install、test、checks 依專案現況偵測 */
|
|
89
95
|
export function loadRepoConfig() {
|
|
90
|
-
const
|
|
96
|
+
const root = projectRoot();
|
|
97
|
+
const detected = detectProjectDefaults(root);
|
|
98
|
+
const p = join(root, "flow.config.json");
|
|
91
99
|
if (!existsSync(p))
|
|
92
|
-
return RepoConfig.parse({});
|
|
93
|
-
const r = readJsonFile(p, RepoConfig);
|
|
100
|
+
return RepoConfig.parse(withProjectDefaults({}, detected));
|
|
101
|
+
const r = readJsonFile(p, z.preprocess((raw) => withProjectDefaults(raw, detected), RepoConfig));
|
|
94
102
|
if (!r.ok)
|
|
95
103
|
throw new Error(r.error);
|
|
96
104
|
return r.data;
|
|
@@ -103,7 +111,7 @@ function loadOrderedTasks(run) {
|
|
|
103
111
|
}
|
|
104
112
|
// ───────────────────────── 各階段 ─────────────────────────
|
|
105
113
|
async function specStage(run) {
|
|
106
|
-
const agent = specAgent(run.cycle);
|
|
114
|
+
const agent = specAgent(run.cycle, run.id);
|
|
107
115
|
info(run, `📝 產生規格(${agent})`);
|
|
108
116
|
const { r } = await agentStep(run, agent, "spec", renderPrompt("spec", { requirement: run.requirement }), { kind: "write" });
|
|
109
117
|
await discardChanges(worktreeDir(run.id)); // 這個階段只允許寫 .flow/
|
|
@@ -163,7 +171,7 @@ function announceTasks(run, ordered) {
|
|
|
163
171
|
const cfg = loadRepoConfig();
|
|
164
172
|
info(run, `📋 共 ${ordered.length} 個任務:${ordered.map((t) => t.id).join(" → ")}`);
|
|
165
173
|
ordered.forEach((t, i) => {
|
|
166
|
-
const a = taskAgents(run.cycle, i, cfg.tddSplit);
|
|
174
|
+
const a = taskAgents(run.cycle, i, cfg.tddSplit, run.id);
|
|
167
175
|
info(run, ` ${t.id} 測試:${a.tests} 實作:${a.code}`);
|
|
168
176
|
});
|
|
169
177
|
}
|
|
@@ -175,10 +183,13 @@ function planSettled(run, key) {
|
|
|
175
183
|
if (!run.autopilot) {
|
|
176
184
|
info(run, `✋ 計畫已通過審查,請檢視 ${flowFile(run, "plan.md")},確認後執行 agentflowctl approve ${run.id}`);
|
|
177
185
|
}
|
|
178
|
-
|
|
186
|
+
const settled = succeed(run, key, next);
|
|
187
|
+
const attempts = { ...settled.attempts };
|
|
188
|
+
delete attempts["plan-arbitration"];
|
|
189
|
+
return { ...settled, attempts, taskIndex: 0, taskPhase: "tests" };
|
|
179
190
|
}
|
|
180
191
|
async function planStage(run) {
|
|
181
|
-
const agent = planAgent(run.cycle);
|
|
192
|
+
const agent = planAgent(run.cycle, run.id);
|
|
182
193
|
info(run, `🗺️ 拆解任務(${agent})`);
|
|
183
194
|
const cfg = loadRepoConfig();
|
|
184
195
|
const { r, agent: actual } = await agentStep(run, agent, "plan", renderPrompt("plan", { testPattern: cfg.testPattern }), { kind: "write" });
|
|
@@ -194,9 +205,9 @@ async function planStage(run) {
|
|
|
194
205
|
}
|
|
195
206
|
async function planReviewStage(run) {
|
|
196
207
|
const cfg = loadRepoConfig();
|
|
197
|
-
const author = run.planWriter ?? planAgent(run.cycle);
|
|
198
|
-
const panel = reviewers(run.cycle, author, cfg.planReviewQuorum);
|
|
208
|
+
const author = run.planWriter ?? planAgent(run.cycle, run.id);
|
|
199
209
|
const round = (run.attempts["plan-review"] ?? 0) + 1;
|
|
210
|
+
const panel = reviewers(run.cycle, author, cfg.planReviewQuorum, `${run.id}:plan-review:${round}`);
|
|
200
211
|
const issues = [];
|
|
201
212
|
const issueLines = []; // 只含意見內容、不含審查者名稱,用來偵測僵持
|
|
202
213
|
let firstObjector;
|
|
@@ -252,7 +263,12 @@ async function planReviewStage(run) {
|
|
|
252
263
|
}
|
|
253
264
|
async function planFixStage(run) {
|
|
254
265
|
const cfg = loadRepoConfig();
|
|
255
|
-
const agent = planFixAgent(run.cycle, {
|
|
266
|
+
const agent = planFixAgent(run.cycle, {
|
|
267
|
+
strategy: cfg.fixStrategy,
|
|
268
|
+
planWriter: run.planWriter,
|
|
269
|
+
planReviewer: run.planReviewer,
|
|
270
|
+
seed: `${run.id}:plan-fix:${run.attempts["plan-review"] ?? 0}`,
|
|
271
|
+
});
|
|
256
272
|
info(run, `✏️ 依 ${run.planReviewer ?? "審查者"} 的意見修改計畫(${agent})`);
|
|
257
273
|
const feedback = readFeedback(run);
|
|
258
274
|
const snap = snapshotPlan(run);
|
|
@@ -279,7 +295,8 @@ async function planFixStage(run) {
|
|
|
279
295
|
*/
|
|
280
296
|
async function arbitratePlan(run) {
|
|
281
297
|
const cfg = loadRepoConfig();
|
|
282
|
-
const
|
|
298
|
+
const arbitrationRound = (run.attempts["plan-arbitration"] ?? 0) + 1;
|
|
299
|
+
const panel = arbiterPanel(run.cycle, `${run.id}:arbiter`, run.planWriter, run.planReviewer);
|
|
283
300
|
const mode = panel.length > 1 ? "雙盲交叉仲裁" : "第三方仲裁";
|
|
284
301
|
const verdicts = [];
|
|
285
302
|
for (const arbiter of panel) {
|
|
@@ -295,7 +312,7 @@ async function arbitratePlan(run) {
|
|
|
295
312
|
const result = r.ok ? readJsonFile(flowFile(run, "plan-arbiter.json"), ReviewResult) : undefined;
|
|
296
313
|
if (result?.ok) {
|
|
297
314
|
mkdirSync(join(runDir(run.id), "reviews"), { recursive: true });
|
|
298
|
-
renameSync(flowFile(run, "plan-arbiter.json"), join(runDir(run.id), "reviews", `plan-arbiter-${arbiter}.json`));
|
|
315
|
+
renameSync(flowFile(run, "plan-arbiter.json"), join(runDir(run.id), "reviews", `plan-arbiter-${arbitrationRound}-${arbiter}.json`));
|
|
299
316
|
}
|
|
300
317
|
const verdict = result?.ok ? result.data.verdict : "abstain";
|
|
301
318
|
const notes = result?.ok ? result.data.items.map((i) => `- ${i.criterion}:${i.note}`) : [`- 未產生有效裁決(${r.ok ? "輸出格式錯誤" : r.summary})`];
|
|
@@ -305,13 +322,21 @@ async function arbitratePlan(run) {
|
|
|
305
322
|
rmSync(flowFile(run, "dispute.md"), { force: true });
|
|
306
323
|
const approvals = verdicts.filter((v) => v.verdict === "approve").length;
|
|
307
324
|
const unanimous = approvals === verdicts.length;
|
|
308
|
-
const decision =
|
|
325
|
+
const decision = arbitrationDecision(verdicts.map((v) => v.verdict), cfg.tieBreak, arbitrationRound, config.maxAttempts);
|
|
309
326
|
const summary = unanimous
|
|
310
327
|
? `${mode}一致核准`
|
|
311
328
|
: approvals === 0
|
|
312
|
-
? `${mode}
|
|
329
|
+
? `${mode}沒有任何一方核准${decision === "revise" ? ",交回計畫修訂" : ""}`
|
|
313
330
|
: `${mode}意見分歧,依 tieBreak=${cfg.tieBreak} ${cfg.tieBreak === "proceed" ? "繼續實作" : "停止"}`;
|
|
314
331
|
const record = verdicts.map((v) => `### ${v.arbiter}(${v.verdict})\n\n${v.notes.join("\n")}`).join("\n\n");
|
|
332
|
+
if (decision === "revise") {
|
|
333
|
+
const attempts = { ...run.attempts, "plan-arbitration": arbitrationRound };
|
|
334
|
+
delete attempts["plan-review"];
|
|
335
|
+
rmSync(flowFile(run, "plan-review-last.txt"), { force: true });
|
|
336
|
+
writeFileSync(flowFile(run, "feedback.md"), `# 仲裁要求修訂(第 ${arbitrationRound} 次)\n\n${summary}\n\n${record}\n`);
|
|
337
|
+
info(run, ` → ${summary}`);
|
|
338
|
+
return { ...run, attempts, stage: "plan_fix" };
|
|
339
|
+
}
|
|
315
340
|
writeFileSync(flowFile(run, "plan.md"), `${readFileSync(flowFile(run, "plan.md"), "utf8")}\n\n## 仲裁紀錄\n\n**結果:${summary}**\n\n${record}\n`);
|
|
316
341
|
if (decision === "proceed") {
|
|
317
342
|
info(run, ` → ${summary}`);
|
|
@@ -332,7 +357,7 @@ async function implementStage(run) {
|
|
|
332
357
|
const testCmd = `${cfg.install} && ${cfg.test}`;
|
|
333
358
|
const progress = `${run.taskIndex + 1}/${tasks.length} ${task.id} ${task.title}`;
|
|
334
359
|
const taskJson = JSON.stringify(task, null, 2);
|
|
335
|
-
const agents = taskAgents(run.cycle, run.taskIndex, cfg.tddSplit);
|
|
360
|
+
const agents = taskAgents(run.cycle, run.taskIndex, cfg.tddSplit, run.id);
|
|
336
361
|
// ── 紅燈:只寫測試,而且測試必須失敗 ──
|
|
337
362
|
if (run.taskPhase === "tests") {
|
|
338
363
|
const key = `${task.id}:tests`;
|
|
@@ -351,7 +376,7 @@ async function implementStage(run) {
|
|
|
351
376
|
await resetTo(repo, before);
|
|
352
377
|
return retry(run, key, `沒有新增或修改任何符合 /${cfg.testPattern}/ 的測試檔。`, "implement");
|
|
353
378
|
}
|
|
354
|
-
const red = await runCommand(target(run, `${task.id}-red
|
|
379
|
+
const red = await runCommand(target(run, `${task.id}-red`, CMD_AGENT), testCmd);
|
|
355
380
|
if (red.ok) {
|
|
356
381
|
await resetTo(repo, before);
|
|
357
382
|
return retry(run, key, "測試在功能尚未實作前就全部通過,代表測試沒有驗證到新行為。請撰寫會因功能尚未實作而失敗的測試。", "implement");
|
|
@@ -376,9 +401,11 @@ async function implementStage(run) {
|
|
|
376
401
|
await resetTo(repo, testsCommit);
|
|
377
402
|
return retry(run, key, `實作階段不可修改測試檔,已還原你的變更:${touched.join(", ")}`, "implement");
|
|
378
403
|
}
|
|
379
|
-
const green = await runCommand(target(run, `${task.id}-green
|
|
380
|
-
if (!green.ok)
|
|
404
|
+
const green = await runCommand(target(run, `${task.id}-green`, CMD_AGENT), testCmd);
|
|
405
|
+
if (!green.ok) {
|
|
406
|
+
info(run, ` ✗ 測試仍未通過${logHint(run, green.seq)}`);
|
|
381
407
|
return retry(run, key, `測試仍未通過:\n\n\`\`\`\n${tail(green.output)}\n\`\`\``, "implement");
|
|
408
|
+
}
|
|
382
409
|
info(run, `🟢 [${progress}] 完成`);
|
|
383
410
|
return {
|
|
384
411
|
...succeed(run, key, "implement"),
|
|
@@ -393,14 +420,15 @@ async function verifyStage(run) {
|
|
|
393
420
|
info(run, "🔍 執行驗證");
|
|
394
421
|
const cfg = loadRepoConfig();
|
|
395
422
|
const results = [];
|
|
396
|
-
const install = await runCommand(target(run, "
|
|
423
|
+
const install = await runCommand(target(run, "install", CMD_AGENT), cfg.install);
|
|
397
424
|
if (!install.ok) {
|
|
425
|
+
info(run, ` ✗ install${logHint(run, install.seq)}`);
|
|
398
426
|
results.push({ name: "install", ok: false, output: tail(install.output) });
|
|
399
427
|
}
|
|
400
428
|
else {
|
|
401
429
|
for (const check of cfg.checks) {
|
|
402
|
-
const r = await runCommand(target(run,
|
|
403
|
-
info(run, ` ${r.ok ? "✓" : "✗"} ${check.name}`);
|
|
430
|
+
const r = await runCommand(target(run, check.name, CMD_AGENT), check.cmd);
|
|
431
|
+
info(run, ` ${r.ok ? "✓" : "✗"} ${check.name}${r.ok ? "" : logHint(run, r.seq)}`);
|
|
404
432
|
results.push({ name: check.name, ok: r.ok, output: tail(r.output, 3000) });
|
|
405
433
|
}
|
|
406
434
|
}
|
|
@@ -414,7 +442,13 @@ async function verifyStage(run) {
|
|
|
414
442
|
async function fixStage(run) {
|
|
415
443
|
const cfg = loadRepoConfig();
|
|
416
444
|
const source = run.fixSource ?? "verify";
|
|
417
|
-
const agent = fixAgent(run.cycle, {
|
|
445
|
+
const agent = fixAgent(run.cycle, {
|
|
446
|
+
source,
|
|
447
|
+
strategy: cfg.fixStrategy,
|
|
448
|
+
lastWriter: run.lastWriter,
|
|
449
|
+
lastReviewer: run.lastReviewer,
|
|
450
|
+
seed: `${run.id}:fix:${run.attempts.review ?? 0}`,
|
|
451
|
+
});
|
|
418
452
|
const why = source === "review" ? `依 ${run.lastReviewer ?? "reviewer"} 的審查意見` : "修正驗證錯誤";
|
|
419
453
|
info(run, `🩹 ${why}(${agent})`);
|
|
420
454
|
const repo = worktreeDir(run.id);
|
|
@@ -439,7 +473,7 @@ async function fixStage(run) {
|
|
|
439
473
|
async function reviewStage(run) {
|
|
440
474
|
const cfg = loadRepoConfig();
|
|
441
475
|
const repo = worktreeDir(run.id);
|
|
442
|
-
const panel = reviewers(run.cycle, run.lastWriter, cfg.reviewQuorum);
|
|
476
|
+
const panel = reviewers(run.cycle, run.lastWriter, cfg.reviewQuorum, `${run.id}:review:${run.attempts.review ?? 0}`);
|
|
443
477
|
writeFileSync(flowFile(run, "diff.patch"), await git(repo, "diff", `${run.baseBranch}...HEAD`));
|
|
444
478
|
const authors = [...new Set((await git(repo, "log", "--format=%s", `${run.baseBranch}..HEAD`)).match(/\[[^\]]+\]$/gm) ?? [])]
|
|
445
479
|
.map((s) => s.slice(1, -1));
|
package/dist/logs.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { ADAPTERS } from "./agents/index.js";
|
|
4
|
+
import { str, tryJson } from "./agents/types.js";
|
|
5
|
+
/**
|
|
6
|
+
* 每次執行 agent 或專案指令都寫成一份 log:
|
|
7
|
+
* - 檔名:<三位數序號>-<階段>-<步驟>-<agent>.log,專案指令的 agent 欄位是 cmd
|
|
8
|
+
* - 第一行:`# agentflowctl {...}`,記錄階段、步驟、agent、開始時間
|
|
9
|
+
* - 中間:agent 的原始 stdout(JSON 行)或指令輸出,原樣保存;stderr 接在 `[stderr]` 之後
|
|
10
|
+
* - 最後一行:`# exit {...}`,記錄結束碼與是否成功;沒有這行代表還在執行或被中斷
|
|
11
|
+
*/
|
|
12
|
+
export const HEADER_PREFIX = "# agentflowctl ";
|
|
13
|
+
export const FOOTER_PREFIX = "# exit ";
|
|
14
|
+
export const STDERR_MARK = "[stderr]";
|
|
15
|
+
/** 專案指令(install、測試、checks)的 agent 欄位 */
|
|
16
|
+
export const CMD_AGENT = "cmd";
|
|
17
|
+
const segment = (s) => s.replace(/[\s/\\:*?"<>|]+/g, "_");
|
|
18
|
+
/** 下一份 log 的完整路徑;序號接在目錄裡最大的序號之後 */
|
|
19
|
+
export function nextLogFile(dir, stage, step, agent) {
|
|
20
|
+
const seqs = existsSync(dir) ? readdirSync(dir).map((f) => Number(f.match(/^(\d+)-/)?.[1] ?? 0)) : [];
|
|
21
|
+
const seq = Math.max(0, ...seqs) + 1;
|
|
22
|
+
return join(dir, `${String(seq).padStart(3, "0")}-${segment(stage)}-${segment(step)}-${segment(agent)}.log`);
|
|
23
|
+
}
|
|
24
|
+
export const logSeq = (file) => Number(file.split(/[\\/]/).at(-1)?.match(/^(\d+)-/)?.[1] ?? 0);
|
|
25
|
+
export const headerLine = (h) => `${HEADER_PREFIX}${JSON.stringify(h)}`;
|
|
26
|
+
export const footerLine = (f) => `${FOOTER_PREFIX}${JSON.stringify(f)}`;
|
|
27
|
+
function parseLog(text) {
|
|
28
|
+
const out = { body: [], stderr: [] };
|
|
29
|
+
let inStderr = false;
|
|
30
|
+
for (const line of text.split("\n")) {
|
|
31
|
+
if (line.startsWith(HEADER_PREFIX) && !out.header)
|
|
32
|
+
out.header = tryJson(line.slice(HEADER_PREFIX.length));
|
|
33
|
+
else if (line.startsWith(FOOTER_PREFIX))
|
|
34
|
+
out.footer = tryJson(line.slice(FOOTER_PREFIX.length));
|
|
35
|
+
else if (line === STDERR_MARK)
|
|
36
|
+
inStderr = true;
|
|
37
|
+
else
|
|
38
|
+
(inStderr ? out.stderr : out.body).push(line);
|
|
39
|
+
}
|
|
40
|
+
while (out.stderr.at(-1) === "")
|
|
41
|
+
out.stderr.pop();
|
|
42
|
+
while (out.body.at(-1) === "")
|
|
43
|
+
out.body.pop();
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
/** 依序號列出 run 的所有 log,只讀檔頭與檔尾 */
|
|
47
|
+
export function listLogs(dir) {
|
|
48
|
+
if (!existsSync(dir))
|
|
49
|
+
return [];
|
|
50
|
+
return readdirSync(dir)
|
|
51
|
+
.filter((f) => /^\d+-.*\.log$/.test(f))
|
|
52
|
+
.map((f) => {
|
|
53
|
+
const file = join(dir, f);
|
|
54
|
+
const { header, footer } = parseLog(readFileSync(file, "utf8"));
|
|
55
|
+
return { seq: logSeq(f), file, header, footer };
|
|
56
|
+
})
|
|
57
|
+
.sort((a, b) => a.seq - b.seq);
|
|
58
|
+
}
|
|
59
|
+
/** 執行結果:✓ 成功、✗ 失敗、… 沒有檔尾(還在執行或被中斷) */
|
|
60
|
+
export const logMark = (e) => (!e.footer ? "…" : e.footer.ok ? "✓" : "✗");
|
|
61
|
+
/** ISO 時間轉成本地時間 YYYY-MM-DD HH:mm:ss */
|
|
62
|
+
export function localTime(iso) {
|
|
63
|
+
const d = iso ? new Date(iso) : undefined;
|
|
64
|
+
if (!d || Number.isNaN(d.getTime()))
|
|
65
|
+
return "?";
|
|
66
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
67
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
68
|
+
}
|
|
69
|
+
const clip = (s, max = 800) => (s.length <= max ? s : `${s.slice(0, max)}…(共 ${s.length} 字)`);
|
|
70
|
+
const indent = (s, pad = " ") => s.split("\n").join(`\n${pad}`);
|
|
71
|
+
/** 從沒被 adapter 認得的 JSON 事件裡找錯誤:頂層的 error 類事件算失敗,工具回報的錯誤只當作提示 */
|
|
72
|
+
function findError(ev) {
|
|
73
|
+
const pick = (o) => {
|
|
74
|
+
const err = o.error;
|
|
75
|
+
const nested = err && typeof err === "object" ? str(err.message) : undefined;
|
|
76
|
+
const content = Array.isArray(o.content)
|
|
77
|
+
? o.content.map((c) => (c && typeof c === "object" ? str(c.text) : str(c))).filter(Boolean).join("\n")
|
|
78
|
+
: str(o.content);
|
|
79
|
+
return nested ?? str(err) ?? str(o.message) ?? (content || undefined) ?? str(o.result) ?? JSON.stringify(o);
|
|
80
|
+
};
|
|
81
|
+
if (ev.is_error === true || ev.error || /error|fail/i.test(str(ev.type) ?? "") || /error|fail/i.test(str(ev.subtype) ?? "")) {
|
|
82
|
+
return { message: clip(pick(ev)), fatal: true };
|
|
83
|
+
}
|
|
84
|
+
const walk = (v, depth) => {
|
|
85
|
+
if (!v || typeof v !== "object" || depth > 4)
|
|
86
|
+
return undefined;
|
|
87
|
+
if (!Array.isArray(v) && v.is_error === true)
|
|
88
|
+
return v;
|
|
89
|
+
for (const child of Object.values(v)) {
|
|
90
|
+
const hit = walk(child, depth + 1);
|
|
91
|
+
if (hit)
|
|
92
|
+
return hit;
|
|
93
|
+
}
|
|
94
|
+
return undefined;
|
|
95
|
+
};
|
|
96
|
+
const hit = walk(ev, 0);
|
|
97
|
+
return hit ? { message: clip(pick(hit)), fatal: false } : undefined;
|
|
98
|
+
}
|
|
99
|
+
function renderEvent(ev) {
|
|
100
|
+
switch (ev.kind) {
|
|
101
|
+
case "text":
|
|
102
|
+
return `💬 ${indent(ev.text.trim())}`;
|
|
103
|
+
case "tool":
|
|
104
|
+
return `🔧 ${ev.name}${ev.detail ? `: ${indent(ev.detail.trim())}` : ""}`;
|
|
105
|
+
case "usage":
|
|
106
|
+
return `📊 用量 input ${ev.inputTokens ?? "?"} / output ${ev.outputTokens ?? "?"} tokens`;
|
|
107
|
+
case "done":
|
|
108
|
+
return `🏁 ${ev.ok ? "完成" : "失敗"}${ev.summary ? `:${indent(ev.summary.trim())}` : ""}`;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** 把一份 log 轉成人看得懂的版本;最後附上錯誤整理,讓失敗原因一眼可見 */
|
|
112
|
+
export function renderLog(text, file = "") {
|
|
113
|
+
const { header, footer, body, stderr } = parseLog(text);
|
|
114
|
+
const out = [];
|
|
115
|
+
const errors = [];
|
|
116
|
+
const title = header
|
|
117
|
+
? `${header.stage} / ${header.step} / ${header.agent}${header.adapter && header.adapter !== header.agent ? `(adapter ${header.adapter})` : ""}`
|
|
118
|
+
: "(沒有檔頭)";
|
|
119
|
+
out.push(`${logSeq(file) ? `#${logSeq(file)} ` : ""}${title}`);
|
|
120
|
+
out.push(`開始 ${localTime(header?.startedAt)} ` +
|
|
121
|
+
(footer ? `結束 ${localTime(footer.endedAt)} 結束碼 ${footer.code} ${footer.ok ? "✓ 成功" : "✗ 失敗"}` : "… 沒有結束紀錄(還在執行或被中斷)"));
|
|
122
|
+
if (file)
|
|
123
|
+
out.push(`檔案 ${file}`);
|
|
124
|
+
out.push("");
|
|
125
|
+
const adapter = header?.adapter ? ADAPTERS[header.adapter] : undefined;
|
|
126
|
+
if (!adapter || header?.agent === CMD_AGENT) {
|
|
127
|
+
out.push(...body);
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
let skipped = 0;
|
|
131
|
+
for (const line of body) {
|
|
132
|
+
if (!line.trim())
|
|
133
|
+
continue;
|
|
134
|
+
const events = adapter.parse(line);
|
|
135
|
+
if (events.length) {
|
|
136
|
+
for (const ev of events) {
|
|
137
|
+
const s = renderEvent(ev);
|
|
138
|
+
if (s)
|
|
139
|
+
out.push(s);
|
|
140
|
+
if (ev.kind === "done" && !ev.ok)
|
|
141
|
+
errors.push(`agent 回報失敗${ev.summary ? `:${indent(ev.summary.trim())}` : ""}`);
|
|
142
|
+
}
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const json = tryJson(line);
|
|
146
|
+
if (!json) {
|
|
147
|
+
out.push(`📄 ${line}`);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const err = findError(json);
|
|
151
|
+
if (!err) {
|
|
152
|
+
skipped++;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
out.push(`${err.fatal ? "❌" : "⚠️ "} ${indent(err.message)}`);
|
|
156
|
+
if (err.fatal)
|
|
157
|
+
errors.push(`錯誤事件:${indent(err.message)}`);
|
|
158
|
+
}
|
|
159
|
+
if (skipped)
|
|
160
|
+
out.push(`(另有 ${skipped} 行其他事件未顯示,可用 --raw 查看)`);
|
|
161
|
+
}
|
|
162
|
+
if (footer && footer.code !== 0)
|
|
163
|
+
errors.unshift(`結束碼 ${footer.code}`);
|
|
164
|
+
if (footer && !footer.ok && !errors.length)
|
|
165
|
+
errors.push("執行未通過(沒有更多錯誤訊息)");
|
|
166
|
+
const stderrText = stderr.length ? `stderr:\n ${indent(clip(stderr.join("\n"), 4000))}` : undefined;
|
|
167
|
+
if (errors.length)
|
|
168
|
+
out.push("", "── 錯誤 ──", ...errors, ...(stderrText ? [stderrText] : []));
|
|
169
|
+
else if (stderrText)
|
|
170
|
+
out.push("", "── 其他輸出 ──", stderrText);
|
|
171
|
+
return out.join("\n");
|
|
172
|
+
}
|
|
173
|
+
//# sourceMappingURL=logs.js.map
|
package/dist/paths.js
CHANGED
|
@@ -14,9 +14,11 @@ export function projectRoot() {
|
|
|
14
14
|
return root;
|
|
15
15
|
}
|
|
16
16
|
export const agentflowctlDir = () => join(projectRoot(), ".agentflowctl");
|
|
17
|
-
export const
|
|
17
|
+
export const runsDir = () => join(agentflowctlDir(), "runs");
|
|
18
|
+
export const runDir = (id) => join(runsDir(), id);
|
|
18
19
|
export const logDir = (id) => join(runDir(id), "logs");
|
|
19
20
|
/** 每個 run 一個 git worktree,Agent 只在這裡工作,不碰你正在編輯的檔案 */
|
|
20
|
-
export const
|
|
21
|
+
export const worktreesDir = () => join(agentflowctlDir(), "worktrees");
|
|
22
|
+
export const worktreeDir = (id) => join(worktreesDir(), id);
|
|
21
23
|
export const flowDir = (id) => join(worktreeDir(id), ".flow");
|
|
22
24
|
//# sourceMappingURL=paths.js.map
|
package/dist/proc.js
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
export function exec(cmd, args, opts = {}) {
|
|
3
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
4
|
const child = spawn(cmd, args, {
|
|
8
5
|
cwd: opts.cwd,
|
|
9
|
-
env,
|
|
6
|
+
env: { ...process.env, ...opts.env },
|
|
10
7
|
shell: opts.shell ?? false,
|
|
11
8
|
});
|
|
12
9
|
let stdout = "";
|
package/dist/roles.js
CHANGED
|
@@ -2,78 +2,109 @@
|
|
|
2
2
|
* 決定每個步驟由哪個 agent 執行。
|
|
3
3
|
*
|
|
4
4
|
* 規則只有三條:
|
|
5
|
-
* 1. 審查者不能是最後寫程式的 agent
|
|
5
|
+
* 1. 審查者不能是最後寫程式的 agent,多位審查者彼此不重複。
|
|
6
6
|
* 2. 開啟 tddSplit 時,同一個任務的測試與實作由不同 agent 負責。
|
|
7
|
-
* 3.
|
|
7
|
+
* 3. 人選隨機決定,不依 cycle 的順序。cycle 只代表有哪些 agent 參與。
|
|
8
|
+
*
|
|
9
|
+
* 隨機以 seed(run id 加上步驟與輪次)決定:同一個 run 的同一步驟重算時會得到同樣的人,
|
|
10
|
+
* 所以 resume、或同一步驟在不同地方重算時結果一致。
|
|
8
11
|
*/
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
if (!exclude.includes(candidate))
|
|
16
|
-
return candidate;
|
|
12
|
+
/** FNV-1a 雜湊:把 seed 字串轉成 32 位元整數 */
|
|
13
|
+
function hash(seed) {
|
|
14
|
+
let h = 0x811c9dc5;
|
|
15
|
+
for (let i = 0; i < seed.length; i++) {
|
|
16
|
+
h ^= seed.charCodeAt(i);
|
|
17
|
+
h = Math.imul(h, 0x01000193);
|
|
17
18
|
}
|
|
18
|
-
return
|
|
19
|
+
return h >>> 0;
|
|
19
20
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
/** mulberry32:由 seed 決定的亂數產生器 */
|
|
22
|
+
function rng(seed) {
|
|
23
|
+
let a = hash(seed);
|
|
24
|
+
return () => {
|
|
25
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
26
|
+
let t = a;
|
|
27
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
28
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
29
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
30
|
+
};
|
|
26
31
|
}
|
|
27
|
-
/**
|
|
28
|
-
export function
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
for (let i =
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
break;
|
|
35
|
-
picked.push(next);
|
|
36
|
-
cursor = next;
|
|
32
|
+
/** 由 seed 決定的洗牌,不改動原陣列 */
|
|
33
|
+
export function shuffled(items, seed) {
|
|
34
|
+
const out = [...items];
|
|
35
|
+
const rand = rng(seed);
|
|
36
|
+
for (let i = out.length - 1; i > 0; i--) {
|
|
37
|
+
const j = Math.floor(rand() * (i + 1));
|
|
38
|
+
[out[i], out[j]] = [out[j], out[i]];
|
|
37
39
|
}
|
|
38
|
-
return
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
/** 隨機挑一位不在 exclude 裡的 agent;全部被排除時退回任一位(單一 agent 也能運作) */
|
|
43
|
+
export function pick(cycle, seed, exclude = []) {
|
|
44
|
+
const order = shuffled(cycle, seed);
|
|
45
|
+
return order.find((c) => !exclude.includes(c)) ?? order[0];
|
|
46
|
+
}
|
|
47
|
+
/** 規格與計畫由同一位撰寫,計畫審查才能同時避開兩者的作者 */
|
|
48
|
+
export const specAgent = (cycle, seed) => pick(cycle, `${seed}:author`);
|
|
49
|
+
export const planAgent = specAgent;
|
|
50
|
+
/**
|
|
51
|
+
* 每 cycle.length 個任務為一輪,每輪洗一次牌,所以每家輪完一次才會再輪到;
|
|
52
|
+
* 換輪時若新一輪第一位和上一輪最後一位相同,就把它往後移,避免同一家連續撰寫測試。
|
|
53
|
+
*/
|
|
54
|
+
function taskBag(cycle, seed, bag) {
|
|
55
|
+
const order = shuffled(cycle, `${seed}:tasks:${bag}`);
|
|
56
|
+
if (bag === 0 || order.length < 2)
|
|
57
|
+
return order;
|
|
58
|
+
const prevLast = taskBag(cycle, seed, bag - 1).at(-1);
|
|
59
|
+
return order[0] === prevLast ? [...order.slice(1), order[0]] : order;
|
|
60
|
+
}
|
|
61
|
+
/** 第 i 個任務的測試作者從洗好的牌依序取;實作者隨機挑一位測試作者以外的 agent */
|
|
62
|
+
export function taskAgents(cycle, taskIndex, tddSplit, seed) {
|
|
63
|
+
const n = cycle.length;
|
|
64
|
+
const tests = taskBag(cycle, seed, Math.floor(taskIndex / n))[taskIndex % n];
|
|
65
|
+
return { tests, code: tddSplit ? pick(cycle, `${seed}:code:${taskIndex}`, [tests]) : tests };
|
|
66
|
+
}
|
|
67
|
+
/** 隨機挑出 quorum 位彼此不重複、而且不是作者的 reviewer */
|
|
68
|
+
export function reviewers(cycle, lastWriter, quorum, seed) {
|
|
69
|
+
const picked = shuffled(cycle.filter((c) => c !== lastWriter), seed).slice(0, quorum);
|
|
70
|
+
return picked.length ? picked : [lastWriter ?? cycle[0]];
|
|
39
71
|
}
|
|
40
72
|
/**
|
|
41
73
|
* 誰來修正:
|
|
42
74
|
* - verify 失敗(型別、lint、建置這類機械性錯誤):交給最後寫程式的 agent
|
|
43
|
-
* - review 要求修改:ring
|
|
75
|
+
* - review 要求修改:ring 策略隨機挑一位 reviewer 以外的 agent;author 策略交回作者
|
|
44
76
|
*/
|
|
45
77
|
export function fixAgent(cycle, opts) {
|
|
46
78
|
if (opts.source === "verify" || opts.strategy === "author")
|
|
47
79
|
return opts.lastWriter ?? cycle[0];
|
|
48
|
-
return
|
|
80
|
+
return pick(cycle, opts.seed, opts.lastReviewer ? [opts.lastReviewer] : []);
|
|
49
81
|
}
|
|
50
|
-
/** 計畫修正者:ring
|
|
82
|
+
/** 計畫修正者:ring 策略隨機挑一位審查者以外的 agent;author 策略交回計畫作者 */
|
|
51
83
|
export function planFixAgent(cycle, opts) {
|
|
52
84
|
if (opts.strategy === "author")
|
|
53
85
|
return opts.planWriter ?? cycle[0];
|
|
54
|
-
return
|
|
86
|
+
return pick(cycle, opts.seed, opts.planReviewer ? [opts.planReviewer] : []);
|
|
55
87
|
}
|
|
56
88
|
/**
|
|
57
89
|
* 仲裁小組:
|
|
58
|
-
* - 有第三方(作者與最後審查者以外的 agent)→
|
|
90
|
+
* - 有第三方(作者與最後審查者以外的 agent)→ 隨機挑一位第三方單獨仲裁
|
|
59
91
|
* - 只有兩家 → 兩家各自在全新 context 中雙盲仲裁,避免讓一直反對的審查者同時當裁判
|
|
60
92
|
* - 只有一家 → 只能由它自己仲裁
|
|
61
93
|
*/
|
|
62
|
-
export function arbiterPanel(cycle, writer, reviewer) {
|
|
94
|
+
export function arbiterPanel(cycle, seed, writer, reviewer) {
|
|
63
95
|
const involved = [writer, reviewer].filter((x) => Boolean(x));
|
|
64
|
-
const third = cycle.find((c) => !involved.includes(c));
|
|
96
|
+
const third = shuffled(cycle, seed).find((c) => !involved.includes(c));
|
|
65
97
|
if (third)
|
|
66
98
|
return [third];
|
|
67
99
|
return [...new Set(cycle)];
|
|
68
100
|
}
|
|
69
101
|
/**
|
|
70
|
-
* 代打人選:原本的 agent
|
|
102
|
+
* 代打人選:原本的 agent 可用就用它;額度用完時,隨機挑一位還有額度的 agent。
|
|
71
103
|
* 全部都用完時回傳 undefined。
|
|
72
104
|
*/
|
|
73
|
-
export function availableAgent(cycle, preferred, exhausted) {
|
|
105
|
+
export function availableAgent(cycle, preferred, exhausted, seed) {
|
|
74
106
|
if (!exhausted.includes(preferred))
|
|
75
107
|
return preferred;
|
|
76
|
-
|
|
77
|
-
return exhausted.includes(next) ? undefined : next;
|
|
108
|
+
return shuffled(cycle, seed).find((c) => !exhausted.includes(c));
|
|
78
109
|
}
|
|
79
110
|
//# sourceMappingURL=roles.js.map
|