agentflowctl 0.11.0 → 0.13.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 CHANGED
@@ -46,11 +46,14 @@ agentflowctl list # 列出 run
46
46
  agentflowctl status f-xxxx # 看進度、結果與下一步
47
47
  agentflowctl logs f-xxxx # 列出各步驟的 log
48
48
  agentflowctl logs f-xxxx --latest # 看最新一份 log
49
+ agentflowctl stats f-xxxx # 各步驟耗時、執行與失敗次數
49
50
  agentflowctl resume f-xxxx # 從暫停、中斷或失敗處接續
50
51
  ```
51
52
 
52
53
  `status` 會列出目前階段、未結的交接事項與下一步指令;失敗或暫停時也會顯示原因。要看某一步的詳細輸出,可用 `logs <id> <編號>`;加 `--full` 看完整工具內容,或加 `--raw` 看原始輸出。
53
54
 
55
+ `stats` 依 log 的開始與結束時間統計每個步驟的執行次數、失敗次數、總耗時與最長一次,並分開列出 agent 與專案指令(install、測試、checks)各占多少時間,最耗時的步驟排在最前面。沒有結束紀錄的 log 列為未完成,不計入耗時;總經過時間包含暫停與等待核准。
56
+
54
57
  執行紀錄在 `.agentflowctl/runs/<id>/`,工作分支在 `.agentflowctl/worktrees/<id>/`。不再需要某次 run 時,可用 `agentflowctl clean <id>` 清除 worktree 與紀錄;`flow/<id>` 分支會保留。
55
58
 
56
59
  ## 執行停下來時怎麼做
@@ -87,6 +90,7 @@ agentflowctl resume f-xxxx
87
90
  | `run --req "..."` / `--req-file <檔案>` | 二選一,直接輸入需求或讀取檔案 |
88
91
  | `run --manual-plan` | 計畫通過審查後等待你確認,再用 `approve <id>` 繼續 |
89
92
  | `run --cycle <名單>` | 指定這次參與的 agent,例如 `--cycle claude,codex`;優先於設定檔的 `cycle` |
93
+ | `run --model-mode balanced\|adaptive` | 只覆蓋這次 run 的模型模式;`resume` 沿用建立時的模式 |
90
94
  | `run --base <分支>` | 指定起始分支;未設定時使用目前分支 |
91
95
  | `run --max-agent-runs <次數>` | 覆蓋這次的 `maxAgentRuns`;上限不夠時可用 `resume <id> --max-agent-runs <次數>` 調高 |
92
96
  | `-v` / `--verbose` | 執行時顯示 agent 文字、工具呼叫與專案指令,適用於 `run`、`resume`、`approve` |
@@ -112,6 +116,28 @@ agentflowctl agent cycle claude,codex
112
116
 
113
117
  `agent add` 的 `--adapter` 可填 `claude`、`codex`、`gemini` 或 `command`。`--model` 指定個別 agent 的模型;`--extra-arg=--參數` 可重複使用,傳給該 CLI。使用 `command` adapter 時,把指令寫在 `--` 後,例如 `agentflowctl agent add aider --adapter command -- aider --message {prompt}`。`agent remove <名稱>` 會移除設定與參與名單;`agent cycle` 不帶名單則顯示目前參與者。
114
118
 
119
+ `model add/set/remove` 只修改指定 agent 的模型清單;`model remove` 移除最後一個模型時,會檢查參與的 agent 是否仍有模型,不論目前使用哪種模型模式。同一 adapter 的 `agent set` 會保留清單;換 adapter 時會清掉舊 adapter 的模型設定。`agent setup` 遇到同名 agent 會先詢問是否覆寫。
120
+
121
+ ### 依階段與任務難度選模型
122
+
123
+ 預設是 `balanced`:沿用每個 agent 的 `model`、`defaultModels` 或 CLI 預設。想啟用自動選模,先為**每個參與的 agent** 登記可用模型與強度,再切換模式:
124
+
125
+ ```bash
126
+ agentflowctl model add claude MODEL_NAME --strength low
127
+ agentflowctl model add claude ANOTHER_MODEL --strength high
128
+ agentflowctl model list
129
+ agentflowctl model mode adaptive
130
+ agentflowctl run --req-file ./requirement.md
131
+ ```
132
+
133
+ 把 `MODEL_NAME` 換成該 CLI 目前可呼叫的別名或完整 ID。`model add` 會用目前登入的帳號送出短請求,可能耗用少量 token;成功才寫入設定。需要重驗時執行 `model check`。用 `model set claude MODEL_NAME --strength medium` 改強度、`model remove claude MODEL_NAME` 移除模型,或用 `model stage taskReview high` 調整階段最低強度;`model stage` 不帶強度時列出各階段實際生效的強度。終端機每次呼叫會顯示送給 CLI 的模型名稱,Claude Code 與 Gemini CLI 回報的實際模型不同時也會顯示;`status <id>` 會按階段、任務、模型與步驟顯示用量。`run --model-mode balanced` 可暫時回到原設定。
134
+
135
+ `status <id>` 的用量以每次 LLM 呼叫為一筆,失敗、額度用完及代打也會計入呼叫次數。只有 CLI 同時回報輸入與輸出 token,才把兩者納入合計與模型強度占比;明確回報的 0 仍算已回報。缺少任一數字列為「未回報」;舊紀錄無法分辨真實 0 與預設補值,列為「舊紀錄不明」,原始數字只供查閱。各 agent、階段、任務、模型與步驟、模型強度是同一批呼叫的不同分組,不應跨組相加。輸入 token 一律包含 cache 讀取與寫入:Claude Code 回報的 `input_tokens` 不含 cache,agentflowctl 會把 cache 讀寫加回去;Codex 的 `input_tokens` 本來就包含 cache。有回報 cache 時,`status` 與 `logs` 會另外標出其中讀取與寫入 cache 各多少。`model add/check` 的探測請求可能耗用 token,但不屬於 run,因此不在 `status` 內。
136
+
137
+ 計畫 agent 會查閱相關程式碼,依影響範圍、技術不確定性與失敗後果為每個任務標註 `low`/`medium`/`high` 難度,取三者中最高等級,並在計畫中寫出依據;計畫審查會逐項核對。自動選模先遵守角色分配,再取階段強度與任務難度中較高者;失敗重試會提高強度。若分配到的 agent 沒有足夠強度的模型,會選它最強的模型並提示。這些強度是你對模型能力的設定,不由 CLI 自動評分。
138
+
139
+ 模型探測必須能禁止工具。這版 Codex CLI 沒有可確認的無工具探測參數,因此 `model add` 無法驗證並登記 Codex 模型;手動寫入 `models` 仍可執行,但不代表已驗證可用。`balanced` 仍可照原方式使用。自訂 `command` adapter 另需提供會實際呼叫模型的探測命令;設定方式與限制見[詳細參考](docs/reference.md#模型設定與自動選模)。
140
+
115
141
  ### 專案設定
116
142
 
117
143
  你也可以直接編輯 `flow.config.json`。這是可用的最小範例;沒有寫的欄位會使用預設值:
@@ -132,6 +158,8 @@ agentflowctl agent cycle claude,codex
132
158
  | `agents` | `{}` | 以名稱為 key 定義 agent;每個都要有 `adapter`,可加 `model`、`extraArgs`;`command` adapter 另需 `command` 指令陣列 |
133
159
  | `cycle` | 自動偵測 | 填 agent 名稱陣列,例如 `["claude", "codex"]`;未填時使用已設定且可執行的 agent;順序不決定角色 |
134
160
  | `defaultModels` | `{}` | 依 adapter 設預設模型,例如 `{ "claude": "模型名稱" }`;個別 agent 的 `model` 優先 |
161
+ | `modelSelection` | `balanced` | `mode` 可為 `balanced` 或 `adaptive`;`stageStrength` 可覆蓋各 LLM 階段強度 |
162
+ | `agents.<名稱>.models` | 無 | 自動選模時使用;每筆有 `name` 與 `strength`,建議用 `model add` 設定並實際驗證 |
135
163
  | `fixStrategy` | `"ring"` | `"ring"` 由審查者以外的 agent 修正;`"author"` 交回最後作者 |
136
164
  | `tddSplit` | `true` | 有多位 agent 時,`true` 會把同一任務的測試與實作分給不同 agent |
137
165
  | `reviewQuorum` | `1` | 任務與最終程式碼審查需要幾位不同審查者核准 |
@@ -153,7 +181,7 @@ agentflowctl agent cycle claude,codex
153
181
  | `AGENTFLOWCTL_VERBOSE` | 未開啟 | 設為 `1` 顯示詳細輸出,效果同 `-v` |
154
182
  | `AGENTFLOWCTL_MAX_TURNS` | `80` | 目前程式會讀取此值,但尚未用它限制 agent 執行 |
155
183
 
156
- 環境變數對新啟動的 agentflowctl 程序生效。`AGENTFLOWCTL_MAX_ATTEMPTS` 是單一關卡的重試上限;`maxAgentRuns` 則是整次 run 的 agent 執行次數上限。
184
+ 環境變數對新啟動的 agentflowctl 程序生效。`AGENTFLOWCTL_MAX_ATTEMPTS` 是單一關卡的重試上限;`maxAgentRuns` 則是整次 run 的 agent 執行次數上限。修正成功、或計畫審查與程式碼審查整組完成一輪有效審查後,該關的失敗次數會歸零,所以上限只計算連續失敗。
157
185
 
158
186
  ## 更多文件
159
187
 
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { z } from "zod";
3
3
  import { AgentDef, RepoConfig } from "./schemas.js";
4
4
  /** 這些欄位的意義取決於 adapter,換 adapter 時要清掉 */
5
- const ADAPTER_FIELDS = ["model", "extraArgs", "command"];
5
+ const ADAPTER_FIELDS = ["model", "models", "modelProbe", "extraArgs", "command"];
6
6
  const agentsOf = (cfg) => ({ ...(cfg.agents ?? {}) });
7
7
  const isDefined = (cfg, name) => name in agentsOf(cfg);
8
8
  /** 只留下有值的欄位,驗證後回傳 */
@@ -33,13 +33,14 @@ export function setAgent(cfg, name, patch) {
33
33
  if (!isDefined(cfg, name))
34
34
  throw new Error(`未定義的 agent:${name}`);
35
35
  if (Object.values(patch).every((v) => v === undefined)) {
36
- throw new Error("沒有要修改的欄位(--adapter、--model、--extra-arg 或 -- <command>)");
36
+ throw new Error("沒有要修改的欄位(--adapter、--model、--model-probe-arg、--extra-arg 或 -- <command>)");
37
37
  }
38
38
  const agents = agentsOf(cfg);
39
39
  const base = { ...agents[name] };
40
40
  const changes = [];
41
41
  if (patch.adapter !== undefined && patch.adapter !== base.adapter) {
42
- const cleared = ADAPTER_FIELDS.filter((f) => base[f] !== undefined && patch[f] === undefined);
42
+ const given = patch;
43
+ const cleared = ADAPTER_FIELDS.filter((f) => base[f] !== undefined && given[f] === undefined);
43
44
  for (const f of cleared)
44
45
  delete base[f];
45
46
  if (cleared.length)
@@ -24,6 +24,11 @@ function settingsFile(o) {
24
24
  }
25
25
  export const claude = {
26
26
  probe: () => ({ cmd: "claude", args: ["--version"] }),
27
+ invokeModelProbe: (model) => ({
28
+ cmd: "claude",
29
+ args: ["-p", "只回答 OK", "--model", model, "--output-format", "stream-json", "--verbose",
30
+ "--tools", "", "--strict-mcp-config", "--disable-slash-commands"],
31
+ }),
27
32
  invoke: (o) => ({
28
33
  cmd: "claude",
29
34
  args: [
@@ -40,7 +45,10 @@ export const claude = {
40
45
  if (!ev)
41
46
  return [];
42
47
  const out = [];
43
- if (ev.type === "assistant") {
48
+ if (ev.type === "system" && ev.subtype === "init" && str(ev.model)) {
49
+ out.push({ kind: "model", id: str(ev.model) });
50
+ }
51
+ else if (ev.type === "assistant") {
44
52
  const content = ev.message?.content ?? [];
45
53
  for (const b of content) {
46
54
  if (b.type === "text" && str(b.text)?.trim())
@@ -51,11 +59,19 @@ export const claude = {
51
59
  }
52
60
  else if (ev.type === "result") {
53
61
  const usage = (ev.usage ?? {});
54
- out.push({
55
- kind: "usage",
56
- inputTokens: num(usage.input_tokens),
57
- outputTokens: num(usage.output_tokens),
58
- });
62
+ // Claude 的 input_tokens 不含 cache,要加回來才是實際送入量
63
+ const input = num(usage.input_tokens);
64
+ const cacheRead = num(usage.cache_read_input_tokens);
65
+ const cacheWrite = num(usage.cache_creation_input_tokens);
66
+ if (input !== undefined || num(usage.output_tokens) !== undefined) {
67
+ out.push({
68
+ kind: "usage",
69
+ inputTokens: input === undefined ? undefined : input + (cacheRead ?? 0) + (cacheWrite ?? 0),
70
+ outputTokens: num(usage.output_tokens),
71
+ ...(cacheRead !== undefined && { cacheReadTokens: cacheRead }),
72
+ ...(cacheWrite !== undefined && { cacheWriteTokens: cacheWrite }),
73
+ });
74
+ }
59
75
  out.push({ kind: "done", ok: ev.is_error !== true, summary: str(ev.result) });
60
76
  }
61
77
  return out;
@@ -30,7 +30,11 @@ export const codex = {
30
30
  }
31
31
  else if (ev.type === "turn.completed") {
32
32
  const usage = (ev.usage ?? {});
33
- out.push({ kind: "usage", inputTokens: num(usage.input_tokens), outputTokens: num(usage.output_tokens) });
33
+ if (num(usage.input_tokens) !== undefined || num(usage.output_tokens) !== undefined) {
34
+ const cacheRead = num(usage.cached_input_tokens);
35
+ out.push({ kind: "usage", inputTokens: num(usage.input_tokens), outputTokens: num(usage.output_tokens),
36
+ ...(cacheRead !== undefined && { cacheReadTokens: cacheRead }) });
37
+ }
34
38
  }
35
39
  else if (ev.type === "turn.failed" || ev.type === "error") {
36
40
  const err = (ev.error ?? {});
@@ -10,9 +10,11 @@ export const command = {
10
10
  if (!cmd)
11
11
  throw new Error("command adapter 需要設定 command 陣列");
12
12
  const hasPlaceholder = rest.some((a) => a.includes("{prompt}"));
13
+ if (o.command?.some((a) => a.includes("{model}")) && !o.model)
14
+ throw new Error("command 含 {model},但沒有設定 model");
13
15
  return {
14
- cmd,
15
- args: [...rest.map((a) => a.replaceAll("{prompt}", o.prompt)), ...o.extraArgs],
16
+ cmd: cmd.replaceAll("{model}", o.model ?? ""),
17
+ args: [...rest.map((a) => a.replaceAll("{prompt}", o.prompt).replaceAll("{model}", o.model ?? "")), ...o.extraArgs],
16
18
  input: hasPlaceholder ? undefined : o.prompt,
17
19
  };
18
20
  },
@@ -1,3 +1,5 @@
1
+ import { writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
1
3
  import { num, str, toolDetail, tryJson } from "./types.js";
2
4
  /**
3
5
  * Google Gemini CLI:`gemini -p ... --output-format stream-json`。
@@ -7,6 +9,13 @@ import { num, str, toolDetail, tryJson } from "./types.js";
7
9
  */
8
10
  export const gemini = {
9
11
  probe: () => ({ cmd: "gemini", args: ["--version"] }),
12
+ invokeModelProbe: (model, cwd) => {
13
+ const policy = join(cwd, "deny-tools.toml");
14
+ writeFileSync(policy, '[[rule]]\ntoolName = "*"\ndecision = "deny"\npriority = 10000\n');
15
+ return { cmd: "gemini", args: ["-p", "只回答 OK", "--model", model, "--output-format", "stream-json",
16
+ "--approval-mode", "default", "--extensions", "none", "--policy", policy],
17
+ env: { GEMINI_CLI_TRUST_WORKSPACE: "true" } };
18
+ },
10
19
  invoke: (o) => ({
11
20
  cmd: "gemini",
12
21
  args: ["-p", o.prompt, "--output-format", "stream-json", "--approval-mode", "yolo", ...(o.model ? ["-m", o.model] : []), ...o.extraArgs],
@@ -17,7 +26,10 @@ export const gemini = {
17
26
  if (!ev)
18
27
  return [];
19
28
  const out = [];
20
- if (ev.type === "message" && ev.role === "assistant" && str(ev.content)?.trim()) {
29
+ if (ev.type === "init" && str(ev.model)) {
30
+ out.push({ kind: "model", id: str(ev.model) });
31
+ }
32
+ else if (ev.type === "message" && ev.role === "assistant" && str(ev.content)?.trim()) {
21
33
  out.push({ kind: "text", text: str(ev.content) });
22
34
  }
23
35
  else if (ev.type === "tool_use") {
@@ -25,11 +37,10 @@ export const gemini = {
25
37
  }
26
38
  else if (ev.type === "result") {
27
39
  const stats = (ev.stats ?? {});
28
- out.push({
29
- kind: "usage",
30
- inputTokens: num(stats.input_tokens) ?? num(stats.inputTokens),
31
- outputTokens: num(stats.output_tokens) ?? num(stats.outputTokens),
32
- });
40
+ const inputTokens = num(stats.input_tokens) ?? num(stats.inputTokens);
41
+ const outputTokens = num(stats.output_tokens) ?? num(stats.outputTokens);
42
+ if (inputTokens !== undefined || outputTokens !== undefined)
43
+ out.push({ kind: "usage", inputTokens, outputTokens });
33
44
  out.push({ kind: "done", ok: ev.status !== "error", summary: str(ev.response) });
34
45
  }
35
46
  else if (ev.type === "error") {
package/dist/cli.js CHANGED
@@ -12,13 +12,27 @@ import { cleanableRuns, cleanRun } from "./cleanup.js";
12
12
  import { describeDetected, detectProjectDefaults } from "./detect.js";
13
13
  import { CMD_AGENT, listLogs, localTime, logMark, nextLogFile, renderLog } from "./logs.js";
14
14
  import { flowDir, logDir, projectRoot, worktreeDir } from "./paths.js";
15
- import { TaskList } from "./schemas.js";
16
- import { agentRuns, getRun, listRuns, listSubstitutions, saveRun, usageByAgent } from "./store.js";
15
+ import { ModelStage, ModelStrength, TaskList } from "./schemas.js";
16
+ import { computeStats, formatDuration } from "./stats.js";
17
+ import { agentRuns, getRun, listRuns, listSubstitutions, saveRun, usageByAgent, usageByModelStage, usageByStage, usageByStrength, usageByTask } from "./store.js";
17
18
  import { readJsonFile } from "./util.js";
18
19
  import { openActions, readHandoff } from "./handoff.js";
19
20
  import { stopReport } from "./stopReport.js";
20
21
  import { runSetup, SETUP_ADAPTERS } from "./setup.js";
21
22
  import { addAgent, readRawConfig, removeAgent, setAgent, setCycle, writeRawConfig } from "./agentConfig.js";
23
+ import { addModel, removeModel, setModelMode, setModelStrength, setStageStrength } from "./modelConfig.js";
24
+ import { DEFAULT_STAGE_STRENGTH, effectiveStageStrengths, validateAdaptiveConfig } from "./modelSelection.js";
25
+ import { probeModel } from "./modelProbe.js";
26
+ const cacheNote = (c) => c.cacheReadTokens || c.cacheWriteTokens ? `(含 cache 讀 ${c.cacheReadTokens}、寫 ${c.cacheWriteTokens})` : "";
27
+ function printUsage(title, rows) {
28
+ if (!rows.length)
29
+ return;
30
+ console.log(`\n${title}`);
31
+ for (const [key, c] of rows) {
32
+ const value = c.reportedRuns ? `輸入 ${c.inputTokens}${cacheNote(c)}、輸出 ${c.outputTokens}、合計 ${c.tokens} tokens` : "未回報或回報狀態不明";
33
+ console.log(` ${key}: ${value};${c.runs} 次(未回報 ${c.unreportedRuns}、舊紀錄不明 ${c.legacyRuns})`);
34
+ }
35
+ }
22
36
  function mustGetRun(id) {
23
37
  const run = getRun(id);
24
38
  if (!run)
@@ -107,6 +121,7 @@ program
107
121
  .option("--max-agent-runs <n>", "單一 run 最多執行幾次 agent(預設取 flow.config.json 的 maxAgentRuns)")
108
122
  .option("--manual-plan", "計畫通過 AI 審查後,仍停下來等你確認", false)
109
123
  .option("--cycle <agents>", "參與的 agent,例如 claude,codex,gemini(順序不影響分工)")
124
+ .option("--model-mode <mode>", "這次 run 的模型模式:balanced 或 adaptive")
110
125
  .action(async (opts) => {
111
126
  const requirement = opts.reqFile ? readFileSync(opts.reqFile, "utf8") : opts.req;
112
127
  if (!requirement?.trim())
@@ -116,12 +131,17 @@ program
116
131
  if (!base)
117
132
  throw new Error("目前不在任何分支上,請用 --base 指定基底分支");
118
133
  const cycle = await resolveCycle(opts.cycle);
134
+ const cfg = loadRepoConfig();
135
+ const modelMode = opts.modelMode ?? cfg.modelSelection.mode;
136
+ if (modelMode !== "balanced" && modelMode !== "adaptive")
137
+ throw new Error(`未知的模型模式:${modelMode}`);
138
+ if (modelMode === "adaptive")
139
+ validateAdaptiveConfig(cfg, cycle);
119
140
  const id = `f-${Date.now().toString(36)}`;
120
141
  const branch = `flow/${id}`;
121
142
  console.log(`[${id}] 🌿 從 ${base} 建立 worktree(分支 ${branch})`);
122
143
  await addWorktree(root, worktreeDir(id), base, branch);
123
144
  console.log(`[${id}] 🤝 參與的 agent:${cycle.join("、")}(角色隨機分配)`);
124
- const cfg = loadRepoConfig();
125
145
  const now = new Date().toISOString();
126
146
  // worktree 一建好就寫入紀錄:之後在任何地方中斷,都能用 resume 接續或用 clean 清掉
127
147
  const run = saveRun({
@@ -134,6 +154,7 @@ program
134
154
  maxAgentRuns: opts.maxAgentRuns ? Number(opts.maxAgentRuns) : cfg.maxAgentRuns,
135
155
  cycle,
136
156
  attempts: {},
157
+ modelMode,
137
158
  taskIndex: 0,
138
159
  taskPhase: "tests",
139
160
  createdAt: now,
@@ -162,13 +183,15 @@ program
162
183
  .option("--max-agent-runs <n>", "調整 agent 執行次數上限")
163
184
  .action(async (id, opts) => {
164
185
  let run = mustGetRun(id);
186
+ if (run.modelMode === "adaptive")
187
+ validateAdaptiveConfig(loadRepoConfig(), run.cycle);
165
188
  if (opts.maxAgentRuns)
166
189
  run = { ...run, maxAgentRuns: Number(opts.maxAgentRuns) };
167
190
  if (run.stage === "paused") {
168
191
  run = { ...run, stage: run.pausedStage ?? "spec", pausedStage: undefined, pauseReason: undefined };
169
192
  }
170
193
  if (run.stage === "failed") {
171
- run = { ...run, stage: run.failedStage ?? "spec", attempts: {}, failedStage: undefined, failureReason: undefined };
194
+ run = { ...run, stage: run.failedStage ?? "spec", attempts: {}, modelRetryAttempts: {}, failedStage: undefined, failureReason: undefined };
172
195
  }
173
196
  await drive(saveRun(run));
174
197
  });
@@ -215,8 +238,27 @@ program
215
238
  if (Object.keys(byAgent).length) {
216
239
  console.log("\n各 agent 用量");
217
240
  for (const [agent, c] of Object.entries(byAgent)) {
218
- console.log(` ${agent.padEnd(10)} ${String(c.runs).padStart(3)} 次 ${String(c.tokens).padStart(9)} tokens`);
241
+ console.log(` ${agent.padEnd(10)} ${String(c.runs).padStart(3)} 次 ${String(c.tokens).padStart(9)} 已回報 tokens${cacheNote(c)}(未回報 ${c.unreportedRuns}、舊紀錄不明 ${c.legacyRuns}${c.legacyTokens ? `,原始數字 ${c.legacyTokens} tokens` : ""})`);
242
+ }
243
+ }
244
+ const byStage = usageByStage(id);
245
+ const stageOrder = [...Object.keys(DEFAULT_STAGE_STRENGTH), "其他"];
246
+ printUsage("各階段用量(同一步驟的所有任務合計)", Object.entries(byStage).sort(([a], [b]) => stageOrder.indexOf(a) - stageOrder.indexOf(b)));
247
+ const taskNo = (key) => /^T-(\d+)$/.exec(key) ? Number(key.slice(2)) : Infinity;
248
+ printUsage("各任務用量(寫測試、實作、任務審查、任務修正)", Object.entries(usageByTask(id)).sort(([a], [b]) => taskNo(a) - taskNo(b)));
249
+ printUsage("各模型與步驟用量(只加總明確回報)", Object.entries(usageByModelStage(id)));
250
+ const byStrength = usageByStrength(id);
251
+ const reportedTotal = Object.values(byStrength).reduce((sum, entry) => sum + entry.tokens, 0);
252
+ if (Object.keys(byStrength).length) {
253
+ console.log("\n模型強度用量(占比只計入明確回報)");
254
+ for (const strength of ["low", "medium", "high", "未知"]) {
255
+ const entry = byStrength[strength];
256
+ if (!entry)
257
+ continue;
258
+ const share = reportedTotal ? `${(entry.tokens / reportedTotal * 100).toFixed(1)}%` : "無法計算";
259
+ console.log(` ${strength}: ${entry.tokens} tokens,占比 ${share},呼叫 ${entry.runs} 次(未回報 ${entry.unreportedRuns}、舊紀錄不明 ${entry.legacyRuns})`);
219
260
  }
261
+ console.log(` 高強度呼叫:${byStrength.high?.runs ?? 0} 次`);
220
262
  }
221
263
  const subs = listSubstitutions(id);
222
264
  if (subs.length) {
@@ -237,6 +279,18 @@ program
237
279
  // ───────────── agent 管理:讀寫 flow.config.json 的 agents 與 cycle ─────────────
238
280
  const configPath = () => join(projectRoot(), "flow.config.json");
239
281
  const collect = (value, prev = []) => [...prev, value];
282
+ const parseStrength = (value) => {
283
+ const result = ModelStrength.safeParse(value);
284
+ if (!result.success)
285
+ throw new Error(`未知的模型強度:${value}(請使用 low、medium 或 high)`);
286
+ return result.data;
287
+ };
288
+ const parseModelStage = (value) => {
289
+ const result = ModelStage.safeParse(value);
290
+ if (!result.success)
291
+ throw new Error(`未知的 LLM 階段:${value}`);
292
+ return result.data;
293
+ };
240
294
  function applyEdit(edit, done) {
241
295
  const before = readRawConfig(configPath());
242
296
  const { cfg, changes } = edit(before);
@@ -279,8 +333,9 @@ agent
279
333
  .requiredOption("--adapter <adapter>", "claude、codex、gemini 或 command")
280
334
  .option("--model <model>", "模型名稱")
281
335
  .option("--extra-arg <arg>", "額外參數,可重複;以 - 開頭時寫成 --extra-arg=--sandbox", collect)
336
+ .option("--model-probe-arg <arg>", "自訂 command 的模型探測命令參數,可重複", collect)
282
337
  .action((name, command, opts) => {
283
- applyEdit((cfg) => addAgent(cfg, name, { adapter: opts.adapter, model: opts.model, extraArgs: opts.extraArg, command: command.length ? command : undefined }), `已新增 ${name};要讓它參與請用 agent cycle`);
338
+ applyEdit((cfg) => addAgent(cfg, name, { adapter: opts.adapter, model: opts.model, extraArgs: opts.extraArg, modelProbe: opts.modelProbeArg, command: command.length ? command : undefined }), `已新增 ${name};要讓它參與請用 agent cycle`);
284
339
  });
285
340
  agent
286
341
  .command("set <name> [command...]")
@@ -288,8 +343,9 @@ agent
288
343
  .option("--adapter <adapter>", "claude、codex、gemini 或 command")
289
344
  .option("--model <model>", "模型名稱")
290
345
  .option("--extra-arg <arg>", "額外參數,可重複,會整個取代原本的設定", collect)
346
+ .option("--model-probe-arg <arg>", "自訂 command 的模型探測命令參數,可重複,會整個取代", collect)
291
347
  .action((name, command, opts) => {
292
- applyEdit((cfg) => setAgent(cfg, name, { adapter: opts.adapter, model: opts.model, extraArgs: opts.extraArg, command: command.length ? command : undefined }), `已更新 ${name}`);
348
+ applyEdit((cfg) => setAgent(cfg, name, { adapter: opts.adapter, model: opts.model, extraArgs: opts.extraArg, modelProbe: opts.modelProbeArg, command: command.length ? command : undefined }), `已更新 ${name}`);
293
349
  });
294
350
  agent
295
351
  .command("remove <name>")
@@ -346,6 +402,86 @@ agent
346
402
  console.log("");
347
403
  await doctor();
348
404
  });
405
+ // ───────────── 模型清單與各階段強度 ─────────────
406
+ const model = program.command("model").description("設定模型強度並檢查目前帳號能否呼叫模型");
407
+ model.command("add <agent> <name>")
408
+ .requiredOption("--strength <strength>", "low、medium 或 high")
409
+ .action(async (agent, name, opts) => {
410
+ const strength = parseStrength(opts.strength);
411
+ const before = readRawConfig(configPath());
412
+ const cfg = loadRepoConfig();
413
+ const def = cfg.agents[agent];
414
+ if (!def)
415
+ throw new Error(`未定義的 agent:${agent}`);
416
+ const next = addModel(before, agent, name, strength);
417
+ console.log("模型檢查會送出最短請求,可能耗用少量 token。");
418
+ const checked = await probeModel(def, name);
419
+ if (checked.status !== "ok")
420
+ throw new Error(`${agent} 的模型 ${name} 未加入:${checked.reason}`);
421
+ writeRawConfig(configPath(), next);
422
+ console.log(`✅ 已新增 ${agent} 的模型 ${name}(${strength})${checked.resolvedModel ? ` → ${checked.resolvedModel}` : ""}${def.adapter === "command" ? "(由自訂探測命令回報)" : ""};探測可能耗用少量 token`);
423
+ });
424
+ model.command("set <agent> <name>")
425
+ .requiredOption("--strength <strength>", "low、medium 或 high")
426
+ .action((agent, name, opts) => {
427
+ writeRawConfig(configPath(), setModelStrength(readRawConfig(configPath()), agent, name, parseStrength(opts.strength)));
428
+ console.log(`✅ 已將 ${agent} 的模型 ${name} 強度設為 ${opts.strength};此指令不重驗可用性`);
429
+ });
430
+ model.command("remove <agent> <name>").action((agent, name) => {
431
+ writeRawConfig(configPath(), removeModel(readRawConfig(configPath()), agent, name));
432
+ console.log(`✅ 已移除 ${agent} 的模型 ${name}`);
433
+ });
434
+ model.command("list [agent]").action((agent) => {
435
+ const cfg = loadRepoConfig();
436
+ const names = agent ? [agent] : Object.keys(cfg.agents);
437
+ for (const name of names) {
438
+ const def = cfg.agents[name];
439
+ if (!def)
440
+ throw new Error(`未定義的 agent:${name}`);
441
+ console.log(`${name}(${def.adapter})`);
442
+ for (const item of def.models ?? [])
443
+ console.log(` ${item.name} ${item.strength}`);
444
+ if (!def.models?.length)
445
+ console.log(" 尚未設定模型");
446
+ }
447
+ console.log("清單只顯示設定內容;要確認當前帳號是否可用,請執行 model check。");
448
+ });
449
+ model.command("check [agent]").action(async (agent) => {
450
+ const cfg = loadRepoConfig();
451
+ const names = agent ? [agent] : Object.keys(cfg.agents);
452
+ console.log("模型重驗會逐一送出短請求,可能耗用少量 token。");
453
+ for (const name of names) {
454
+ const def = cfg.agents[name];
455
+ if (!def)
456
+ throw new Error(`未定義的 agent:${name}`);
457
+ for (const item of def.models ?? []) {
458
+ const checked = await probeModel(def, item.name);
459
+ const at = new Date().toISOString();
460
+ const mark = checked.status === "ok" ? "✅" : checked.status === "unverifiable" ? "⚪" : "❌";
461
+ const result = checked.status === "ok" ? `可呼叫${checked.resolvedModel ? ` → ${checked.resolvedModel}` : ""}${def.adapter === "command" ? "(由自訂探測命令回報)" : ""}` : checked.reason;
462
+ console.log(`${mark} ${at} ${name} ${item.name}: ${result}`);
463
+ }
464
+ }
465
+ });
466
+ model.command("mode [mode]").action((mode) => {
467
+ if (!mode)
468
+ return console.log(`模型模式:${loadRepoConfig().modelSelection.mode}`);
469
+ if (mode !== "balanced" && mode !== "adaptive")
470
+ throw new Error(`未知的模型模式:${mode}`);
471
+ writeRawConfig(configPath(), setModelMode(readRawConfig(configPath()), mode));
472
+ console.log(`✅ 模型模式已設為 ${mode}`);
473
+ });
474
+ model.command("stage [stage] [strength]").action((stage, strength) => {
475
+ if (!stage || !strength) {
476
+ const all = effectiveStageStrengths(loadRepoConfig());
477
+ const stages = stage ? [parseModelStage(stage)] : Object.keys(all);
478
+ for (const s of stages)
479
+ console.log(`${s}: ${all[s].strength}${all[s].custom ? "(自訂)" : "(預設)"}`);
480
+ return;
481
+ }
482
+ writeRawConfig(configPath(), setStageStrength(readRawConfig(configPath()), parseModelStage(stage), parseStrength(strength)));
483
+ console.log(`✅ ${stage} 的最低強度已設為 ${strength}`);
484
+ });
349
485
  /** 檢查設定的 agent 是否已安裝,印出參與的 agent 與主要設定 */
350
486
  async function doctor() {
351
487
  const cfg = loadRepoConfig();
@@ -403,6 +539,27 @@ program
403
539
  const text = readFileSync(entry.file, "utf8");
404
540
  console.log(opts.raw ? text : renderLog(text, entry.file, { full: opts.full }));
405
541
  });
542
+ program
543
+ .command("stats <id>")
544
+ .description("依步驟統計耗時、執行次數與失敗次數,找出最花時間與最常重試的地方")
545
+ .action((id) => {
546
+ mustGetRun(id);
547
+ const stats = computeStats(listLogs(logDir(id)));
548
+ if (!stats.steps.length)
549
+ return console.log("還沒有 log");
550
+ const total = stats.agentMs + stats.cmdMs;
551
+ const share = (ms) => (total ? `${(ms / total * 100).toFixed(0)}%` : "-");
552
+ console.log(`總經過時間 ${formatDuration(stats.wallMs)}(含暫停與等待核准)`);
553
+ console.log(` agent ${formatDuration(stats.agentMs).padStart(7)} ${share(stats.agentMs)}`);
554
+ console.log(` 專案指令 ${formatDuration(stats.cmdMs).padStart(7)} ${share(stats.cmdMs)}`);
555
+ if (stats.unfinished)
556
+ console.log(` 未完成 ${stats.unfinished} 份(沒有結束紀錄,不計入耗時)`);
557
+ console.log("\n 步驟 類型 次數 失敗 總耗時 最長 占比");
558
+ for (const s of stats.steps) {
559
+ console.log(` ${s.step.padEnd(19)} ${s.kind === "cmd" ? "指令 " : "agent"} ${String(s.runs).padStart(4)} ${String(s.failed).padStart(4)} ${formatDuration(s.totalMs).padStart(7)} ${formatDuration(s.maxMs).padStart(7)} ${share(s.totalMs).padStart(5)}${s.unfinished ? ` (未完成 ${s.unfinished})` : ""}`);
560
+ }
561
+ console.log(`\n次數多或失敗多的步驟可用 agentflowctl logs ${id} 找出編號查看原因;token 用量見 agentflowctl status ${id}`);
562
+ });
406
563
  program.parseAsync().catch((err) => {
407
564
  console.error(`錯誤:${err.message}`);
408
565
  process.exit(1);
package/dist/engine.js CHANGED
@@ -14,7 +14,8 @@ import { arbiterPanel, availableAgent, fixAgent, planAgent, planFixAgent, review
14
14
  import { resolveAgent, runAgent, runCommand } from "./runner.js";
15
15
  import { AcceptanceList, ArbiterResult, ConsistentReviewResult, RepoConfig, TaskList, } from "./schemas.js";
16
16
  import { addSubstitution, addUsage, agentRuns, saveRun } from "./store.js";
17
- import { orderTasks, taskAcceptance } from "./tasks.js";
17
+ import { clearModelReviewFailure, clearModelReviewStage, recordModelReviewFailure, selectModel } from "./modelSelection.js";
18
+ import { orderTasks, taskAcceptance, validateTaskComplexity } from "./tasks.js";
18
19
  import { readJsonFile, renderPrompt, tail } from "./util.js";
19
20
  // ───────────────────────── 共用工具 ─────────────────────────
20
21
  const info = (run, msg) => console.log(`[${run.id}] ${msg}`);
@@ -60,10 +61,16 @@ async function agentStep(run, planned, step, prompt, mode) {
60
61
  info(run, `🔁 ${agent} 額度已用完,${step} 由 ${sub} 代打${note ? `(注意:${note})` : ""}`);
61
62
  agent = sub;
62
63
  }
64
+ const selected = selectModel(run, cfg, agent, step, /^T-\d+-/.test(step) ? loadOrderedTasks(run)[run.taskIndex]?.complexity : undefined, mode.kind === "review" ? agent : undefined);
65
+ info(run, `🤖 ${step}:${agent} 使用 ${selected.name ?? "CLI 預設(名稱未知)"}${selected.insufficient ? `(低於目標 ${selected.targetStrength})` : ""}`);
63
66
  const callKey = handoffKey(run, step, mode.slot ?? 0, agent);
64
67
  prepareHandoff(run.id, callKey, handoffTarget(run), mode.blind ?? false);
65
- const r = await runAgent(agent, resolveAgent(cfg, agent), target(run, step, agent), prompt);
66
- addUsage(run.id, { stage: step, agent, inputTokens: r.inputTokens, outputTokens: r.outputTokens });
68
+ const r = await runAgent(agent, { ...resolveAgent(cfg, agent), model: selected.name }, { ...target(run, step, agent), strength: selected.strength, targetStrength: selected.targetStrength }, prompt);
69
+ if (r.resolvedModel && r.resolvedModel !== selected.name)
70
+ info(run, ` ↳ CLI 回報實際模型:${r.resolvedModel}`);
71
+ addUsage(run.id, { stage: step, agent, model: selected.name, resolvedModel: r.resolvedModel,
72
+ strength: selected.strength, targetStrength: selected.targetStrength, usageReported: r.usageReported,
73
+ inputTokens: r.inputTokens, outputTokens: r.outputTokens, cacheReadTokens: r.cacheReadTokens, cacheWriteTokens: r.cacheWriteTokens });
67
74
  if (!r.quotaExhausted) {
68
75
  reportMeta(run, agent, r);
69
76
  return { r, agent, step, callKey };
@@ -205,6 +212,9 @@ function validatePlan(run) {
205
212
  const tasks = readJsonFile(flowFile(run, "tasks.json"), TaskList);
206
213
  if (!tasks.ok)
207
214
  return tasks.error;
215
+ const complexityError = validateTaskComplexity(tasks.data, run.modelMode ?? "balanced");
216
+ if (complexityError)
217
+ return complexityError;
208
218
  return orderTasks(tasks.data, new Set(ids));
209
219
  }
210
220
  function acceptPlan(run, ordered) {
@@ -272,13 +282,14 @@ async function planReviewStage(run) {
272
282
  if (tampered.length)
273
283
  info(run, ` ↩️ 已還原審查者修改的檔案:${tampered.join(", ")}`);
274
284
  if (!r.ok)
275
- return retry(run, "plan-review-run", `Agent 執行失敗:${r.summary}`, "plan_review");
285
+ return retry(recordModelReviewFailure(run, "plan-review", reviewer), "plan-review-run", `Agent 執行失敗:${r.summary}`, "plan_review");
276
286
  const review = readJsonFile(flowFile(run, "plan-review.json"), ConsistentReviewResult);
277
287
  if (!review.ok)
278
- return retry(run, "plan-review-run", review.error, "plan_review");
288
+ return retry(recordModelReviewFailure(run, "plan-review", reviewer), "plan-review-run", review.error, "plan_review");
279
289
  const handoffError = finishHandoff(run, outcome, "reviewer", { target: "plan", verdict: review.data.verdict });
280
290
  if (handoffError)
281
- return retry(run, "plan-review-run", handoffError, "plan_review");
291
+ return retry(recordModelReviewFailure(run, "plan-review", reviewer), "plan-review-run", handoffError, "plan_review");
292
+ run = clearModelReviewFailure(run, "plan-review", reviewer);
282
293
  // 審查紀錄移到 worktree 外面:之後的仲裁者看不到是哪一家提的意見
283
294
  mkdirSync(join(runDir(run.id), "reviews"), { recursive: true });
284
295
  renameSync(flowFile(run, "plan-review.json"), join(runDir(run.id), "reviews", `plan-review-${round}-${reviewer}.json`));
@@ -294,6 +305,10 @@ async function planReviewStage(run) {
294
305
  issueLines.push(...lines);
295
306
  issues.push(opinion(reviewer, lines));
296
307
  }
308
+ run = clearModelReviewStage(run, "plan-review");
309
+ const attempts = { ...run.attempts };
310
+ delete attempts["plan-review-run"];
311
+ run = { ...run, attempts };
297
312
  if (!firstObjector)
298
313
  return planSettled(run, "plan-review");
299
314
  const report = `計畫審查要求修改:\n\n${issues.join("\n\n")}`;
@@ -539,7 +554,7 @@ async function taskReviewStep(run, task, progress, taskJson, acceptanceJson) {
539
554
  });
540
555
  if ("run" in result)
541
556
  return result.run;
542
- const reviewed = succeed(run, `${task.id}:review-run`, "implement");
557
+ const reviewed = succeed(result.state, `${task.id}:review-run`, "implement");
543
558
  if (!result.objector)
544
559
  return { ...succeed(reviewed, key, "implement"), taskPhase: "verify" };
545
560
  return {
@@ -665,7 +680,7 @@ async function fixStage(run) {
665
680
  if ("run" in result)
666
681
  return result.run;
667
682
  // 修正者成為新的作者,下一輪審查會換成別人
668
- return { ...to(run, "verify"), lastWriter: result.agent };
683
+ return { ...succeed(run, "fix", "verify"), lastWriter: result.agent };
669
684
  }
670
685
  /**
671
686
  * 由作者以外的審查小組審查 base 之後的變更;回傳第一位要求修改的審查者與所有意見,
@@ -694,14 +709,16 @@ async function codeReview(run, opts) {
694
709
  if (tampered.length)
695
710
  info(run, ` ↩️ 已還原審查者修改的檔案:${tampered.join(", ")}`);
696
711
  if (!r.ok)
697
- return { run: retry(run, opts.runKey, `Agent 執行失敗:${r.summary}`, opts.backTo) };
712
+ return { run: retry(opts.step === "review" ? recordModelReviewFailure(run, "review", reviewer) : run, opts.runKey, `Agent 執行失敗:${r.summary}`, opts.backTo) };
698
713
  const review = readJsonFile(flowFile(run, "review.json"), ConsistentReviewResult);
699
714
  if (!review.ok)
700
- return { run: retry(run, opts.runKey, review.error, opts.backTo) };
715
+ return { run: retry(opts.step === "review" ? recordModelReviewFailure(run, "review", reviewer) : run, opts.runKey, review.error, opts.backTo) };
701
716
  const gate = opts.gate ? { target: "code", verdict: review.data.verdict } : undefined;
702
717
  const handoffError = finishHandoff(run, outcome, "reviewer", gate);
703
718
  if (handoffError)
704
- return { run: retry(run, opts.runKey, handoffError, opts.backTo) };
719
+ return { run: retry(opts.step === "review" ? recordModelReviewFailure(run, "review", reviewer) : run, opts.runKey, handoffError, opts.backTo) };
720
+ if (opts.step === "review")
721
+ run = clearModelReviewFailure(run, "review", reviewer);
705
722
  renameSync(flowFile(run, "review.json"), flowFile(run, opts.saveAs(reviewer)));
706
723
  if (review.data.verdict === "approve") {
707
724
  info(run, ` ✓ ${reviewer} 核准`);
@@ -713,7 +730,11 @@ async function codeReview(run, opts) {
713
730
  .filter((i) => i.status !== "met")
714
731
  .map((i) => reviewIssue(i.criterion, i.status, i.note))));
715
732
  }
716
- return { objector, issues };
733
+ if (opts.step === "review")
734
+ run = clearModelReviewStage(run, "review");
735
+ const attempts = { ...run.attempts };
736
+ delete attempts[opts.runKey];
737
+ return { state: { ...run, attempts }, objector, issues };
717
738
  }
718
739
  async function reviewStage(run) {
719
740
  const result = await codeReview(run, {
@@ -730,9 +751,9 @@ async function reviewStage(run) {
730
751
  if ("run" in result)
731
752
  return result.run;
732
753
  if (!result.objector)
733
- return succeed(run, "review", "pr");
754
+ return succeed(result.state, "review", "pr");
734
755
  return {
735
- ...retry(run, "review", `程式碼審查要求修改:\n\n${result.issues.join("\n\n")}`, "fix"),
756
+ ...retry(result.state, "review", `程式碼審查要求修改:\n\n${result.issues.join("\n\n")}`, "fix"),
736
757
  fixSource: "review",
737
758
  lastReviewer: result.objector,
738
759
  };
package/dist/logs.js CHANGED
@@ -119,7 +119,7 @@ function renderEvent(ev, full, lastText) {
119
119
  return `🔧 ${ev.name}: ${full ? indent(detail) : compactDetail(detail)}`;
120
120
  }
121
121
  case "usage":
122
- return `📊 用量 input ${ev.inputTokens ?? "?"} / output ${ev.outputTokens ?? "?"} tokens`;
122
+ return `📊 用量 input ${ev.inputTokens ?? "?"} / output ${ev.outputTokens ?? "?"} tokens${ev.cacheReadTokens !== undefined || ev.cacheWriteTokens !== undefined ? `(input 含 cache 讀 ${ev.cacheReadTokens ?? 0}、寫 ${ev.cacheWriteTokens ?? 0})` : ""}`;
123
123
  case "done": {
124
124
  const summary = ev.summary?.trim();
125
125
  // 最後一則回覆通常就是 summary,精簡模式不再重印一次
@@ -0,0 +1,74 @@
1
+ import { ModelStage, ModelStrength, RepoConfig } from "./schemas.js";
2
+ const agentsOf = (cfg) => ({ ...(cfg.agents ?? {}) });
3
+ const modelsOf = (agent) => [...(agent.models ?? [])];
4
+ function withAgent(cfg, agent, edit) {
5
+ const agents = agentsOf(cfg);
6
+ if (!agents[agent])
7
+ throw new Error(`未定義的 agent:${agent}`);
8
+ agents[agent] = edit({ ...agents[agent] });
9
+ const next = { ...cfg, agents };
10
+ RepoConfig.parse(next);
11
+ return next;
12
+ }
13
+ export function addModel(cfg, agent, name, strength) {
14
+ ModelStrength.parse(strength);
15
+ if (!name.trim())
16
+ throw new Error("模型名稱不可為空");
17
+ if (name !== name.trim())
18
+ throw new Error("模型名稱前後不可有空白");
19
+ return withAgent(cfg, agent, (def) => {
20
+ const models = modelsOf(def);
21
+ if (models.some((m) => m.name === name))
22
+ throw new Error(`agent ${agent} 的模型名稱 ${name} 重複`);
23
+ return { ...def, models: [...models, { name, strength }] };
24
+ });
25
+ }
26
+ export function setModelStrength(cfg, agent, name, strength) {
27
+ ModelStrength.parse(strength);
28
+ return withAgent(cfg, agent, (def) => {
29
+ const models = modelsOf(def);
30
+ if (!models.some((m) => m.name === name))
31
+ throw new Error(`agent ${agent} 沒有模型 ${name}`);
32
+ return { ...def, models: models.map((m) => m.name === name ? { ...m, strength } : m) };
33
+ });
34
+ }
35
+ /** 有 cycle 時看 cycle,否則看全部 agent;只做設定層的保守檢查。 */
36
+ function assertModelsPresent(cfg) {
37
+ const agents = agentsOf(cfg);
38
+ const affected = cfg.cycle ?? Object.keys(agents);
39
+ const missing = affected.filter((name) => !agents[name] || !modelsOf(agents[name]).length);
40
+ if (missing.length)
41
+ throw new Error(`以下 agent 缺少模型:${missing.join("、")}`);
42
+ }
43
+ export function removeModel(cfg, agent, name) {
44
+ const next = withAgent(cfg, agent, (def) => {
45
+ const models = modelsOf(def);
46
+ if (!models.some((m) => m.name === name))
47
+ throw new Error(`agent ${agent} 沒有模型 ${name}`);
48
+ return { ...def, models: models.filter((m) => m.name !== name) };
49
+ });
50
+ const lastModelRemoved = !modelsOf(agentsOf(next)[agent]).length;
51
+ if (lastModelRemoved || (cfg.modelSelection ?? {}).mode === "adaptive")
52
+ assertModelsPresent(next);
53
+ return next;
54
+ }
55
+ export function setModelMode(cfg, mode) {
56
+ if (mode !== "balanced" && mode !== "adaptive")
57
+ throw new Error(`未知的模型模式:${mode}`);
58
+ if (mode === "adaptive")
59
+ assertModelsPresent(cfg);
60
+ const next = { ...cfg, modelSelection: { ...(cfg.modelSelection ?? {}), mode } };
61
+ RepoConfig.parse(next);
62
+ return next;
63
+ }
64
+ export function setStageStrength(cfg, stage, strength) {
65
+ const parsed = ModelStage.safeParse(stage);
66
+ if (!parsed.success)
67
+ throw new Error(`未知的 LLM 階段:${stage}`);
68
+ ModelStrength.parse(strength);
69
+ const selection = (cfg.modelSelection ?? {});
70
+ const next = { ...cfg, modelSelection: { ...selection, stageStrength: { ...(selection.stageStrength ?? {}), [stage]: strength } } };
71
+ RepoConfig.parse(next);
72
+ return next;
73
+ }
74
+ //# sourceMappingURL=modelConfig.js.map
@@ -0,0 +1,74 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { ADAPTERS } from "./agents/index.js";
5
+ import { exec } from "./proc.js";
6
+ export function probeFailureReason(message) {
7
+ if (/\b429\b|quota|rate.?limit|usage limit/i.test(message))
8
+ return `額度或速率限制:${message}`;
9
+ if (/invalid model|model.*(not found|unknown|unavailable)|unknown model/i.test(message))
10
+ return `模型名稱無效或目前不可用:${message}`;
11
+ if (/auth|unauthori[sz]ed|forbidden|\b401\b|\b403\b/i.test(message))
12
+ return `認證或權限失敗:${message}`;
13
+ if (/network|connect|timed? out|dns|enotfound/i.test(message))
14
+ return `網路連線失敗:${message}`;
15
+ return message;
16
+ }
17
+ /** 在獨立暫存目錄對指定模型送出最短請求;安全能力不足時直接拒絕。 */
18
+ export async function probeModel(def, model, timeoutMs = 30000) {
19
+ const dir = mkdtempSync(join(tmpdir(), "agentflowctl-model-"));
20
+ try {
21
+ const adapter = ADAPTERS[def.adapter];
22
+ let inv;
23
+ if (def.adapter === "command") {
24
+ if (!def.modelProbe?.length || !def.modelProbe.some((arg) => arg.includes("{model}"))) {
25
+ return { status: "unverifiable", reason: "command agent 缺少含 {model} 的 modelProbe" };
26
+ }
27
+ const [cmd, ...rest] = def.modelProbe;
28
+ inv = { cmd: cmd.replaceAll("{model}", model), args: rest.map((arg) => arg.replaceAll("{model}", model)) };
29
+ }
30
+ else {
31
+ if (!adapter.invokeModelProbe)
32
+ return { status: "unverifiable", reason: `${def.adapter} CLI 無法安全停用工具,因此不能驗證模型` };
33
+ inv = adapter.invokeModelProbe(model, dir);
34
+ }
35
+ const events = [];
36
+ const result = await exec(inv.cmd, inv.args, {
37
+ cwd: dir, env: inv.env, input: inv.input, timeoutMs,
38
+ onStdoutLine: (line) => { if (def.adapter !== "command")
39
+ events.push(...adapter.parse(line)); },
40
+ });
41
+ if (result.code !== 0)
42
+ return { status: "failed", reason: result.code === 124 ? "模型檢查逾時" : probeFailureReason(result.stderr.trim() || result.stdout.trim() || `結束碼 ${result.code}`) };
43
+ if (def.adapter === "command") {
44
+ try {
45
+ const data = JSON.parse(result.stdout.trim());
46
+ if (data.requestedModel !== model || typeof data.resolvedModel !== "string" || !data.resolvedModel.trim()) {
47
+ return { status: "failed", reason: "自訂探測命令回報的模型名稱不符" };
48
+ }
49
+ return { status: "ok", resolvedModel: data.resolvedModel };
50
+ }
51
+ catch {
52
+ return { status: "failed", reason: "自訂探測命令未輸出有效 JSON" };
53
+ }
54
+ }
55
+ if (events.some((e) => e.kind === "tool"))
56
+ return { status: "failed", reason: "模型檢查期間出現工具呼叫,未通過無工具驗證" };
57
+ const done = events.filter((e) => e.kind === "done").at(-1);
58
+ if (done?.kind !== "done")
59
+ return { status: "failed", reason: "CLI 沒有回報完成" };
60
+ if (!done.ok)
61
+ return { status: "failed", reason: done.summary ?? "CLI 回報失敗" };
62
+ if (!events.some((e) => e.kind === "text" && e.text.trim()))
63
+ return { status: "failed", reason: "CLI 沒有回傳文字內容" };
64
+ const resolved = events.find((e) => e.kind === "model");
65
+ return { status: "ok", resolvedModel: resolved?.kind === "model" ? resolved.id : undefined };
66
+ }
67
+ catch (error) {
68
+ return { status: "failed", reason: error.message };
69
+ }
70
+ finally {
71
+ rmSync(dir, { recursive: true, force: true });
72
+ }
73
+ }
74
+ //# sourceMappingURL=modelProbe.js.map
@@ -0,0 +1,107 @@
1
+ const LEVEL = { low: 0, medium: 1, high: 2 };
2
+ const STRENGTHS = ["low", "medium", "high"];
3
+ export const DEFAULT_STAGE_STRENGTH = {
4
+ spec: "medium", plan: "high", planReview: "high", planFix: "medium", planArbiter: "high",
5
+ taskTests: "low", taskCode: "low", taskReview: "medium", taskFix: "low", fix: "medium", review: "high",
6
+ };
7
+ export function effectiveStageStrengths(cfg) {
8
+ const custom = cfg.modelSelection.stageStrength;
9
+ return Object.fromEntries(Object.keys(DEFAULT_STAGE_STRENGTH).map((stage) => [stage, { strength: custom[stage] ?? DEFAULT_STAGE_STRENGTH[stage], custom: custom[stage] !== undefined }]));
10
+ }
11
+ /** 小組審查失敗只影響該審查者的下一次選模。 */
12
+ export function recordModelReviewFailure(run, step, reviewer) {
13
+ const key = `${step}:${reviewer}`;
14
+ return { ...run, modelRetryAttempts: { ...run.modelRetryAttempts, [key]: (run.modelRetryAttempts?.[key] ?? 0) + 1 } };
15
+ }
16
+ export function clearModelReviewFailure(run, step, reviewer) {
17
+ const attempts = { ...run.modelRetryAttempts };
18
+ delete attempts[`${step}:${reviewer}`];
19
+ return { ...run, modelRetryAttempts: attempts };
20
+ }
21
+ export function clearModelReviewStage(run, step) {
22
+ const attempts = Object.fromEntries(Object.entries(run.modelRetryAttempts ?? {}).filter(([key]) => !key.startsWith(`${step}:`)));
23
+ return { ...run, modelRetryAttempts: attempts };
24
+ }
25
+ /** 把步驟名稱(例如 `T-1-code`、`plan-review`)對應到階段鍵;不認得時回傳 undefined。 */
26
+ export function stageOfStep(step) {
27
+ const task = /^T-\d+-(tests|code|review|fix)$/.exec(step);
28
+ if (task)
29
+ return { tests: "taskTests", code: "taskCode", review: "taskReview", fix: "taskFix" }[task[1]];
30
+ const stage = {
31
+ spec: "spec", plan: "plan", "plan-review": "planReview", "plan-fix": "planFix",
32
+ "plan-arbiter": "planArbiter", fix: "fix", review: "review",
33
+ };
34
+ return stage[step];
35
+ }
36
+ function stepStage(step) {
37
+ const found = stageOfStep(step);
38
+ if (!found)
39
+ throw new Error(`未知的 LLM 步驟:${step}`);
40
+ return found;
41
+ }
42
+ function escalation(run, stage, step, reviewer) {
43
+ const a = run.attempts;
44
+ if (stage === "planReview" || stage === "review")
45
+ return run.modelRetryAttempts?.[`${step}:${reviewer ?? ""}`] ?? 0;
46
+ if (stage === "planArbiter")
47
+ return 0;
48
+ if (stage === "planFix")
49
+ return (a["plan-fix"] ?? 0) + Math.max((a["plan-review"] ?? 0) + (a["plan-handoff"] ?? 0) - 1, 0);
50
+ if (stage === "taskFix") {
51
+ const id = step.slice(0, -4);
52
+ return (a[`${id}:fix`] ?? 0) + Math.max((a[`${id}:review`] ?? 0) + (a[`${id}:verify`] ?? 0) - 1, 0);
53
+ }
54
+ if (stage === "fix")
55
+ return (a.fix ?? 0) + Math.max((a.verify ?? 0) + (a.review ?? 0) - 1, 0);
56
+ if (stage === "taskTests" || stage === "taskCode" || stage === "taskReview") {
57
+ const id = step.slice(0, step.lastIndexOf("-"));
58
+ const suffix = step.slice(step.lastIndexOf("-") + 1);
59
+ return a[`${id}:${suffix === "review" ? "review-run" : suffix}`] ?? 0;
60
+ }
61
+ return a[step] ?? 0;
62
+ }
63
+ /** 每次呼叫前依目前設定與 run 狀態選擇模型;不修改任何狀態。 */
64
+ export function selectModel(run, cfg, agent, step, complexity, reviewer) {
65
+ const def = cfg.agents[agent];
66
+ if (!def)
67
+ throw new Error(`未定義的 agent:${agent}`);
68
+ const mode = run.modelMode ?? "balanced";
69
+ if (mode === "balanced") {
70
+ const fallback = def.adapter === "command" ? undefined : cfg.defaultModels[def.adapter];
71
+ return { mode, name: def.model ?? fallback, insufficient: false };
72
+ }
73
+ if (!def.models?.length)
74
+ throw new Error(`agent ${agent} 沒有設定 models`);
75
+ const stage = stepStage(step);
76
+ const floor = effectiveStageStrengths(cfg)[stage].strength;
77
+ const baseline = stage.startsWith("task") ? Math.max(LEVEL[floor], LEVEL[complexity ?? "medium"]) : LEVEL[floor];
78
+ const targetStrength = STRENGTHS[Math.min(2, baseline + escalation(run, stage, step, reviewer))];
79
+ const candidates = def.models.filter((m) => LEVEL[m.strength] >= LEVEL[targetStrength]);
80
+ const min = Math.min(...candidates.map((m) => LEVEL[m.strength]));
81
+ const chosen = candidates.find((m) => LEVEL[m.strength] === min)
82
+ ?? def.models.reduce((best, current) => LEVEL[current.strength] > LEVEL[best.strength] ? current : best);
83
+ return { mode, name: chosen.name, strength: chosen.strength, targetStrength, insufficient: LEVEL[chosen.strength] < LEVEL[targetStrength] };
84
+ }
85
+ /** 啟用 adaptive 前檢查本次實際參與的 agent。 */
86
+ export function validateAdaptiveConfig(cfg, cycle) {
87
+ for (const name of cycle) {
88
+ const def = cfg.agents[name];
89
+ if (!def)
90
+ throw new Error(`未定義的 agent:${name}`);
91
+ if (!def.models?.length)
92
+ throw new Error(`agent ${name} 的 models 至少要有一個模型`);
93
+ const names = def.models.map((m) => m.name);
94
+ if (new Set(names).size !== names.length)
95
+ throw new Error(`agent ${name} 的 models 有重複名稱`);
96
+ if (def.adapter === "command") {
97
+ if (!def.command?.some((arg) => arg.includes("{model}")))
98
+ throw new Error(`agent ${name} 的 command 缺少 {model}`);
99
+ if (!def.modelProbe?.length || !def.modelProbe.some((arg) => arg.includes("{model}")))
100
+ throw new Error(`agent ${name} 的 modelProbe 缺少 {model}`);
101
+ }
102
+ else if (def.extraArgs.some((arg) => arg === "--model" || arg === "-m" || arg.startsWith("--model="))) {
103
+ throw new Error(`agent ${name} 的 extraArgs 與 adaptive 模型參數衝突`);
104
+ }
105
+ }
106
+ }
107
+ //# sourceMappingURL=modelSelection.js.map
package/dist/proc.js CHANGED
@@ -6,6 +6,8 @@ export function exec(cmd, args, opts = {}) {
6
6
  env: { ...process.env, ...opts.env },
7
7
  shell: opts.shell ?? false,
8
8
  });
9
+ let timedOut = false;
10
+ const timer = opts.timeoutMs ? setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, opts.timeoutMs) : undefined;
9
11
  let stdout = "";
10
12
  let stderr = "";
11
13
  let pending = "";
@@ -22,11 +24,14 @@ export function exec(cmd, args, opts = {}) {
22
24
  child.stderr.on("data", (d) => {
23
25
  stderr += d.toString();
24
26
  });
25
- child.on("error", reject);
27
+ child.on("error", (error) => { if (timer)
28
+ clearTimeout(timer); reject(error); });
26
29
  child.on("close", (code) => {
30
+ if (timer)
31
+ clearTimeout(timer);
27
32
  if (opts.onStdoutLine && pending)
28
33
  opts.onStdoutLine(pending);
29
- resolve({ code: code ?? 1, stdout, stderr });
34
+ resolve({ code: timedOut ? 124 : code ?? 1, stdout, stderr: timedOut ? `${stderr}\n執行逾時` : stderr });
30
35
  });
31
36
  child.stdin.on("error", () => { }); // 子程序不讀 stdin 時忽略 EPIPE
32
37
  child.stdin.end(opts.input ?? "");
package/dist/runner.js CHANGED
@@ -63,11 +63,16 @@ export async function runAgent(name, def, t, prompt) {
63
63
  projectRoot: projectRoot(),
64
64
  command: def.command,
65
65
  });
66
- appendLog(t.logFile, headerLine({ stage: t.stage, step: t.step, agent: name, adapter: def.adapter, startedAt: new Date().toISOString() }));
66
+ appendLog(t.logFile, headerLine({ stage: t.stage, step: t.step, agent: name, adapter: def.adapter, model: def.model, strength: t.strength, targetStrength: t.targetStrength, startedAt: new Date().toISOString() }));
67
67
  let done;
68
68
  let lastText = "";
69
69
  let inputTokens = 0;
70
70
  let outputTokens = 0;
71
+ let inputReported = false;
72
+ let outputReported = false;
73
+ let cacheReadTokens;
74
+ let cacheWriteTokens;
75
+ let resolvedModel;
71
76
  const r = await exec(inv.cmd, inv.args, {
72
77
  cwd: t.cwd,
73
78
  env: inv.env,
@@ -87,8 +92,21 @@ export async function runAgent(name, def, t, prompt) {
87
92
  console.log(formatToolLine(name, ev));
88
93
  }
89
94
  else if (ev.kind === "usage") {
90
- inputTokens += ev.inputTokens ?? 0;
91
- outputTokens += ev.outputTokens ?? 0;
95
+ if (ev.inputTokens !== undefined) {
96
+ inputReported = true;
97
+ inputTokens += ev.inputTokens;
98
+ }
99
+ if (ev.outputTokens !== undefined) {
100
+ outputReported = true;
101
+ outputTokens += ev.outputTokens;
102
+ }
103
+ if (ev.cacheReadTokens !== undefined)
104
+ cacheReadTokens = (cacheReadTokens ?? 0) + ev.cacheReadTokens;
105
+ if (ev.cacheWriteTokens !== undefined)
106
+ cacheWriteTokens = (cacheWriteTokens ?? 0) + ev.cacheWriteTokens;
107
+ }
108
+ else if (ev.kind === "model") {
109
+ resolvedModel = ev.id;
92
110
  }
93
111
  else if (ev.kind === "done") {
94
112
  done = { ok: ev.ok, summary: ev.summary };
@@ -105,7 +123,8 @@ export async function runAgent(name, def, t, prompt) {
105
123
  const summary = done?.summary || lastText || tail(r.stdout, 2000) || tail(r.stderr, 2000);
106
124
  const quotaExhausted = !ok && isQuotaError(`${summary}\n${done?.summary ?? ""}\n${r.stderr}\n${tail(r.stdout, 4000)}`);
107
125
  const meta = parseResultMeta(summary) ?? parseResultMeta(lastText);
108
- return { ok, quotaExhausted, summary, meta, inputTokens, outputTokens };
126
+ return { ok, quotaExhausted, summary, meta, usageReported: inputReported && outputReported,
127
+ inputTokens: inputReported ? inputTokens : undefined, outputTokens: outputReported ? outputTokens : undefined, cacheReadTokens, cacheWriteTokens, resolvedModel };
109
128
  }
110
129
  /**
111
130
  * 在 worktree 內執行專案指令(安裝、測試、建置)。
package/dist/schemas.js CHANGED
@@ -71,6 +71,7 @@ export const TaskItem = z.object({
71
71
  description: z.string().min(1),
72
72
  dependsOn: z.array(z.string()).default([]),
73
73
  acceptance: z.array(z.string()).min(1, "每個任務至少要對應一條驗收條件"),
74
+ complexity: z.enum(["low", "medium", "high"]).optional(),
74
75
  });
75
76
  /** Agent 在 plan 階段產出的 .flow/tasks.json */
76
77
  export const TaskList = z.array(TaskItem).min(1);
@@ -97,12 +98,17 @@ export const ArbiterResult = ReviewResult.extend({
97
98
  .transform((verdict) => verdict === "reject" ? "changes_requested" : verdict),
98
99
  });
99
100
  /** 一個 agent 的定義;名稱(agents 的 key)用在 cycle 裡 */
101
+ export const ModelStrength = z.enum(["low", "medium", "high"]);
102
+ export const ModelStage = z.enum(["spec", "plan", "planReview", "planFix", "planArbiter", "taskTests", "taskCode", "taskReview", "taskFix", "fix", "review"]);
103
+ export const ModelEntry = z.object({ name: z.string().trim().min(1), strength: ModelStrength });
100
104
  export const AgentDef = z.object({
101
105
  adapter: z.enum(["claude", "codex", "gemini", "command"]),
102
106
  model: z.string().optional(),
107
+ models: z.array(ModelEntry).optional(),
103
108
  extraArgs: z.array(z.string()).default([]),
104
109
  /** 只有 command adapter 使用,`{prompt}` 會被替換成 prompt */
105
110
  command: z.array(z.string()).optional(),
111
+ modelProbe: z.array(z.string()).optional(),
106
112
  });
107
113
  /** 目標專案可選的 flow.config.json,預設值對應 Vite + TypeScript + Vitest 專案 */
108
114
  export const RepoConfig = z.object({
@@ -114,6 +120,15 @@ export const RepoConfig = z.object({
114
120
  codex: z.string().trim().min(1).optional(),
115
121
  gemini: z.string().trim().min(1).optional(),
116
122
  }).default({}),
123
+ modelSelection: z.object({
124
+ mode: z.enum(["balanced", "adaptive"]).default("balanced"),
125
+ stageStrength: z.strictObject({
126
+ spec: ModelStrength.optional(), plan: ModelStrength.optional(), planReview: ModelStrength.optional(),
127
+ planFix: ModelStrength.optional(), planArbiter: ModelStrength.optional(), taskTests: ModelStrength.optional(),
128
+ taskCode: ModelStrength.optional(), taskReview: ModelStrength.optional(), taskFix: ModelStrength.optional(),
129
+ fix: ModelStrength.optional(), review: ModelStrength.optional(),
130
+ }).default({}),
131
+ }).default({ mode: "balanced", stageStrength: {} }),
117
132
  /** 參與的 agent(順序不影響分工);未設定時取 agents 裡已安裝的 CLI */
118
133
  cycle: z.array(z.string()).min(1).optional(),
119
134
  /** review 後的修正由誰做:ring=輪到下一位;author=最後寫程式的 agent */
@@ -171,6 +186,8 @@ export const FlowRun = z.object({
171
186
  fixSource: z.enum(["verify", "review"]).optional(),
172
187
  /** 各關卡的連續失敗次數 */
173
188
  attempts: z.record(z.string(), z.number()),
189
+ modelMode: z.enum(["balanced", "adaptive"]).optional(),
190
+ modelRetryAttempts: z.record(z.string(), z.number().int().nonnegative()).optional(),
174
191
  taskIndex: z.number().int().nonnegative(),
175
192
  /** 目前任務進行到哪一步:寫測試 → 實作 → 審查 → 驗證,審查或驗證未通過時進入修正 */
176
193
  taskPhase: z.enum(["tests", "code", "review", "verify", "fix"]),
package/dist/stats.js ADDED
@@ -0,0 +1,53 @@
1
+ import { CMD_AGENT } from "./logs.js";
2
+ const time = (iso) => (iso ? new Date(iso).getTime() : NaN);
3
+ /** 從 log 的檔頭與檔尾算出每個步驟的次數、失敗與耗時,不讀 log 內容 */
4
+ export function computeStats(entries) {
5
+ const byKey = new Map();
6
+ let agentMs = 0;
7
+ let cmdMs = 0;
8
+ let unfinished = 0;
9
+ let first = Infinity;
10
+ let last = -Infinity;
11
+ for (const { header, footer } of entries) {
12
+ if (!header)
13
+ continue;
14
+ const kind = header.agent === CMD_AGENT ? "cmd" : "agent";
15
+ const key = `${kind}:${header.step}`;
16
+ const s = byKey.get(key) ?? { step: header.step, kind, runs: 0, failed: 0, unfinished: 0, totalMs: 0, maxMs: 0 };
17
+ byKey.set(key, s);
18
+ s.runs += 1;
19
+ const start = time(header.startedAt);
20
+ const end = time(footer?.endedAt);
21
+ if (!footer || Number.isNaN(start) || Number.isNaN(end)) {
22
+ s.unfinished += 1;
23
+ unfinished += 1;
24
+ continue;
25
+ }
26
+ if (!footer.ok)
27
+ s.failed += 1;
28
+ const ms = Math.max(0, end - start);
29
+ s.totalMs += ms;
30
+ s.maxMs = Math.max(s.maxMs, ms);
31
+ if (kind === "cmd")
32
+ cmdMs += ms;
33
+ else
34
+ agentMs += ms;
35
+ first = Math.min(first, start);
36
+ last = Math.max(last, end);
37
+ }
38
+ const steps = [...byKey.values()].sort((a, b) => b.totalMs - a.totalMs);
39
+ return { steps, agentMs, cmdMs, wallMs: last > first ? last - first : 0, unfinished };
40
+ }
41
+ /** 毫秒轉成 1h02m、3m05s、12s */
42
+ export function formatDuration(ms) {
43
+ const sec = Math.round(ms / 1000);
44
+ const h = Math.floor(sec / 3600);
45
+ const m = Math.floor((sec % 3600) / 60);
46
+ const s = sec % 60;
47
+ if (h)
48
+ return `${h}h${String(m).padStart(2, "0")}m`;
49
+ if (m)
50
+ return `${m}m${String(s).padStart(2, "0")}s`;
51
+ return `${s}s`;
52
+ }
53
+ //# sourceMappingURL=stats.js.map
package/dist/store.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
+ import { stageOfStep } from "./modelSelection.js";
3
4
  import { agentflowctlDir, runDir } from "./paths.js";
4
5
  import { FlowRun } from "./schemas.js";
5
6
  const statePath = (id) => join(runDir(id), "state.json");
@@ -34,21 +35,42 @@ export function addUsage(id, entry) {
34
35
  mkdirSync(runDir(id), { recursive: true });
35
36
  appendFileSync(usagePath(id), `${JSON.stringify({ at: new Date().toISOString(), ...entry })}\n`);
36
37
  }
37
- /** 依 agent 加總 token 與執行次數,方便比較各家模型 */
38
- export function usageByAgent(id) {
38
+ const emptySummary = () => ({ tokens: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, runs: 0, reportedRuns: 0, unreportedRuns: 0, legacyRuns: 0, legacyTokens: 0 });
39
+ export function listUsage(id) {
39
40
  const p = usagePath(id);
40
- const out = {};
41
- if (!existsSync(p))
42
- return out;
43
- for (const line of readFileSync(p, "utf8").split("\n").filter(Boolean)) {
44
- const e = JSON.parse(line);
45
- const key = e.agent ?? "?";
46
- const acc = (out[key] ??= { tokens: 0, runs: 0 });
41
+ return existsSync(p) ? readFileSync(p, "utf8").split("\n").filter(Boolean).map((line) => JSON.parse(line)) : [];
42
+ }
43
+ function addSummary(acc, e) {
44
+ acc.runs += 1;
45
+ if (e.usageReported === true && typeof e.inputTokens === "number" && typeof e.outputTokens === "number") {
46
+ acc.reportedRuns += 1;
47
+ acc.inputTokens += e.inputTokens ?? 0;
48
+ acc.outputTokens += e.outputTokens ?? 0;
47
49
  acc.tokens += (e.inputTokens ?? 0) + (e.outputTokens ?? 0);
48
- acc.runs += 1;
50
+ acc.cacheReadTokens += e.cacheReadTokens ?? 0;
51
+ acc.cacheWriteTokens += e.cacheWriteTokens ?? 0;
49
52
  }
53
+ else if (e.usageReported === false || e.usageReported === true)
54
+ acc.unreportedRuns += 1;
55
+ else {
56
+ acc.legacyRuns += 1;
57
+ acc.legacyTokens += (e.inputTokens ?? 0) + (e.outputTokens ?? 0);
58
+ }
59
+ }
60
+ /** 只加總明確回報的 token,舊資料的 0 不視為已回報。 */
61
+ function groupUsage(id, keyOf) {
62
+ const out = {};
63
+ for (const e of listUsage(id))
64
+ addSummary(out[keyOf(e)] ??= emptySummary(), e);
50
65
  return out;
51
66
  }
67
+ export const usageByAgent = (id) => groupUsage(id, (e) => e.agent);
68
+ export const usageByModelStage = (id) => groupUsage(id, (e) => `${e.model ?? "CLI 預設(名稱未知)"} / ${e.stage}`);
69
+ export const usageByStrength = (id) => groupUsage(id, (e) => e.strength ?? "未知");
70
+ /** 依階段鍵加總(所有任務的同一步合在一起);認不得的步驟歸為「其他」。 */
71
+ export const usageByStage = (id) => groupUsage(id, (e) => stageOfStep(e.stage) ?? "其他");
72
+ /** 依任務加總寫測試、實作、任務審查與任務修正;規格、計畫與整體階段歸為「非任務步驟」。 */
73
+ export const usageByTask = (id) => groupUsage(id, (e) => /^(T-\d+)-/.exec(e.stage)?.[1] ?? "非任務步驟");
52
74
  /** 這個 run 已執行 agent 的次數(每次執行都會記一筆用量) */
53
75
  export function agentRuns(id) {
54
76
  const p = usagePath(id);
package/dist/tasks.js CHANGED
@@ -1,5 +1,12 @@
1
1
  /** 一個任務最多做兩件事:對應的驗收條件超過這個數量就要再拆 */
2
2
  export const MAX_TASK_ACCEPTANCE = 2;
3
+ /** adaptive 的新計畫必須明確標註難度;舊 run 仍可讀取缺少欄位的 task。 */
4
+ export function validateTaskComplexity(tasks, mode) {
5
+ if (mode === "balanced")
6
+ return undefined;
7
+ const missing = tasks.filter((task) => !task.complexity).map((task) => task.id);
8
+ return missing.length ? `任務缺少 complexity:${missing.join("、")}` : undefined;
9
+ }
3
10
  /** 只取目前任務負責的驗收條件,避免每次實作都重讀整份清單。 */
4
11
  export function taskAcceptance(task, acceptance) {
5
12
  const byId = new Map(acceptance.map((item) => [item.id, item]));
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agentflowctl",
3
3
  "license": "MIT",
4
- "version": "0.11.0",
4
+ "version": "0.13.0",
5
5
  "description": "跨廠商 AI 開發 harness:Claude Code、Codex、Gemini 輪流實作、審查、修正",
6
6
  "keywords": [
7
7
  "ai",
@@ -32,6 +32,7 @@
32
32
  - 每一條驗收條件都至少要有一個任務負責;`dependsOn` 不可有循環。
33
33
  - 一個任務只做一件事,最多兩件:`acceptance` 最多列兩條驗收條件;驗收條件一條只描述一個行為。修改時若任務變大,請拆開,不要合併。
34
34
  - 測試檔名必須符合正規表示式 `{{testPattern}}`。
35
+ - 修改 task 時保留或補上 `complexity`(`low`、`medium`、`high`)。依影響範圍、技術不確定性與失敗後果重新判定,取最高等級;同步更新 .flow/plan.md 中該 task 的逐項證據與最終等級。若不同意審查者建議的等級,在「## 審查回應」引用具體程式碼或測試依據。
35
36
  </output_format>
36
37
 
37
38
  <constraints>
@@ -30,6 +30,8 @@
30
30
  1. **需求覆蓋**:規格是否完整涵蓋原始需求?有沒有遺漏、誤解,或加入需求沒要求的範圍?
31
31
  2. **驗收條件**:每一條是否具體、可以用自動化測試驗證,而且只描述一個行為?把多個行為寫在同一條的,要求拆開。有沒有重要的邊界情況或錯誤處理沒被列入?
32
32
  3. **任務拆解**:每個任務是否只做一件事(最多兩件),小到一次 TDD 循環就能完成,而且能寫出「實作前會失敗」的測試?任務太大、一次要動很多檔案或驗證很多行為的,要求拆成更小的任務。相依順序是否合理?
33
+ 同時依 .flow/plan.md 的逐項理由及實際程式碼,獨立核對每個 task 的 `complexity`:分別看影響範圍、技術不確定性與失敗後果,取最高等級。`low` 須是沿用既有做法、侷限單一行為或模組且失敗可由局部測試發現;`medium` 包括多模組或介面協調、非典型邊界、相容性或狀態遷移風險;`high` 包括跨系統契約、架構或資料模型變更、未知的關鍵技術路徑,或資料遺失、權限、難以回復的風險。不要只憑檔案數、程式碼行數或驗收條件數判定。
34
+ 理由缺漏、與程式碼不符,或高低估會影響選模時,要求修正;在 `note` 指出 task ID、具體證據、建議等級及須修改的 .flow/plan.md/.flow/tasks.json 部分。不要為缺少高價值證據的細微措辭差異要求修改。
33
35
  4. **技術方向**:是否符合專案既有的架構與慣例?有沒有更簡單的做法,或明顯的風險?
34
36
 
35
37
  措辭、格式這類不影響實作結果的小問題,不需要要求修改。
package/prompts/plan.md CHANGED
@@ -25,7 +25,7 @@
25
25
  <steps>
26
26
  1. 若 .flow/feedback.md 存在,先閱讀,並依內容修正前次的產出。
27
27
  2. 閱讀規格與相關程式碼。
28
- 3. 撰寫 .flow/plan.md:整體實作方式、要新增或修改的模組、任務順序的理由。
28
+ 3. 撰寫 .flow/plan.md:整體實作方式、要新增或修改的模組、任務順序的理由;逐一記錄 task 的難度判定依據。
29
29
  4. 撰寫 .flow/tasks.json。
30
30
  </steps>
31
31
 
@@ -38,6 +38,7 @@
38
38
  "id": "T-1",
39
39
  "title": "建立表單驗證 schema",
40
40
  "description": "具體要做什麼、要動哪些檔案、測試要驗證什麼行為",
41
+ "complexity": "low",
41
42
  "dependsOn": [],
42
43
  "acceptance": ["AC-1"]
43
44
  }
@@ -51,6 +52,11 @@
51
52
  - `title` 用一句話說出這件事;需要用「並且」「以及」串起來的,就是兩個任務。
52
53
  - `description` 寫清楚要動哪些檔案、測試要驗證哪個行為,以及這個任務不做什麼。
53
54
  - 每個任務都必須能寫出「在實作前會失敗」的測試;純設定或重構類工作請併入相關任務。
55
+ - 每個任務先檢查預計修改的程式碼,再依「影響範圍、技術不確定性、失敗後果」三個面向判定 `complexity`,取其中最高的等級;不要只憑檔案數、程式碼行數或驗收條件數判定。
56
+ - `low`:沿用現有做法,變更侷限在單一行為或模組,失敗容易由局部測試發現且不影響既有資料或對外契約。
57
+ - `medium`:需要協調多個模組或既有介面、處理非典型邊界,或有相容性與狀態遷移風險,但可依已知做法實作與驗證。
58
+ - `high`:涉及跨系統契約、架構或資料模型變更;關鍵技術路徑尚不確定;或失敗可能造成資料遺失、權限問題或難以回復的影響。任一面向符合就標 `high`。
59
+ - 在 .flow/plan.md 逐一列出 task ID、三個面向的具體證據與最終等級;缺少證據時先查閱相關程式碼,不要一律標 `low` 或憑猜測調高。
54
60
  - 測試檔名必須符合正規表示式 `{{testPattern}}`。
55
61
  - 每一條驗收條件都至少要有一個任務負責;`dependsOn` 不可有循環。
56
62
  </guidelines>