agentflowctl 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +235 -0
- package/dist/agents/claude.js +65 -0
- package/dist/agents/codex.js +39 -0
- package/dist/agents/command.js +21 -0
- package/dist/agents/gemini.js +41 -0
- package/dist/agents/index.js +8 -0
- package/dist/agents/types.js +12 -0
- package/dist/cli.js +252 -0
- package/dist/config.js +9 -0
- package/dist/engine.js +540 -0
- package/dist/git.js +59 -0
- package/dist/paths.js +22 -0
- package/dist/proc.js +40 -0
- package/dist/roles.js +79 -0
- package/dist/runner.js +125 -0
- package/dist/schemas.js +131 -0
- package/dist/store.js +77 -0
- package/dist/tasks.js +51 -0
- package/dist/util.js +33 -0
- package/examples/flow.config.json +26 -0
- package/examples/github-actions.yml +47 -0
- package/package.json +56 -0
- package/prompts/fix.md +17 -0
- package/prompts/implement-code.md +27 -0
- package/prompts/implement-tests.md +21 -0
- package/prompts/plan-arbiter.md +37 -0
- package/prompts/plan-fix.md +23 -0
- package/prompts/plan-review.md +44 -0
- package/prompts/plan.md +37 -0
- package/prompts/review.md +37 -0
- package/prompts/spec.md +24 -0
package/dist/roles.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 決定每個步驟由哪個 agent 執行。
|
|
3
|
+
*
|
|
4
|
+
* 規則只有三條:
|
|
5
|
+
* 1. 審查者不能是最後寫程式的 agent。
|
|
6
|
+
* 2. 開啟 tddSplit 時,同一個任務的測試與實作由不同 agent 負責。
|
|
7
|
+
* 3. 所有選擇都沿著同一個輪替順序(cycle)前進,所以不同公司的模型會輪流扮演各個角色。
|
|
8
|
+
*/
|
|
9
|
+
/** 從 from 的下一位開始,找第一個不在 exclude 裡的 agent;全部被排除時退回下一位(單一 agent 也能運作) */
|
|
10
|
+
export function nextAfter(cycle, from, exclude = []) {
|
|
11
|
+
const n = cycle.length;
|
|
12
|
+
const start = from === undefined ? -1 : cycle.indexOf(from);
|
|
13
|
+
for (let i = 1; i <= n; i++) {
|
|
14
|
+
const candidate = cycle[(start + i + n) % n];
|
|
15
|
+
if (!exclude.includes(candidate))
|
|
16
|
+
return candidate;
|
|
17
|
+
}
|
|
18
|
+
return cycle[(start + 1 + n) % n];
|
|
19
|
+
}
|
|
20
|
+
export const specAgent = (cycle) => cycle[0];
|
|
21
|
+
export const planAgent = (cycle) => cycle[0];
|
|
22
|
+
/** 第 i 個任務的測試由輪替順序的第 i 位負責,實作交給下一位 */
|
|
23
|
+
export function taskAgents(cycle, taskIndex, tddSplit) {
|
|
24
|
+
const tests = cycle[taskIndex % cycle.length];
|
|
25
|
+
return { tests, code: tddSplit ? nextAfter(cycle, tests, [tests]) : tests };
|
|
26
|
+
}
|
|
27
|
+
/** 從最後寫程式的 agent 之後,依序挑出 quorum 位不重複、而且不是作者的 reviewer */
|
|
28
|
+
export function reviewers(cycle, lastWriter, quorum) {
|
|
29
|
+
const picked = [];
|
|
30
|
+
let cursor = lastWriter;
|
|
31
|
+
for (let i = 0; i < cycle.length && picked.length < quorum; i++) {
|
|
32
|
+
const next = nextAfter(cycle, cursor, [...(lastWriter ? [lastWriter] : []), ...picked]);
|
|
33
|
+
if (picked.includes(next) || (next === lastWriter && cycle.length > 1))
|
|
34
|
+
break;
|
|
35
|
+
picked.push(next);
|
|
36
|
+
cursor = next;
|
|
37
|
+
}
|
|
38
|
+
return picked.length ? picked : [nextAfter(cycle, lastWriter)];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* 誰來修正:
|
|
42
|
+
* - verify 失敗(型別、lint、建置這類機械性錯誤):交給最後寫程式的 agent
|
|
43
|
+
* - review 要求修改:ring 策略輪到 reviewer 的下一位;author 策略交回作者
|
|
44
|
+
*/
|
|
45
|
+
export function fixAgent(cycle, opts) {
|
|
46
|
+
if (opts.source === "verify" || opts.strategy === "author")
|
|
47
|
+
return opts.lastWriter ?? cycle[0];
|
|
48
|
+
return nextAfter(cycle, opts.lastReviewer, opts.lastReviewer ? [opts.lastReviewer] : []);
|
|
49
|
+
}
|
|
50
|
+
/** 計畫修正者:ring 策略輪到審查者的下一位;author 策略交回計畫作者 */
|
|
51
|
+
export function planFixAgent(cycle, opts) {
|
|
52
|
+
if (opts.strategy === "author")
|
|
53
|
+
return opts.planWriter ?? cycle[0];
|
|
54
|
+
return nextAfter(cycle, opts.planReviewer, opts.planReviewer ? [opts.planReviewer] : []);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* 仲裁小組:
|
|
58
|
+
* - 有第三方(作者與最後審查者以外的 agent)→ 由第三方單獨仲裁
|
|
59
|
+
* - 只有兩家 → 兩家各自在全新 context 中雙盲仲裁,避免讓一直反對的審查者同時當裁判
|
|
60
|
+
* - 只有一家 → 只能由它自己仲裁
|
|
61
|
+
*/
|
|
62
|
+
export function arbiterPanel(cycle, writer, reviewer) {
|
|
63
|
+
const involved = [writer, reviewer].filter((x) => Boolean(x));
|
|
64
|
+
const third = cycle.find((c) => !involved.includes(c));
|
|
65
|
+
if (third)
|
|
66
|
+
return [third];
|
|
67
|
+
return [...new Set(cycle)];
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* 代打人選:原本的 agent 可用就用它;額度用完時,沿輪替順序找下一位還有額度的 agent。
|
|
71
|
+
* 全部都用完時回傳 undefined。
|
|
72
|
+
*/
|
|
73
|
+
export function availableAgent(cycle, preferred, exhausted) {
|
|
74
|
+
if (!exhausted.includes(preferred))
|
|
75
|
+
return preferred;
|
|
76
|
+
const next = nextAfter(cycle, preferred, exhausted);
|
|
77
|
+
return exhausted.includes(next) ? undefined : next;
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=roles.js.map
|
package/dist/runner.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import { ADAPTERS, DEFAULT_CYCLE } from "./agents/index.js";
|
|
4
|
+
import { AgentDef } from "./schemas.js";
|
|
5
|
+
import { projectRoot, runDir } from "./paths.js";
|
|
6
|
+
import { exec, execShell } from "./proc.js";
|
|
7
|
+
import { tail } from "./util.js";
|
|
8
|
+
/** 會讓各家 CLI 改走 API 計費的環境變數;訂閱登入模式下執行 agent 時會移除 */
|
|
9
|
+
export const API_KEY_VARS = [
|
|
10
|
+
"ANTHROPIC_API_KEY",
|
|
11
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
12
|
+
"CODEX_API_KEY",
|
|
13
|
+
"OPENAI_API_KEY",
|
|
14
|
+
"GEMINI_API_KEY",
|
|
15
|
+
"GOOGLE_API_KEY",
|
|
16
|
+
];
|
|
17
|
+
/**
|
|
18
|
+
* 判斷 agent 失敗是否因為方案額度或速率限制。
|
|
19
|
+
* 各家的錯誤訊息會隨版本變動,這裡用寬鬆的樣式比對,且只在執行失敗時才檢查,避免誤判正常輸出。
|
|
20
|
+
*/
|
|
21
|
+
const QUOTA_PATTERNS = [
|
|
22
|
+
/usage limit/i,
|
|
23
|
+
/rate[ _-]?limit/i,
|
|
24
|
+
/quota/i,
|
|
25
|
+
/resource[_ ]exhausted/i,
|
|
26
|
+
/limit reached/i,
|
|
27
|
+
/too many requests/i,
|
|
28
|
+
/hit your (usage )?limit/i,
|
|
29
|
+
/\b429\b/,
|
|
30
|
+
];
|
|
31
|
+
export const isQuotaError = (text) => QUOTA_PATTERNS.some((p) => p.test(text));
|
|
32
|
+
function appendLog(file, text) {
|
|
33
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
34
|
+
appendFileSync(file, text.endsWith("\n") ? text : `${text}\n`);
|
|
35
|
+
}
|
|
36
|
+
/** 內建的 claude、codex、gemini 定義,可在 flow.config.json 覆寫或新增其他 agent */
|
|
37
|
+
export function resolveAgent(cfg, name) {
|
|
38
|
+
const custom = cfg.agents[name];
|
|
39
|
+
if (custom)
|
|
40
|
+
return custom;
|
|
41
|
+
if (DEFAULT_CYCLE.includes(name)) {
|
|
42
|
+
return AgentDef.parse({ adapter: name });
|
|
43
|
+
}
|
|
44
|
+
throw new Error(`未定義的 agent:${name}(請在 flow.config.json 的 agents 裡設定)`);
|
|
45
|
+
}
|
|
46
|
+
/** 確認某個 agent 的 CLI 是否可以執行 */
|
|
47
|
+
export async function probeAgent(def) {
|
|
48
|
+
const { cmd, args } = ADAPTERS[def.adapter].probe(def.command);
|
|
49
|
+
try {
|
|
50
|
+
return (await exec(cmd, args)).code === 0;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** 執行任一家的 agent CLI,把輸出正規化成同一種結果 */
|
|
57
|
+
export async function runAgent(name, def, t, prompt, opts) {
|
|
58
|
+
const adapter = ADAPTERS[def.adapter];
|
|
59
|
+
const inv = adapter.invoke({
|
|
60
|
+
prompt,
|
|
61
|
+
cwd: t.cwd,
|
|
62
|
+
model: def.model,
|
|
63
|
+
extraArgs: def.extraArgs,
|
|
64
|
+
runDir: runDir(t.runId),
|
|
65
|
+
projectRoot: projectRoot(),
|
|
66
|
+
command: def.command,
|
|
67
|
+
});
|
|
68
|
+
appendLog(t.logFile, `# agent=${name} adapter=${def.adapter} cmd=${inv.cmd}`);
|
|
69
|
+
let done;
|
|
70
|
+
let lastText = "";
|
|
71
|
+
let costUsd;
|
|
72
|
+
let inputTokens = 0;
|
|
73
|
+
let outputTokens = 0;
|
|
74
|
+
const r = await exec(inv.cmd, inv.args, {
|
|
75
|
+
cwd: t.cwd,
|
|
76
|
+
env: inv.env,
|
|
77
|
+
unsetEnv: opts.stripApiKeys ? API_KEY_VARS : [],
|
|
78
|
+
input: inv.input,
|
|
79
|
+
onStdoutLine: (line) => {
|
|
80
|
+
if (!line.trim())
|
|
81
|
+
return;
|
|
82
|
+
appendLog(t.logFile, line);
|
|
83
|
+
for (const ev of adapter.parse(line)) {
|
|
84
|
+
if (ev.kind === "text") {
|
|
85
|
+
lastText = ev.text;
|
|
86
|
+
console.log(` 💬 [${name}] ${ev.text.trim().split("\n")[0]?.slice(0, 110)}`);
|
|
87
|
+
}
|
|
88
|
+
else if (ev.kind === "tool") {
|
|
89
|
+
console.log(` 🔧 [${name}] ${ev.name}`);
|
|
90
|
+
}
|
|
91
|
+
else if (ev.kind === "usage") {
|
|
92
|
+
inputTokens += ev.inputTokens ?? 0;
|
|
93
|
+
outputTokens += ev.outputTokens ?? 0;
|
|
94
|
+
if (ev.costUsd !== undefined)
|
|
95
|
+
costUsd = (costUsd ?? 0) + ev.costUsd;
|
|
96
|
+
}
|
|
97
|
+
else if (ev.kind === "done") {
|
|
98
|
+
done = { ok: ev.ok, summary: ev.summary };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
if (r.stderr.trim())
|
|
104
|
+
appendLog(t.logFile, `[stderr]\n${r.stderr}`);
|
|
105
|
+
// CLI 沒回報花費時,用設定的價格從 token 數估算
|
|
106
|
+
if (costUsd === undefined && def.pricing) {
|
|
107
|
+
costUsd = (inputTokens * def.pricing.inputPerMTok + outputTokens * def.pricing.outputPerMTok) / 1_000_000;
|
|
108
|
+
}
|
|
109
|
+
const ok = r.code === 0 && (done?.ok ?? true);
|
|
110
|
+
const summary = done?.summary || lastText || tail(r.stdout, 2000) || tail(r.stderr, 2000);
|
|
111
|
+
const quotaExhausted = !ok && isQuotaError(`${summary}\n${done?.summary ?? ""}\n${r.stderr}\n${tail(r.stdout, 4000)}`);
|
|
112
|
+
return { ok, quotaExhausted, summary, costUsd: costUsd ?? 0, inputTokens, outputTokens };
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* 在 worktree 內執行專案指令(安裝、測試、建置)。
|
|
116
|
+
* 注意:這些指令會執行 agent 寫出來的程式碼,而且不在任何沙箱內。
|
|
117
|
+
*/
|
|
118
|
+
export async function runCommand(t, cmd) {
|
|
119
|
+
appendLog(t.logFile, `$ ${cmd}`);
|
|
120
|
+
const r = await execShell(cmd, { cwd: t.cwd });
|
|
121
|
+
const output = `${r.stdout}\n${r.stderr}`.trim();
|
|
122
|
+
appendLog(t.logFile, output);
|
|
123
|
+
return { ok: r.code === 0, output };
|
|
124
|
+
}
|
|
125
|
+
//# sourceMappingURL=runner.js.map
|
package/dist/schemas.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const Stage = z.enum([
|
|
3
|
+
"spec",
|
|
4
|
+
"plan",
|
|
5
|
+
"plan_review",
|
|
6
|
+
"plan_fix",
|
|
7
|
+
"awaiting_approval",
|
|
8
|
+
"implement",
|
|
9
|
+
"verify",
|
|
10
|
+
"fix",
|
|
11
|
+
"review",
|
|
12
|
+
"pr",
|
|
13
|
+
"paused",
|
|
14
|
+
"done",
|
|
15
|
+
"failed",
|
|
16
|
+
]);
|
|
17
|
+
/** Agent 在 spec 階段產出的 .flow/acceptance.json */
|
|
18
|
+
export const AcceptanceList = z
|
|
19
|
+
.array(z.object({
|
|
20
|
+
id: z.string().regex(/^AC-\d+$/, "id 格式必須是 AC-<數字>"),
|
|
21
|
+
description: z.string().min(1),
|
|
22
|
+
}))
|
|
23
|
+
.min(1);
|
|
24
|
+
export const TaskItem = z.object({
|
|
25
|
+
id: z.string().regex(/^T-\d+$/, "id 格式必須是 T-<數字>"),
|
|
26
|
+
title: z.string().min(1),
|
|
27
|
+
description: z.string().min(1),
|
|
28
|
+
dependsOn: z.array(z.string()).default([]),
|
|
29
|
+
acceptance: z.array(z.string()).min(1, "每個任務至少要對應一條驗收條件"),
|
|
30
|
+
});
|
|
31
|
+
/** Agent 在 plan 階段產出的 .flow/tasks.json */
|
|
32
|
+
export const TaskList = z.array(TaskItem).min(1);
|
|
33
|
+
/** Agent 在 review 階段產出的 .flow/review.json */
|
|
34
|
+
export const ReviewResult = z.object({
|
|
35
|
+
verdict: z.enum(["approve", "changes_requested"]),
|
|
36
|
+
items: z.array(z.object({
|
|
37
|
+
criterion: z.string(),
|
|
38
|
+
status: z.enum(["met", "not_met", "partial"]),
|
|
39
|
+
note: z.string().default(""),
|
|
40
|
+
})),
|
|
41
|
+
});
|
|
42
|
+
/** 一個 agent 的定義;名稱(agents 的 key)用在輪替順序裡 */
|
|
43
|
+
export const AgentDef = z.object({
|
|
44
|
+
adapter: z.enum(["claude", "codex", "gemini", "command"]),
|
|
45
|
+
model: z.string().optional(),
|
|
46
|
+
extraArgs: z.array(z.string()).default([]),
|
|
47
|
+
/** 只有 command adapter 使用,`{prompt}` 會被替換成 prompt */
|
|
48
|
+
command: z.array(z.string()).optional(),
|
|
49
|
+
/** CLI 沒有回報花費時,用 token 數估算(每百萬 token 美元) */
|
|
50
|
+
pricing: z.object({ inputPerMTok: z.number(), outputPerMTok: z.number() }).optional(),
|
|
51
|
+
});
|
|
52
|
+
/** 目標專案可選的 flow.config.json,預設值對應 Vite + TypeScript + Vitest 專案 */
|
|
53
|
+
export const RepoConfig = z.object({
|
|
54
|
+
/** 自訂或覆寫 agent;claude、codex、gemini 三個名稱內建 */
|
|
55
|
+
agents: z.record(z.string(), AgentDef).default({}),
|
|
56
|
+
/** 輪替順序;未設定時自動偵測已安裝的 CLI */
|
|
57
|
+
cycle: z.array(z.string()).min(1).optional(),
|
|
58
|
+
/** review 後的修正由誰做:ring=輪到下一位;author=最後寫程式的 agent */
|
|
59
|
+
fixStrategy: z.enum(["ring", "author"]).default("ring"),
|
|
60
|
+
/** TDD 的測試與實作交給不同的 agent */
|
|
61
|
+
tddSplit: z.boolean().default(true),
|
|
62
|
+
/** 需要幾位不同的 reviewer 都核准 */
|
|
63
|
+
reviewQuorum: z.number().int().min(1).default(1),
|
|
64
|
+
/** 計畫需要幾位不同的 reviewer 都核准 */
|
|
65
|
+
planReviewQuorum: z.number().int().min(1).default(1),
|
|
66
|
+
/** 計畫審查僵持不下(達到重試上限或意見不再變化)時,交給第三方 agent 仲裁,而不是停下來等人 */
|
|
67
|
+
planArbiter: z.boolean().default(true),
|
|
68
|
+
/**
|
|
69
|
+
* 仲裁意見分歧時怎麼辦(只有兩家時由雙方各自仲裁,才可能分歧):
|
|
70
|
+
* proceed=繼續實作,爭議記錄在計畫裡,後面還有測試、驗證與程式碼審查把關;stop=停下來等人
|
|
71
|
+
*/
|
|
72
|
+
tieBreak: z.enum(["proceed", "stop"]).default("proceed"),
|
|
73
|
+
/**
|
|
74
|
+
* subscription:使用各家 CLI 的訂閱登入,執行 agent 時會移除環境中的 API key,避免意外改走 API 計費;
|
|
75
|
+
* api:保留 API key(例如在 CI 中)
|
|
76
|
+
*/
|
|
77
|
+
auth: z.enum(["subscription", "api"]).default("subscription"),
|
|
78
|
+
/** 單一 run 最多執行幾次 agent;訂閱制下用這個取代金額預算 */
|
|
79
|
+
maxAgentRuns: z.number().int().positive().default(60),
|
|
80
|
+
install: z.string().default("npm install --no-audit --no-fund"),
|
|
81
|
+
test: z.string().default("npx vitest run"),
|
|
82
|
+
testPattern: z.string().default("\\.(test|spec)\\.[cm]?[jt]sx?$"),
|
|
83
|
+
checks: z
|
|
84
|
+
.array(z.object({ name: z.string(), cmd: z.string() }))
|
|
85
|
+
.default([
|
|
86
|
+
{ name: "typecheck", cmd: "npx tsc --noEmit" },
|
|
87
|
+
{ name: "lint", cmd: "npx eslint ." },
|
|
88
|
+
{ name: "test", cmd: "npx vitest run" },
|
|
89
|
+
{ name: "build", cmd: "npx vite build" },
|
|
90
|
+
]),
|
|
91
|
+
});
|
|
92
|
+
export const FlowRun = z.object({
|
|
93
|
+
id: z.string(),
|
|
94
|
+
baseBranch: z.string(),
|
|
95
|
+
branch: z.string(),
|
|
96
|
+
requirement: z.string(),
|
|
97
|
+
stage: Stage,
|
|
98
|
+
autopilot: z.boolean(),
|
|
99
|
+
/** 選用:以估計花費(美元)為上限;訂閱登入時通常不設定 */
|
|
100
|
+
budgetUsd: z.number().positive().optional(),
|
|
101
|
+
/** 單一 run 最多執行幾次 agent */
|
|
102
|
+
maxAgentRuns: z.number().int().positive(),
|
|
103
|
+
/** 暫停前所在的階段與原因(額度用完時) */
|
|
104
|
+
pausedStage: Stage.optional(),
|
|
105
|
+
pauseReason: z.string().optional(),
|
|
106
|
+
/** 這個 run 的 agent 輪替順序(建立時決定,resume 時沿用) */
|
|
107
|
+
cycle: z.array(z.string()).min(1),
|
|
108
|
+
/** 最後一個寫程式的 agent,reviewer 不可以是它 */
|
|
109
|
+
lastWriter: z.string().optional(),
|
|
110
|
+
/** 最後一個要求修改的 reviewer */
|
|
111
|
+
lastReviewer: z.string().optional(),
|
|
112
|
+
/** 最後一個撰寫或修改規格與計畫的 agent */
|
|
113
|
+
planWriter: z.string().optional(),
|
|
114
|
+
/** 最後一個對計畫要求修改的 reviewer */
|
|
115
|
+
planReviewer: z.string().optional(),
|
|
116
|
+
/** 目前的 fix 是因為 verify 失敗還是 review 要求修改 */
|
|
117
|
+
fixSource: z.enum(["verify", "review"]).optional(),
|
|
118
|
+
/** 各關卡的連續失敗次數 */
|
|
119
|
+
attempts: z.record(z.string(), z.number()),
|
|
120
|
+
taskIndex: z.number().int().nonnegative(),
|
|
121
|
+
taskPhase: z.enum(["tests", "code"]),
|
|
122
|
+
testsCommit: z.string().optional(),
|
|
123
|
+
/** 目前任務的測試實際由誰撰寫(可能是代打) */
|
|
124
|
+
lastTestsAuthor: z.string().optional(),
|
|
125
|
+
failedStage: Stage.optional(),
|
|
126
|
+
failureReason: z.string().optional(),
|
|
127
|
+
prUrl: z.string().optional(),
|
|
128
|
+
createdAt: z.string(),
|
|
129
|
+
updatedAt: z.string(),
|
|
130
|
+
});
|
|
131
|
+
//# sourceMappingURL=schemas.js.map
|
package/dist/store.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { agentflowctlDir, runDir } from "./paths.js";
|
|
4
|
+
import { FlowRun } from "./schemas.js";
|
|
5
|
+
const statePath = (id) => join(runDir(id), "state.json");
|
|
6
|
+
const costPath = (id) => join(runDir(id), "costs.jsonl");
|
|
7
|
+
/** 先寫暫存檔再 rename,確保 state.json 不會因中斷而只寫一半 */
|
|
8
|
+
function writeAtomic(path, content) {
|
|
9
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
10
|
+
const tmp = `${path}.tmp`;
|
|
11
|
+
writeFileSync(tmp, content);
|
|
12
|
+
renameSync(tmp, path);
|
|
13
|
+
}
|
|
14
|
+
export function saveRun(run) {
|
|
15
|
+
const next = FlowRun.parse({ ...run, updatedAt: new Date().toISOString() });
|
|
16
|
+
writeAtomic(statePath(next.id), JSON.stringify(next, null, 2));
|
|
17
|
+
return next;
|
|
18
|
+
}
|
|
19
|
+
export function getRun(id) {
|
|
20
|
+
const p = statePath(id);
|
|
21
|
+
return existsSync(p) ? FlowRun.parse(JSON.parse(readFileSync(p, "utf8"))) : undefined;
|
|
22
|
+
}
|
|
23
|
+
export function listRuns() {
|
|
24
|
+
const dir = join(agentflowctlDir(), "runs");
|
|
25
|
+
if (!existsSync(dir))
|
|
26
|
+
return [];
|
|
27
|
+
return readdirSync(dir)
|
|
28
|
+
.map(getRun)
|
|
29
|
+
.filter((r) => r !== undefined)
|
|
30
|
+
.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
31
|
+
}
|
|
32
|
+
export function addCost(id, entry) {
|
|
33
|
+
mkdirSync(runDir(id), { recursive: true });
|
|
34
|
+
appendFileSync(costPath(id), `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`);
|
|
35
|
+
}
|
|
36
|
+
/** 依 agent 加總花費與 token,方便比較各家模型 */
|
|
37
|
+
export function costByAgent(id) {
|
|
38
|
+
const p = costPath(id);
|
|
39
|
+
const out = {};
|
|
40
|
+
if (!existsSync(p))
|
|
41
|
+
return out;
|
|
42
|
+
for (const line of readFileSync(p, "utf8").split("\n").filter(Boolean)) {
|
|
43
|
+
const e = JSON.parse(line);
|
|
44
|
+
const key = e.agent ?? "?";
|
|
45
|
+
const acc = (out[key] ??= { usd: 0, tokens: 0, runs: 0 });
|
|
46
|
+
acc.usd += e.usd ?? 0;
|
|
47
|
+
acc.tokens += (e.inputTokens ?? 0) + (e.outputTokens ?? 0);
|
|
48
|
+
acc.runs += 1;
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
export function getCost(id) {
|
|
53
|
+
const p = costPath(id);
|
|
54
|
+
if (!existsSync(p))
|
|
55
|
+
return 0;
|
|
56
|
+
return readFileSync(p, "utf8")
|
|
57
|
+
.split("\n")
|
|
58
|
+
.filter(Boolean)
|
|
59
|
+
.reduce((sum, line) => sum + (JSON.parse(line).usd ?? 0), 0);
|
|
60
|
+
}
|
|
61
|
+
/** 這個 run 已執行 agent 的次數(每次執行都會記一筆花費,即使是 0) */
|
|
62
|
+
export function agentRuns(id) {
|
|
63
|
+
const p = costPath(id);
|
|
64
|
+
return existsSync(p) ? readFileSync(p, "utf8").split("\n").filter(Boolean).length : 0;
|
|
65
|
+
}
|
|
66
|
+
const subPath = (id) => join(runDir(id), "substitutions.jsonl");
|
|
67
|
+
export function addSubstitution(id, s) {
|
|
68
|
+
mkdirSync(runDir(id), { recursive: true });
|
|
69
|
+
appendFileSync(subPath(id), `${JSON.stringify({ at: new Date().toISOString(), ...s })}\n`);
|
|
70
|
+
}
|
|
71
|
+
export function listSubstitutions(id) {
|
|
72
|
+
const p = subPath(id);
|
|
73
|
+
if (!existsSync(p))
|
|
74
|
+
return [];
|
|
75
|
+
return readFileSync(p, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=store.js.map
|
package/dist/tasks.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 檢查任務清單並依相依關係排序(Kahn 演算法)。
|
|
3
|
+
* 回傳排序後的任務,或回傳一段可以直接回饋給 Agent 的錯誤說明。
|
|
4
|
+
*/
|
|
5
|
+
export function orderTasks(tasks, acceptanceIds) {
|
|
6
|
+
const errors = [];
|
|
7
|
+
const ids = new Set();
|
|
8
|
+
for (const t of tasks) {
|
|
9
|
+
if (ids.has(t.id))
|
|
10
|
+
errors.push(`任務 id 重複:${t.id}`);
|
|
11
|
+
ids.add(t.id);
|
|
12
|
+
}
|
|
13
|
+
const covered = new Set();
|
|
14
|
+
for (const t of tasks) {
|
|
15
|
+
for (const d of t.dependsOn)
|
|
16
|
+
if (!ids.has(d))
|
|
17
|
+
errors.push(`${t.id} 相依的 ${d} 不存在`);
|
|
18
|
+
for (const a of t.acceptance) {
|
|
19
|
+
if (!acceptanceIds.has(a))
|
|
20
|
+
errors.push(`${t.id} 對應的驗收條件 ${a} 不存在`);
|
|
21
|
+
covered.add(a);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
for (const a of acceptanceIds)
|
|
25
|
+
if (!covered.has(a))
|
|
26
|
+
errors.push(`驗收條件 ${a} 沒有任何任務負責`);
|
|
27
|
+
if (errors.length)
|
|
28
|
+
return errors.join("\n");
|
|
29
|
+
const indegree = new Map(tasks.map((t) => [t.id, t.dependsOn.length]));
|
|
30
|
+
const byId = new Map(tasks.map((t) => [t.id, t]));
|
|
31
|
+
const queue = tasks.filter((t) => t.dependsOn.length === 0).map((t) => t.id);
|
|
32
|
+
const ordered = [];
|
|
33
|
+
while (queue.length) {
|
|
34
|
+
const id = queue.shift();
|
|
35
|
+
ordered.push(byId.get(id));
|
|
36
|
+
for (const t of tasks) {
|
|
37
|
+
if (!t.dependsOn.includes(id))
|
|
38
|
+
continue;
|
|
39
|
+
const n = (indegree.get(t.id) ?? 0) - 1;
|
|
40
|
+
indegree.set(t.id, n);
|
|
41
|
+
if (n === 0)
|
|
42
|
+
queue.push(t.id);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (ordered.length !== tasks.length) {
|
|
46
|
+
const stuck = tasks.filter((t) => !ordered.includes(t)).map((t) => t.id);
|
|
47
|
+
return `任務之間有循環相依:${stuck.join(", ")}`;
|
|
48
|
+
}
|
|
49
|
+
return ordered;
|
|
50
|
+
}
|
|
51
|
+
//# sourceMappingURL=tasks.js.map
|
package/dist/util.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
export function tail(s, max = 4000) {
|
|
4
|
+
return s.length <= max ? s : `…(前略)\n${s.slice(-max)}`;
|
|
5
|
+
}
|
|
6
|
+
const PROMPTS_DIR = new URL("../prompts/", import.meta.url);
|
|
7
|
+
/** 讀取 prompts/<name>.md 並代入 {{變數}} */
|
|
8
|
+
export function renderPrompt(name, vars = {}) {
|
|
9
|
+
const tpl = readFileSync(new URL(`${name}.md`, PROMPTS_DIR), "utf8");
|
|
10
|
+
return tpl.replace(/\{\{(\w+)\}\}/g, (_, key) => {
|
|
11
|
+
const value = vars[key];
|
|
12
|
+
if (value === undefined)
|
|
13
|
+
throw new Error(`prompt「${name}」缺少變數 ${key}`);
|
|
14
|
+
return value;
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
/** 讀取 Agent 產出的 JSON 檔並用 zod 驗證,錯誤訊息會直接回饋給 Agent */
|
|
18
|
+
export function readJsonFile(path, schema) {
|
|
19
|
+
if (!existsSync(path))
|
|
20
|
+
return { ok: false, error: `找不到 ${path}` };
|
|
21
|
+
let raw;
|
|
22
|
+
try {
|
|
23
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
return { ok: false, error: `${path} 不是合法的 JSON:${e.message}` };
|
|
27
|
+
}
|
|
28
|
+
const parsed = schema.safeParse(raw);
|
|
29
|
+
return parsed.success
|
|
30
|
+
? { ok: true, data: parsed.data }
|
|
31
|
+
: { ok: false, error: `${path} 格式錯誤:\n${z.prettifyError(parsed.error)}` };
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=util.js.map
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"cycle": ["claude", "codex"],
|
|
3
|
+
"fixStrategy": "ring",
|
|
4
|
+
"tddSplit": true,
|
|
5
|
+
"reviewQuorum": 1,
|
|
6
|
+
"planReviewQuorum": 1,
|
|
7
|
+
"planArbiter": true,
|
|
8
|
+
"tieBreak": "proceed",
|
|
9
|
+
"auth": "subscription",
|
|
10
|
+
"maxAgentRuns": 60,
|
|
11
|
+
"agents": {
|
|
12
|
+
"claude": { "adapter": "claude" },
|
|
13
|
+
"codex": {
|
|
14
|
+
"adapter": "codex",
|
|
15
|
+
"pricing": { "inputPerMTok": 1.25, "outputPerMTok": 10 }
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"install": "pnpm ci",
|
|
19
|
+
"test": "npx vitest run",
|
|
20
|
+
"checks": [
|
|
21
|
+
{ "name": "typecheck", "cmd": "npx tsc --noEmit" },
|
|
22
|
+
{ "name": "lint", "cmd": "npx eslint ." },
|
|
23
|
+
{ "name": "test", "cmd": "npx vitest run" },
|
|
24
|
+
{ "name": "build", "cmd": "npx vite build" }
|
|
25
|
+
]
|
|
26
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# 在 issue 加上 flow 標籤時,由 claude 與 codex 輪流完成並開 PR。
|
|
2
|
+
# 放到 .github/workflows/flow.yml;在 repo secrets 設定兩家的 API key。
|
|
3
|
+
# 注意:CI 無法使用訂閱登入,這個範例會產生 API 費用,
|
|
4
|
+
# 專案的 flow.config.json 需設定 "auth": "api",否則 agentflowctl 會移除這些 API key。
|
|
5
|
+
name: flow
|
|
6
|
+
on:
|
|
7
|
+
issues:
|
|
8
|
+
types: [labeled]
|
|
9
|
+
|
|
10
|
+
permissions:
|
|
11
|
+
contents: write
|
|
12
|
+
pull-requests: write
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
flow:
|
|
16
|
+
if: github.event.label.name == 'flow'
|
|
17
|
+
runs-on: ubuntu-latest
|
|
18
|
+
timeout-minutes: 90
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v4
|
|
21
|
+
with:
|
|
22
|
+
fetch-depth: 0
|
|
23
|
+
- uses: actions/setup-node@v4
|
|
24
|
+
with:
|
|
25
|
+
node-version: 22
|
|
26
|
+
- name: 安裝 agent CLI 與 agentflowctl
|
|
27
|
+
run: |
|
|
28
|
+
npm install -g @anthropic-ai/claude-code @openai/codex
|
|
29
|
+
npm install -g agentflowctl
|
|
30
|
+
- name: 執行 flow
|
|
31
|
+
env:
|
|
32
|
+
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
33
|
+
CODEX_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
|
34
|
+
GH_TOKEN: ${{ github.token }}
|
|
35
|
+
ISSUE_BODY: ${{ github.event.issue.body }}
|
|
36
|
+
ISSUE_TITLE: ${{ github.event.issue.title }}
|
|
37
|
+
run: |
|
|
38
|
+
git config user.name "agentflowctl"
|
|
39
|
+
git config user.email "agentflowctl@users.noreply.github.com"
|
|
40
|
+
printf '%s\n\n%s\n' "$ISSUE_TITLE" "$ISSUE_BODY" > /tmp/req.md
|
|
41
|
+
agentflowctl run --req-file /tmp/req.md --budget 10 --cycle claude,codex
|
|
42
|
+
- name: 保存紀錄
|
|
43
|
+
if: always()
|
|
44
|
+
uses: actions/upload-artifact@v4
|
|
45
|
+
with:
|
|
46
|
+
name: agentflowctl-runs
|
|
47
|
+
path: .agentflowctl/runs/
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agentflowctl",
|
|
3
|
+
"license": "MIT",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "跨廠商 AI 開發 harness:Claude Code、Codex、Gemini 輪流實作、審查、修正",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"ai",
|
|
8
|
+
"agent",
|
|
9
|
+
"claude-code",
|
|
10
|
+
"codex",
|
|
11
|
+
"gemini",
|
|
12
|
+
"code-review",
|
|
13
|
+
"tdd",
|
|
14
|
+
"cli"
|
|
15
|
+
],
|
|
16
|
+
"author": "Jin-Ze Huang",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/gogogohuang/agentflowctl.git"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/gogogohuang/agentflowctl#readme",
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/gogogohuang/agentflowctl/issues"
|
|
24
|
+
},
|
|
25
|
+
"type": "module",
|
|
26
|
+
"bin": {
|
|
27
|
+
"agentflowctl": "dist/cli.js"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"!dist/**/*.map",
|
|
32
|
+
"prompts",
|
|
33
|
+
"examples"
|
|
34
|
+
],
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=22"
|
|
37
|
+
},
|
|
38
|
+
"packageManager": "pnpm@9.11.0",
|
|
39
|
+
"scripts": {
|
|
40
|
+
"dev": "tsx src/cli.ts",
|
|
41
|
+
"build": "tsc",
|
|
42
|
+
"typecheck": "tsc --noEmit",
|
|
43
|
+
"test": "vitest run",
|
|
44
|
+
"prepublishOnly": "pnpm run typecheck && pnpm test && pnpm run build"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"commander": "^12.1.0",
|
|
48
|
+
"zod": "^4.0.0"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/node": "^22.10.0",
|
|
52
|
+
"tsx": "^4.19.0",
|
|
53
|
+
"typescript": "^5.7.0",
|
|
54
|
+
"vitest": "^3.0.0"
|
|
55
|
+
}
|
|
56
|
+
}
|
package/prompts/fix.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
你是資深工程師,這個階段負責修正驗證或程式碼審查發現的問題。目前的工作目錄就是專案(agentflowctl 為這次任務建立的專用 git worktree)。
|
|
2
|
+
|
|
3
|
+
## 要修正的問題
|
|
4
|
+
|
|
5
|
+
請先閱讀 .flow/feedback.md,裡面列出了失敗的檢查(型別、lint、測試、建置)或審查意見。
|
|
6
|
+
規格請參考 .flow/spec.md 與 .flow/acceptance.json。
|
|
7
|
+
|
|
8
|
+
## 工作步驟
|
|
9
|
+
|
|
10
|
+
1. 找出每個問題的根本原因再修正,不要只針對症狀打補丁。
|
|
11
|
+
2. 在沙箱內自行執行相關檢查,確認問題已解決且沒有造成新的錯誤。
|
|
12
|
+
|
|
13
|
+
## 限制
|
|
14
|
+
|
|
15
|
+
- 不可刪除測試檔(檔名符合 `{{testPattern}}`),也不可用 skip、放寬斷言、`@ts-ignore`、`eslint-disable` 等方式讓檢查通過。
|
|
16
|
+
- 如果測試本身確實有誤,可以修正測試,但必須在回覆中說明理由。
|
|
17
|
+
- 不要執行 git commit(權限設定已禁止)。
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
你是資深工程師,正在用 TDD 開發。測試已經寫好並提交,這個階段要**實作到測試通過**。目前的工作目錄就是專案(agentflowctl 為這次任務建立的專用 git worktree)。
|
|
2
|
+
|
|
3
|
+
## 目前任務
|
|
4
|
+
|
|
5
|
+
```json
|
|
6
|
+
{{task}}
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
完整規格與計畫請參考 .flow/spec.md、.flow/plan.md。
|
|
10
|
+
|
|
11
|
+
## 目前失敗的測試輸出
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
{{redOutput}}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## 工作步驟
|
|
18
|
+
|
|
19
|
+
1. 若 .flow/feedback.md 存在,先閱讀,並依內容修正。
|
|
20
|
+
2. 撰寫讓測試通過的最小實作,符合專案既有的程式風格與架構。
|
|
21
|
+
3. 執行 `{{testCmd}}` 確認全部測試通過(包含既有測試)。
|
|
22
|
+
4. 測試通過後,在不改變行為的前提下整理程式碼。
|
|
23
|
+
|
|
24
|
+
## 限制
|
|
25
|
+
|
|
26
|
+
- **不可修改任何測試檔**,修改會被自動還原並視為失敗。若認為測試本身有誤,請在回覆中說明原因。
|
|
27
|
+
- 不要執行 git commit(權限設定已禁止)。
|