agentflowctl 0.2.0 → 0.4.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 +154 -16
- package/dist/agentConfig.js +6 -11
- package/dist/agents/claude.js +2 -2
- package/dist/agents/codex.js +6 -3
- package/dist/agents/gemini.js +2 -2
- package/dist/agents/index.js +0 -2
- package/dist/agents/types.js +14 -0
- package/dist/cleanup.js +54 -0
- package/dist/cli.js +120 -48
- package/dist/config.js +2 -0
- package/dist/detect.js +82 -0
- package/dist/engine.js +23 -13
- package/dist/logs.js +173 -0
- package/dist/paths.js +4 -2
- package/dist/proc.js +1 -4
- package/dist/runner.js +30 -26
- package/dist/schemas.js +2 -2
- package/dist/setup.js +70 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from "commander";
|
|
3
|
-
import {
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import { stdin, stdout } from "node:process";
|
|
6
|
+
import { createInterface } from "node:readline/promises";
|
|
7
|
+
import { config } from "./config.js";
|
|
6
8
|
import { advance, loadRepoConfig } from "./engine.js";
|
|
7
|
-
import {
|
|
8
|
-
import { addWorktree, git
|
|
9
|
-
import {
|
|
9
|
+
import { probeAgent, resolveAgent, runCommand } from "./runner.js";
|
|
10
|
+
import { addWorktree, git } from "./git.js";
|
|
11
|
+
import { cleanableRuns, cleanRun } from "./cleanup.js";
|
|
12
|
+
import { describeDetected, detectProjectDefaults } from "./detect.js";
|
|
13
|
+
import { CMD_AGENT, listLogs, localTime, logMark, nextLogFile, renderLog } from "./logs.js";
|
|
14
|
+
import { flowDir, logDir, projectRoot, worktreeDir } from "./paths.js";
|
|
10
15
|
import { TaskList } from "./schemas.js";
|
|
11
16
|
import { agentRuns, getRun, listRuns, listSubstitutions, saveRun, usageByAgent } from "./store.js";
|
|
12
17
|
import { readJsonFile } from "./util.js";
|
|
18
|
+
import { runSetup, SETUP_ADAPTERS } from "./setup.js";
|
|
13
19
|
import { addAgent, readRawConfig, removeAgent, setAgent, setCycle, writeRawConfig } from "./agentConfig.js";
|
|
14
20
|
function mustGetRun(id) {
|
|
15
21
|
const run = getRun(id);
|
|
@@ -40,7 +46,14 @@ function printSummary(run) {
|
|
|
40
46
|
if (run.stage === "failed") {
|
|
41
47
|
console.log(`失敗於 ${run.failedStage ?? "?"}`);
|
|
42
48
|
console.log(`原因 ${run.failureReason ?? "?"}`);
|
|
43
|
-
|
|
49
|
+
const logs = listLogs(logDir(run.id));
|
|
50
|
+
const last = logs.at(-1);
|
|
51
|
+
const lastFailed = logs.filter((e) => e.footer && !e.footer.ok).at(-1);
|
|
52
|
+
if (last)
|
|
53
|
+
console.log(`最後的 log #${last.seq} ${logMark(last)}(agentflowctl logs ${run.id} ${last.seq})`);
|
|
54
|
+
if (lastFailed && lastFailed !== last)
|
|
55
|
+
console.log(`最近失敗的 log #${lastFailed.seq}(agentflowctl logs ${run.id} ${lastFailed.seq})`);
|
|
56
|
+
console.log(`\n必要時直接在 worktree 裡修正,再執行 agentflowctl resume ${run.id}`);
|
|
44
57
|
}
|
|
45
58
|
if (run.stage === "paused") {
|
|
46
59
|
console.log(`暫停於 ${run.pausedStage ?? "?"}`);
|
|
@@ -61,17 +74,26 @@ async function resolveCycle(flag) {
|
|
|
61
74
|
}
|
|
62
75
|
return wanted;
|
|
63
76
|
}
|
|
77
|
+
// 沒有內建 agent:只從 agents 裡定義的挑出已安裝的
|
|
78
|
+
const defined = Object.keys(cfg.agents);
|
|
79
|
+
if (!defined.length)
|
|
80
|
+
throw new Error("還沒有設定任何 agent,請先用 agentflowctl agent setup 互動設定,或用 agent add <name> --adapter <adapter> 新增");
|
|
64
81
|
const found = [];
|
|
65
|
-
for (const name of
|
|
82
|
+
for (const name of defined)
|
|
66
83
|
if (await probeAgent(resolveAgent(cfg, name)))
|
|
67
84
|
found.push(name);
|
|
68
85
|
if (!found.length)
|
|
69
|
-
throw new Error(
|
|
86
|
+
throw new Error(`設定的 agent(${defined.join("、")})都沒有偵測到已安裝的 CLI,可用 agentflowctl doctor 檢查`);
|
|
70
87
|
return found;
|
|
71
88
|
}
|
|
72
89
|
const program = new Command()
|
|
73
90
|
.name("agentflowctl")
|
|
74
|
-
.description("在專案資料夾內執行的 Agent 開發流程:規格 → 計畫 → TDD 實作 → 驗證 → 審查 → PR")
|
|
91
|
+
.description("在專案資料夾內執行的 Agent 開發流程:規格 → 計畫 → TDD 實作 → 驗證 → 審查 → PR")
|
|
92
|
+
.option("-v, --verbose", "執行時印出 agent 的文字、工具呼叫與專案指令(預設只印階段進度)")
|
|
93
|
+
.hook("preAction", (cmd) => {
|
|
94
|
+
if (cmd.opts().verbose)
|
|
95
|
+
config.verbose = true;
|
|
96
|
+
});
|
|
75
97
|
program
|
|
76
98
|
.command("run")
|
|
77
99
|
.description("在目前的專案建立並執行新的 flow")
|
|
@@ -95,12 +117,9 @@ program
|
|
|
95
117
|
console.log(`[${id}] 🌿 從 ${base} 建立 worktree(分支 ${branch})`);
|
|
96
118
|
await addWorktree(root, worktreeDir(id), base, branch);
|
|
97
119
|
console.log(`[${id}] 🤝 agent 輪替順序:${cycle.join(" → ")}`);
|
|
98
|
-
// 先裝好相依套件:有些 agent 的沙箱不能連網,無法自己安裝
|
|
99
120
|
const cfg = loadRepoConfig();
|
|
100
|
-
const install = await runCommand({ runId: id, cwd: worktreeDir(id), logFile: join(logDir(id), "install.log") }, cfg.install);
|
|
101
|
-
if (!install.ok)
|
|
102
|
-
console.log(`[${id}] ⚠️ 安裝相依套件失敗,稍後 verify 階段會再試一次(見 ${join(logDir(id), "install.log")})`);
|
|
103
121
|
const now = new Date().toISOString();
|
|
122
|
+
// worktree 一建好就寫入紀錄:之後在任何地方中斷,都能用 resume 接續或用 clean 清掉
|
|
104
123
|
const run = saveRun({
|
|
105
124
|
id,
|
|
106
125
|
baseBranch: base,
|
|
@@ -116,6 +135,12 @@ program
|
|
|
116
135
|
createdAt: now,
|
|
117
136
|
updatedAt: now,
|
|
118
137
|
});
|
|
138
|
+
for (const line of describeDetected(readRawConfig(configPath()), detectProjectDefaults(root)))
|
|
139
|
+
console.log(`[${id}] ${line}`);
|
|
140
|
+
// 先裝好相依套件:有些 agent 的沙箱不能連網,無法自己安裝
|
|
141
|
+
const install = await runCommand({ runId: id, cwd: worktreeDir(id), logFile: nextLogFile(logDir(id), "setup", "install", CMD_AGENT), stage: "setup", step: "install" }, cfg.install);
|
|
142
|
+
if (!install.ok)
|
|
143
|
+
console.log(`[${id}] ⚠️ 安裝相依套件失敗,稍後 verify 階段會再試一次(agentflowctl logs ${id} ${install.seq})`);
|
|
119
144
|
await drive(run);
|
|
120
145
|
});
|
|
121
146
|
program
|
|
@@ -152,14 +177,29 @@ program
|
|
|
152
177
|
console.log(`已取消 ${id}`);
|
|
153
178
|
});
|
|
154
179
|
program
|
|
155
|
-
.command("clean
|
|
156
|
-
.description("移除 run 的 worktree
|
|
157
|
-
.
|
|
158
|
-
|
|
159
|
-
if (
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
180
|
+
.command("clean [id]")
|
|
181
|
+
.description("移除 run 的 worktree 與紀錄(分支會保留);--all 清掉所有已結束的 run 與中斷留下的孤兒 worktree")
|
|
182
|
+
.option("--all", "清除所有 done、failed 的 run,以及沒有紀錄的 worktree;進行中、暫停、等待核准的不動", false)
|
|
183
|
+
.action(async (id, opts) => {
|
|
184
|
+
if (opts.all === Boolean(id))
|
|
185
|
+
throw new Error("請指定 run id,或使用 --all(兩者擇一)");
|
|
186
|
+
if (id) {
|
|
187
|
+
if (!(await cleanRun(id)))
|
|
188
|
+
throw new Error(`找不到 run:${id}`);
|
|
189
|
+
console.log(`已清除 ${id},分支仍保留,不需要時可用 git branch -D 刪除`);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const targets = cleanableRuns();
|
|
193
|
+
if (!targets.length) {
|
|
194
|
+
await git(projectRoot(), "worktree", "prune");
|
|
195
|
+
console.log("沒有可清除的 run");
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
for (const t of targets) {
|
|
199
|
+
await cleanRun(t.id);
|
|
200
|
+
console.log(`已清除 ${t.id}(${t.stage ?? "沒有紀錄,可能是建立時中斷"})`);
|
|
201
|
+
}
|
|
202
|
+
console.log(`\n共清除 ${targets.length} 個,分支仍保留,不需要時可用 git branch -D 刪除`);
|
|
163
203
|
});
|
|
164
204
|
program
|
|
165
205
|
.command("status <id>")
|
|
@@ -207,23 +247,24 @@ function applyEdit(edit, done) {
|
|
|
207
247
|
const agent = program.command("agent").description("管理 agent 與 adapter 設定(寫入 flow.config.json)");
|
|
208
248
|
agent
|
|
209
249
|
.command("list")
|
|
210
|
-
.description("
|
|
250
|
+
.description("列出設定的 agent、是否已安裝、在輪替中的位置")
|
|
211
251
|
.action(async () => {
|
|
212
252
|
const cfg = loadRepoConfig();
|
|
213
253
|
const cycle = await resolveCycle().catch(() => cfg.cycle ?? []);
|
|
214
|
-
const names =
|
|
254
|
+
const names = Object.keys(cfg.agents);
|
|
255
|
+
if (!names.length)
|
|
256
|
+
console.log("還沒有設定任何 agent,請用 agent setup 互動設定,或用 agent add <name> --adapter <adapter> 新增");
|
|
215
257
|
for (const name of names) {
|
|
216
258
|
const def = resolveAgent(cfg, name);
|
|
217
259
|
const ok = await probeAgent(def);
|
|
218
260
|
const pos = cycle.indexOf(name);
|
|
219
|
-
const kind = DEFAULT_CYCLE.includes(name) ? (name in cfg.agents ? "內建(已覆寫)" : "內建") : "自訂";
|
|
220
261
|
const detail = [
|
|
221
262
|
`adapter=${def.adapter}`,
|
|
222
263
|
def.model && `model=${def.model}`,
|
|
223
264
|
def.extraArgs.length && `extraArgs=${def.extraArgs.join(" ")}`,
|
|
224
265
|
def.command && `command=${def.command.join(" ")}`,
|
|
225
266
|
].filter(Boolean);
|
|
226
|
-
console.log(`${ok ? "✅" : "❌"} ${name.padEnd(14)} ${
|
|
267
|
+
console.log(`${ok ? "✅" : "❌"} ${name.padEnd(14)} ${pos >= 0 ? `輪替 #${pos + 1}` : "不在輪替"} ${detail.join(" ")}`);
|
|
227
268
|
}
|
|
228
269
|
console.log(`
|
|
229
270
|
輪替順序:${cycle.length ? cycle.join(" → ") : "(沒有可用的 agent)"}${cfg.cycle ? "" : "(自動偵測)"}`);
|
|
@@ -248,8 +289,8 @@ agent
|
|
|
248
289
|
});
|
|
249
290
|
agent
|
|
250
291
|
.command("remove <name>")
|
|
251
|
-
.description("
|
|
252
|
-
.action((name) => applyEdit((cfg) => removeAgent(cfg, name),
|
|
292
|
+
.description("刪除 agent,一併從輪替移除")
|
|
293
|
+
.action((name) => applyEdit((cfg) => removeAgent(cfg, name), `已移除 ${name}`));
|
|
253
294
|
agent
|
|
254
295
|
.command("cycle [names]")
|
|
255
296
|
.description("顯示輪替順序,或用逗號分隔設定新的順序")
|
|
@@ -267,12 +308,36 @@ agent
|
|
|
267
308
|
console.log(`⚠️ ${n} 目前找不到可執行的 CLI,run 會失敗,請先安裝或用 agent set 修正`);
|
|
268
309
|
}
|
|
269
310
|
});
|
|
270
|
-
|
|
271
|
-
.command("
|
|
272
|
-
.description("
|
|
311
|
+
agent
|
|
312
|
+
.command("setup")
|
|
313
|
+
.description("互動式設定:偵測已安裝的 claude、codex、gemini,逐一選擇要不要加入並設定輪替順序")
|
|
273
314
|
.action(async () => {
|
|
315
|
+
if (!stdin.isTTY)
|
|
316
|
+
throw new Error("agent setup 需要互動式終端機,請改用 agent add");
|
|
317
|
+
const detected = {};
|
|
318
|
+
for (const a of SETUP_ADAPTERS)
|
|
319
|
+
detected[a] = await probeAgent({ adapter: a, extraArgs: [] });
|
|
320
|
+
const rl = createInterface({ input: stdin, output: stdout });
|
|
321
|
+
let edit;
|
|
322
|
+
try {
|
|
323
|
+
edit = await runSetup(readRawConfig(configPath()), { ask: (q) => rl.question(q), detected, log: (l) => console.log(l) });
|
|
324
|
+
}
|
|
325
|
+
finally {
|
|
326
|
+
rl.close();
|
|
327
|
+
}
|
|
328
|
+
if (!edit)
|
|
329
|
+
return;
|
|
330
|
+
const result = edit;
|
|
331
|
+
applyEdit(() => result, "設定完成");
|
|
332
|
+
console.log("");
|
|
333
|
+
await doctor();
|
|
334
|
+
});
|
|
335
|
+
/** 檢查設定的 agent 是否已安裝,印出輪替順序與主要設定 */
|
|
336
|
+
async function doctor() {
|
|
274
337
|
const cfg = loadRepoConfig();
|
|
275
|
-
const names =
|
|
338
|
+
const names = Object.keys(cfg.agents);
|
|
339
|
+
if (!names.length)
|
|
340
|
+
console.log("還沒有設定任何 agent,請用 agent setup 互動設定,或用 agent add <name> --adapter <adapter> 新增");
|
|
276
341
|
for (const name of names) {
|
|
277
342
|
const def = resolveAgent(cfg, name);
|
|
278
343
|
const ok = await probeAgent(def);
|
|
@@ -284,15 +349,11 @@ program
|
|
|
284
349
|
catch (e) {
|
|
285
350
|
console.log(`\n${e.message}`);
|
|
286
351
|
}
|
|
287
|
-
|
|
288
|
-
console.log("\n登入方式:訂閱登入(執行 agent 時會移除 API key)");
|
|
289
|
-
if (leaked.length) {
|
|
290
|
-
console.log(`⚠️ 環境中有 ${leaked.join("、")},agentflowctl 執行 agent 時會移除,但你自己直接執行 CLI 時仍可能改走 API 計費`);
|
|
291
|
-
}
|
|
292
|
-
console.log(`單一 run 的 agent 執行上限:${cfg.maxAgentRuns} 次`);
|
|
352
|
+
console.log(`\n單一 run 的 agent 執行上限:${cfg.maxAgentRuns} 次`);
|
|
293
353
|
console.log(`修正策略:${cfg.fixStrategy} 測試與實作分開:${cfg.tddSplit ? "是" : "否"}`);
|
|
294
354
|
console.log(`程式碼審查人數:${cfg.reviewQuorum} 計畫審查人數:${cfg.planReviewQuorum} 計畫仲裁:${cfg.planArbiter ? "開啟" : "關閉"}`);
|
|
295
|
-
}
|
|
355
|
+
}
|
|
356
|
+
program.command("doctor").description("檢查可用的 agent CLI 與目前的輪替設定").action(doctor);
|
|
296
357
|
program
|
|
297
358
|
.command("list")
|
|
298
359
|
.description("列出這個專案的所有 run")
|
|
@@ -303,18 +364,29 @@ program
|
|
|
303
364
|
}
|
|
304
365
|
});
|
|
305
366
|
program
|
|
306
|
-
.command("logs <id>")
|
|
307
|
-
.description("列出 log
|
|
308
|
-
.option("--latest", "顯示最新一份 log
|
|
309
|
-
.
|
|
367
|
+
.command("logs <id> [seq]")
|
|
368
|
+
.description("列出 log;指定編號(或 --latest)時顯示解析後的內容,最後附上錯誤整理")
|
|
369
|
+
.option("--latest", "顯示最新一份 log", false)
|
|
370
|
+
.option("--raw", "顯示原始內容(agent 的 JSON 行)", false)
|
|
371
|
+
.action((id, seq, opts) => {
|
|
310
372
|
mustGetRun(id);
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
if (!files.length)
|
|
373
|
+
const logs = listLogs(logDir(id));
|
|
374
|
+
if (!logs.length)
|
|
314
375
|
return console.log("還沒有 log");
|
|
315
|
-
if (!opts.latest)
|
|
316
|
-
|
|
317
|
-
|
|
376
|
+
if (!seq && !opts.latest) {
|
|
377
|
+
console.log(" # 結果 階段 步驟 agent 開始時間");
|
|
378
|
+
for (const e of logs) {
|
|
379
|
+
const h = e.header;
|
|
380
|
+
console.log(`${String(e.seq).padStart(3)} ${logMark(e).padEnd(4)} ${(h?.stage ?? "?").padEnd(12)} ${(h?.step ?? "?").padEnd(19)} ${(h?.agent ?? "?").padEnd(10)} ${localTime(h?.startedAt)}`);
|
|
381
|
+
}
|
|
382
|
+
console.log(`\n查看內容:agentflowctl logs ${id} <編號>(加 --raw 看原始 JSON)`);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
const entry = seq ? logs.find((e) => e.seq === Number(seq)) : logs.at(-1);
|
|
386
|
+
if (!entry)
|
|
387
|
+
throw new Error(`找不到 log #${seq}(共 ${logs.length} 份,可用 agentflowctl logs ${id} 列出)`);
|
|
388
|
+
const text = readFileSync(entry.file, "utf8");
|
|
389
|
+
console.log(opts.raw ? text : renderLog(text, entry.file));
|
|
318
390
|
});
|
|
319
391
|
program.parseAsync().catch((err) => {
|
|
320
392
|
console.error(`錯誤:${err.message}`);
|
package/dist/config.js
CHANGED
|
@@ -5,5 +5,7 @@ export const config = {
|
|
|
5
5
|
maxTurns: Number(process.env.AGENTFLOWCTL_MAX_TURNS ?? 80),
|
|
6
6
|
/** 同一個關卡連續失敗幾次後停止 */
|
|
7
7
|
maxAttempts: Number(process.env.AGENTFLOWCTL_MAX_ATTEMPTS ?? 3),
|
|
8
|
+
/** 終端機是否印出 agent 的文字、工具呼叫與專案指令;預設安靜,-v 或 AGENTFLOWCTL_VERBOSE=1 開啟 */
|
|
9
|
+
verbose: process.env.AGENTFLOWCTL_VERBOSE === "1",
|
|
8
10
|
};
|
|
9
11
|
//# sourceMappingURL=config.js.map
|
package/dist/detect.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { RepoConfig } from "./schemas.js";
|
|
4
|
+
const LOCKFILES = [
|
|
5
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
6
|
+
["yarn.lock", "yarn"],
|
|
7
|
+
["bun.lock", "bun"],
|
|
8
|
+
["bun.lockb", "bun"],
|
|
9
|
+
["package-lock.json", "npm"],
|
|
10
|
+
];
|
|
11
|
+
/** 不鎖 lockfile:implement 階段 agent 可能新增依賴 */
|
|
12
|
+
const INSTALL = {
|
|
13
|
+
npm: RepoConfig.parse({}).install,
|
|
14
|
+
pnpm: "pnpm install",
|
|
15
|
+
yarn: "yarn install",
|
|
16
|
+
bun: "bun install",
|
|
17
|
+
};
|
|
18
|
+
/** 執行專案內 bin 的前綴,取代預設指令裡的 npx */
|
|
19
|
+
const EXEC = { npm: "npx", pnpm: "pnpm exec", yarn: "yarn", bun: "bunx" };
|
|
20
|
+
/** 每項檢查會找的 script 名稱,依序取第一個存在的 */
|
|
21
|
+
const CHECK_SCRIPTS = {
|
|
22
|
+
typecheck: ["typecheck", "type-check"],
|
|
23
|
+
lint: ["lint"],
|
|
24
|
+
test: ["test"],
|
|
25
|
+
build: ["build"],
|
|
26
|
+
};
|
|
27
|
+
function readPackageJson(root) {
|
|
28
|
+
try {
|
|
29
|
+
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
|
|
30
|
+
return pkg && typeof pkg === "object" ? pkg : {};
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function detectManager(root, pkg) {
|
|
37
|
+
if (typeof pkg.packageManager === "string") {
|
|
38
|
+
const name = pkg.packageManager.split("@")[0];
|
|
39
|
+
if (name === "npm" || name === "pnpm" || name === "yarn" || name === "bun") {
|
|
40
|
+
return { manager: name, source: "package.json 的 packageManager" };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
for (const [file, manager] of LOCKFILES) {
|
|
44
|
+
if (existsSync(join(root, file)))
|
|
45
|
+
return { manager, source: file };
|
|
46
|
+
}
|
|
47
|
+
return { manager: "npm", source: "預設" };
|
|
48
|
+
}
|
|
49
|
+
const withExec = (cmd, manager) => cmd.replace(/^npx /, `${EXEC[manager]} `);
|
|
50
|
+
export function detectProjectDefaults(root) {
|
|
51
|
+
const pkg = readPackageJson(root);
|
|
52
|
+
const { manager, source } = detectManager(root, pkg);
|
|
53
|
+
const defaults = RepoConfig.parse({});
|
|
54
|
+
const scripts = pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
|
|
55
|
+
const checks = defaults.checks.map(({ name, cmd }) => {
|
|
56
|
+
const script = CHECK_SCRIPTS[name]?.find((s) => typeof scripts[s] === "string");
|
|
57
|
+
return { name, cmd: script ? `${manager} run ${script}` : withExec(cmd, manager) };
|
|
58
|
+
});
|
|
59
|
+
return { manager, source, install: INSTALL[manager], test: withExec(defaults.test, manager), checks };
|
|
60
|
+
}
|
|
61
|
+
/** 補上原始設定裡沒寫的 install、test、checks;不是物件就原樣回傳,交給 schema 報錯 */
|
|
62
|
+
export function withProjectDefaults(raw, detected) {
|
|
63
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
64
|
+
return raw;
|
|
65
|
+
const { install, test, checks } = detected;
|
|
66
|
+
return { install, test, checks, ...raw };
|
|
67
|
+
}
|
|
68
|
+
/** run 開始時印出的說明:只列出這次用了偵測結果的欄位 */
|
|
69
|
+
export function describeDetected(raw, detected) {
|
|
70
|
+
const lines = [];
|
|
71
|
+
if (!("install" in raw))
|
|
72
|
+
lines.push(` install:${detected.install}`);
|
|
73
|
+
if (!("test" in raw))
|
|
74
|
+
lines.push(` test:${detected.test}`);
|
|
75
|
+
if (!("checks" in raw))
|
|
76
|
+
lines.push(...detected.checks.map((c) => ` checks.${c.name}:${c.cmd}`));
|
|
77
|
+
if (!lines.length)
|
|
78
|
+
return [];
|
|
79
|
+
const why = detected.source === "預設" ? "預設" : `依 ${detected.source}`;
|
|
80
|
+
return [`🔧 依專案偵測指令:${detected.manager}(${why})`, ...lines];
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=detect.js.map
|
package/dist/engine.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
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 { detectProjectDefaults, withProjectDefaults } from "./detect.js";
|
|
4
6
|
import { changedFiles, commitAll, discardChanges, git, headCommit, resetTo } from "./git.js";
|
|
5
7
|
import { flowDir, logDir, projectRoot, runDir, worktreeDir } from "./paths.js";
|
|
8
|
+
import { CMD_AGENT, nextLogFile } from "./logs.js";
|
|
6
9
|
import { exec } from "./proc.js";
|
|
7
10
|
import { arbiterPanel, availableAgent, fixAgent, planAgent, planFixAgent, reviewers, specAgent, taskAgents } from "./roles.js";
|
|
8
11
|
import { resolveAgent, runAgent, runCommand } from "./runner.js";
|
|
@@ -12,11 +15,11 @@ import { orderTasks } from "./tasks.js";
|
|
|
12
15
|
import { readJsonFile, renderPrompt, tail } from "./util.js";
|
|
13
16
|
// ───────────────────────── 共用工具 ─────────────────────────
|
|
14
17
|
const info = (run, msg) => console.log(`[${run.id}] ${msg}`);
|
|
18
|
+
const logHint = (run, seq) => `(agentflowctl logs ${run.id} ${seq})`;
|
|
15
19
|
const flowFile = (run, name) => join(flowDir(run.id), name);
|
|
16
20
|
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`) };
|
|
21
|
+
function target(run, step, agent) {
|
|
22
|
+
return { runId: run.id, cwd: worktreeDir(run.id), logFile: nextLogFile(logDir(run.id), run.stage, step, agent), stage: run.stage, step };
|
|
20
23
|
}
|
|
21
24
|
/** 額度用完而必須停下:審查類步驟,或所有 agent 的額度都用完 */
|
|
22
25
|
export class QuotaPause extends Error {
|
|
@@ -43,7 +46,7 @@ async function agentStep(run, planned, step, prompt, mode) {
|
|
|
43
46
|
info(run, `🔁 ${agent} 額度已用完,${step} 由 ${sub} 代打${note ? `(注意:${note})` : ""}`);
|
|
44
47
|
agent = sub;
|
|
45
48
|
}
|
|
46
|
-
const r = await runAgent(agent, resolveAgent(cfg, agent), target(run,
|
|
49
|
+
const r = await runAgent(agent, resolveAgent(cfg, agent), target(run, step, agent), prompt);
|
|
47
50
|
addUsage(run.id, { stage: step, agent, inputTokens: r.inputTokens, outputTokens: r.outputTokens });
|
|
48
51
|
if (!r.quotaExhausted) {
|
|
49
52
|
reportMeta(run, agent, r);
|
|
@@ -72,6 +75,7 @@ function readFeedback(run) {
|
|
|
72
75
|
function retry(run, key, reason, backTo) {
|
|
73
76
|
const n = (run.attempts[key] ?? 0) + 1;
|
|
74
77
|
const attempts = { ...run.attempts, [key]: n };
|
|
78
|
+
mkdirSync(flowDir(run.id), { recursive: true });
|
|
75
79
|
writeFileSync(flowFile(run, "feedback.md"), `# 前次嘗試未通過(第 ${n} 次)\n\n${reason}\n`);
|
|
76
80
|
if (n >= config.maxAttempts) {
|
|
77
81
|
return { ...run, attempts, stage: "failed", failedStage: backTo, failureReason: `${key} 連續失敗 ${n} 次:${tail(reason, 500)}` };
|
|
@@ -86,11 +90,14 @@ function succeed(run, key, next) {
|
|
|
86
90
|
return { ...run, attempts, stage: next };
|
|
87
91
|
}
|
|
88
92
|
/** 設定檔放在主專案根目錄,未 commit 的修改也會生效 */
|
|
93
|
+
/** 讀取 flow.config.json;沒寫的 install、test、checks 依專案現況偵測 */
|
|
89
94
|
export function loadRepoConfig() {
|
|
90
|
-
const
|
|
95
|
+
const root = projectRoot();
|
|
96
|
+
const detected = detectProjectDefaults(root);
|
|
97
|
+
const p = join(root, "flow.config.json");
|
|
91
98
|
if (!existsSync(p))
|
|
92
|
-
return RepoConfig.parse({});
|
|
93
|
-
const r = readJsonFile(p, RepoConfig);
|
|
99
|
+
return RepoConfig.parse(withProjectDefaults({}, detected));
|
|
100
|
+
const r = readJsonFile(p, z.preprocess((raw) => withProjectDefaults(raw, detected), RepoConfig));
|
|
94
101
|
if (!r.ok)
|
|
95
102
|
throw new Error(r.error);
|
|
96
103
|
return r.data;
|
|
@@ -351,7 +358,7 @@ async function implementStage(run) {
|
|
|
351
358
|
await resetTo(repo, before);
|
|
352
359
|
return retry(run, key, `沒有新增或修改任何符合 /${cfg.testPattern}/ 的測試檔。`, "implement");
|
|
353
360
|
}
|
|
354
|
-
const red = await runCommand(target(run, `${task.id}-red
|
|
361
|
+
const red = await runCommand(target(run, `${task.id}-red`, CMD_AGENT), testCmd);
|
|
355
362
|
if (red.ok) {
|
|
356
363
|
await resetTo(repo, before);
|
|
357
364
|
return retry(run, key, "測試在功能尚未實作前就全部通過,代表測試沒有驗證到新行為。請撰寫會因功能尚未實作而失敗的測試。", "implement");
|
|
@@ -376,9 +383,11 @@ async function implementStage(run) {
|
|
|
376
383
|
await resetTo(repo, testsCommit);
|
|
377
384
|
return retry(run, key, `實作階段不可修改測試檔,已還原你的變更:${touched.join(", ")}`, "implement");
|
|
378
385
|
}
|
|
379
|
-
const green = await runCommand(target(run, `${task.id}-green
|
|
380
|
-
if (!green.ok)
|
|
386
|
+
const green = await runCommand(target(run, `${task.id}-green`, CMD_AGENT), testCmd);
|
|
387
|
+
if (!green.ok) {
|
|
388
|
+
info(run, ` ✗ 測試仍未通過${logHint(run, green.seq)}`);
|
|
381
389
|
return retry(run, key, `測試仍未通過:\n\n\`\`\`\n${tail(green.output)}\n\`\`\``, "implement");
|
|
390
|
+
}
|
|
382
391
|
info(run, `🟢 [${progress}] 完成`);
|
|
383
392
|
return {
|
|
384
393
|
...succeed(run, key, "implement"),
|
|
@@ -393,14 +402,15 @@ async function verifyStage(run) {
|
|
|
393
402
|
info(run, "🔍 執行驗證");
|
|
394
403
|
const cfg = loadRepoConfig();
|
|
395
404
|
const results = [];
|
|
396
|
-
const install = await runCommand(target(run, "
|
|
405
|
+
const install = await runCommand(target(run, "install", CMD_AGENT), cfg.install);
|
|
397
406
|
if (!install.ok) {
|
|
407
|
+
info(run, ` ✗ install${logHint(run, install.seq)}`);
|
|
398
408
|
results.push({ name: "install", ok: false, output: tail(install.output) });
|
|
399
409
|
}
|
|
400
410
|
else {
|
|
401
411
|
for (const check of cfg.checks) {
|
|
402
|
-
const r = await runCommand(target(run,
|
|
403
|
-
info(run, ` ${r.ok ? "✓" : "✗"} ${check.name}`);
|
|
412
|
+
const r = await runCommand(target(run, check.name, CMD_AGENT), check.cmd);
|
|
413
|
+
info(run, ` ${r.ok ? "✓" : "✗"} ${check.name}${r.ok ? "" : logHint(run, r.seq)}`);
|
|
404
414
|
results.push({ name: check.name, ok: r.ok, output: tail(r.output, 3000) });
|
|
405
415
|
}
|
|
406
416
|
}
|
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
|