agentflowctl 0.10.0 → 0.12.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.
@@ -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,9 @@ 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
+ if (num(usage.input_tokens) !== undefined || num(usage.output_tokens) !== undefined) {
63
+ out.push({ kind: "usage", inputTokens: num(usage.input_tokens), outputTokens: num(usage.output_tokens) });
64
+ }
59
65
  out.push({ kind: "done", ok: ev.is_error !== true, summary: str(ev.result) });
60
66
  }
61
67
  return out;
@@ -30,7 +30,9 @@ 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
+ out.push({ kind: "usage", inputTokens: num(usage.input_tokens), outputTokens: num(usage.output_tokens) });
35
+ }
34
36
  }
35
37
  else if (ev.type === "turn.failed" || ev.type === "error") {
36
38
  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,25 @@ 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 { agentRuns, getRun, listRuns, listSubstitutions, saveRun, usageByAgent, usageByModelStage, usageByStage, usageByStrength, usageByTask } from "./store.js";
17
17
  import { readJsonFile } from "./util.js";
18
18
  import { openActions, readHandoff } from "./handoff.js";
19
19
  import { stopReport } from "./stopReport.js";
20
20
  import { runSetup, SETUP_ADAPTERS } from "./setup.js";
21
21
  import { addAgent, readRawConfig, removeAgent, setAgent, setCycle, writeRawConfig } from "./agentConfig.js";
22
+ import { addModel, removeModel, setModelMode, setModelStrength, setStageStrength } from "./modelConfig.js";
23
+ import { DEFAULT_STAGE_STRENGTH, effectiveStageStrengths, validateAdaptiveConfig } from "./modelSelection.js";
24
+ import { probeModel } from "./modelProbe.js";
25
+ function printUsage(title, rows) {
26
+ if (!rows.length)
27
+ return;
28
+ console.log(`\n${title}`);
29
+ for (const [key, c] of rows) {
30
+ const value = c.reportedRuns ? `輸入 ${c.inputTokens}、輸出 ${c.outputTokens}、合計 ${c.tokens} tokens` : "未回報或回報狀態不明";
31
+ console.log(` ${key}: ${value};${c.runs} 次(未回報 ${c.unreportedRuns}、舊紀錄不明 ${c.legacyRuns})`);
32
+ }
33
+ }
22
34
  function mustGetRun(id) {
23
35
  const run = getRun(id);
24
36
  if (!run)
@@ -107,6 +119,7 @@ program
107
119
  .option("--max-agent-runs <n>", "單一 run 最多執行幾次 agent(預設取 flow.config.json 的 maxAgentRuns)")
108
120
  .option("--manual-plan", "計畫通過 AI 審查後,仍停下來等你確認", false)
109
121
  .option("--cycle <agents>", "參與的 agent,例如 claude,codex,gemini(順序不影響分工)")
122
+ .option("--model-mode <mode>", "這次 run 的模型模式:balanced 或 adaptive")
110
123
  .action(async (opts) => {
111
124
  const requirement = opts.reqFile ? readFileSync(opts.reqFile, "utf8") : opts.req;
112
125
  if (!requirement?.trim())
@@ -116,12 +129,17 @@ program
116
129
  if (!base)
117
130
  throw new Error("目前不在任何分支上,請用 --base 指定基底分支");
118
131
  const cycle = await resolveCycle(opts.cycle);
132
+ const cfg = loadRepoConfig();
133
+ const modelMode = opts.modelMode ?? cfg.modelSelection.mode;
134
+ if (modelMode !== "balanced" && modelMode !== "adaptive")
135
+ throw new Error(`未知的模型模式:${modelMode}`);
136
+ if (modelMode === "adaptive")
137
+ validateAdaptiveConfig(cfg, cycle);
119
138
  const id = `f-${Date.now().toString(36)}`;
120
139
  const branch = `flow/${id}`;
121
140
  console.log(`[${id}] 🌿 從 ${base} 建立 worktree(分支 ${branch})`);
122
141
  await addWorktree(root, worktreeDir(id), base, branch);
123
142
  console.log(`[${id}] 🤝 參與的 agent:${cycle.join("、")}(角色隨機分配)`);
124
- const cfg = loadRepoConfig();
125
143
  const now = new Date().toISOString();
126
144
  // worktree 一建好就寫入紀錄:之後在任何地方中斷,都能用 resume 接續或用 clean 清掉
127
145
  const run = saveRun({
@@ -134,6 +152,7 @@ program
134
152
  maxAgentRuns: opts.maxAgentRuns ? Number(opts.maxAgentRuns) : cfg.maxAgentRuns,
135
153
  cycle,
136
154
  attempts: {},
155
+ modelMode,
137
156
  taskIndex: 0,
138
157
  taskPhase: "tests",
139
158
  createdAt: now,
@@ -162,13 +181,15 @@ program
162
181
  .option("--max-agent-runs <n>", "調整 agent 執行次數上限")
163
182
  .action(async (id, opts) => {
164
183
  let run = mustGetRun(id);
184
+ if (run.modelMode === "adaptive")
185
+ validateAdaptiveConfig(loadRepoConfig(), run.cycle);
165
186
  if (opts.maxAgentRuns)
166
187
  run = { ...run, maxAgentRuns: Number(opts.maxAgentRuns) };
167
188
  if (run.stage === "paused") {
168
189
  run = { ...run, stage: run.pausedStage ?? "spec", pausedStage: undefined, pauseReason: undefined };
169
190
  }
170
191
  if (run.stage === "failed") {
171
- run = { ...run, stage: run.failedStage ?? "spec", attempts: {}, failedStage: undefined, failureReason: undefined };
192
+ run = { ...run, stage: run.failedStage ?? "spec", attempts: {}, modelRetryAttempts: {}, failedStage: undefined, failureReason: undefined };
172
193
  }
173
194
  await drive(saveRun(run));
174
195
  });
@@ -215,8 +236,27 @@ program
215
236
  if (Object.keys(byAgent).length) {
216
237
  console.log("\n各 agent 用量");
217
238
  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`);
239
+ console.log(` ${agent.padEnd(10)} ${String(c.runs).padStart(3)} 次 ${String(c.tokens).padStart(9)} 已回報 tokens(未回報 ${c.unreportedRuns}、舊紀錄不明 ${c.legacyRuns}${c.legacyTokens ? `,原始數字 ${c.legacyTokens} tokens` : ""})`);
240
+ }
241
+ }
242
+ const byStage = usageByStage(id);
243
+ const stageOrder = [...Object.keys(DEFAULT_STAGE_STRENGTH), "其他"];
244
+ printUsage("各階段用量(同一步驟的所有任務合計)", Object.entries(byStage).sort(([a], [b]) => stageOrder.indexOf(a) - stageOrder.indexOf(b)));
245
+ const taskNo = (key) => /^T-(\d+)$/.exec(key) ? Number(key.slice(2)) : Infinity;
246
+ printUsage("各任務用量(寫測試、實作、任務審查、任務修正)", Object.entries(usageByTask(id)).sort(([a], [b]) => taskNo(a) - taskNo(b)));
247
+ printUsage("各模型與步驟用量(只加總明確回報)", Object.entries(usageByModelStage(id)));
248
+ const byStrength = usageByStrength(id);
249
+ const reportedTotal = Object.values(byStrength).reduce((sum, entry) => sum + entry.tokens, 0);
250
+ if (Object.keys(byStrength).length) {
251
+ console.log("\n模型強度用量(占比只計入明確回報)");
252
+ for (const strength of ["low", "medium", "high", "未知"]) {
253
+ const entry = byStrength[strength];
254
+ if (!entry)
255
+ continue;
256
+ const share = reportedTotal ? `${(entry.tokens / reportedTotal * 100).toFixed(1)}%` : "無法計算";
257
+ console.log(` ${strength}: ${entry.tokens} tokens,占比 ${share},呼叫 ${entry.runs} 次(未回報 ${entry.unreportedRuns}、舊紀錄不明 ${entry.legacyRuns})`);
219
258
  }
259
+ console.log(` 高強度呼叫:${byStrength.high?.runs ?? 0} 次`);
220
260
  }
221
261
  const subs = listSubstitutions(id);
222
262
  if (subs.length) {
@@ -237,6 +277,18 @@ program
237
277
  // ───────────── agent 管理:讀寫 flow.config.json 的 agents 與 cycle ─────────────
238
278
  const configPath = () => join(projectRoot(), "flow.config.json");
239
279
  const collect = (value, prev = []) => [...prev, value];
280
+ const parseStrength = (value) => {
281
+ const result = ModelStrength.safeParse(value);
282
+ if (!result.success)
283
+ throw new Error(`未知的模型強度:${value}(請使用 low、medium 或 high)`);
284
+ return result.data;
285
+ };
286
+ const parseModelStage = (value) => {
287
+ const result = ModelStage.safeParse(value);
288
+ if (!result.success)
289
+ throw new Error(`未知的 LLM 階段:${value}`);
290
+ return result.data;
291
+ };
240
292
  function applyEdit(edit, done) {
241
293
  const before = readRawConfig(configPath());
242
294
  const { cfg, changes } = edit(before);
@@ -279,8 +331,9 @@ agent
279
331
  .requiredOption("--adapter <adapter>", "claude、codex、gemini 或 command")
280
332
  .option("--model <model>", "模型名稱")
281
333
  .option("--extra-arg <arg>", "額外參數,可重複;以 - 開頭時寫成 --extra-arg=--sandbox", collect)
334
+ .option("--model-probe-arg <arg>", "自訂 command 的模型探測命令參數,可重複", collect)
282
335
  .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`);
336
+ 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
337
  });
285
338
  agent
286
339
  .command("set <name> [command...]")
@@ -288,8 +341,9 @@ agent
288
341
  .option("--adapter <adapter>", "claude、codex、gemini 或 command")
289
342
  .option("--model <model>", "模型名稱")
290
343
  .option("--extra-arg <arg>", "額外參數,可重複,會整個取代原本的設定", collect)
344
+ .option("--model-probe-arg <arg>", "自訂 command 的模型探測命令參數,可重複,會整個取代", collect)
291
345
  .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}`);
346
+ applyEdit((cfg) => setAgent(cfg, name, { adapter: opts.adapter, model: opts.model, extraArgs: opts.extraArg, modelProbe: opts.modelProbeArg, command: command.length ? command : undefined }), `已更新 ${name}`);
293
347
  });
294
348
  agent
295
349
  .command("remove <name>")
@@ -346,6 +400,86 @@ agent
346
400
  console.log("");
347
401
  await doctor();
348
402
  });
403
+ // ───────────── 模型清單與各階段強度 ─────────────
404
+ const model = program.command("model").description("設定模型強度並檢查目前帳號能否呼叫模型");
405
+ model.command("add <agent> <name>")
406
+ .requiredOption("--strength <strength>", "low、medium 或 high")
407
+ .action(async (agent, name, opts) => {
408
+ const strength = parseStrength(opts.strength);
409
+ const before = readRawConfig(configPath());
410
+ const cfg = loadRepoConfig();
411
+ const def = cfg.agents[agent];
412
+ if (!def)
413
+ throw new Error(`未定義的 agent:${agent}`);
414
+ const next = addModel(before, agent, name, strength);
415
+ console.log("模型檢查會送出最短請求,可能耗用少量 token。");
416
+ const checked = await probeModel(def, name);
417
+ if (checked.status !== "ok")
418
+ throw new Error(`${agent} 的模型 ${name} 未加入:${checked.reason}`);
419
+ writeRawConfig(configPath(), next);
420
+ console.log(`✅ 已新增 ${agent} 的模型 ${name}(${strength})${checked.resolvedModel ? ` → ${checked.resolvedModel}` : ""}${def.adapter === "command" ? "(由自訂探測命令回報)" : ""};探測可能耗用少量 token`);
421
+ });
422
+ model.command("set <agent> <name>")
423
+ .requiredOption("--strength <strength>", "low、medium 或 high")
424
+ .action((agent, name, opts) => {
425
+ writeRawConfig(configPath(), setModelStrength(readRawConfig(configPath()), agent, name, parseStrength(opts.strength)));
426
+ console.log(`✅ 已將 ${agent} 的模型 ${name} 強度設為 ${opts.strength};此指令不重驗可用性`);
427
+ });
428
+ model.command("remove <agent> <name>").action((agent, name) => {
429
+ writeRawConfig(configPath(), removeModel(readRawConfig(configPath()), agent, name));
430
+ console.log(`✅ 已移除 ${agent} 的模型 ${name}`);
431
+ });
432
+ model.command("list [agent]").action((agent) => {
433
+ const cfg = loadRepoConfig();
434
+ const names = agent ? [agent] : Object.keys(cfg.agents);
435
+ for (const name of names) {
436
+ const def = cfg.agents[name];
437
+ if (!def)
438
+ throw new Error(`未定義的 agent:${name}`);
439
+ console.log(`${name}(${def.adapter})`);
440
+ for (const item of def.models ?? [])
441
+ console.log(` ${item.name} ${item.strength}`);
442
+ if (!def.models?.length)
443
+ console.log(" 尚未設定模型");
444
+ }
445
+ console.log("清單只顯示設定內容;要確認當前帳號是否可用,請執行 model check。");
446
+ });
447
+ model.command("check [agent]").action(async (agent) => {
448
+ const cfg = loadRepoConfig();
449
+ const names = agent ? [agent] : Object.keys(cfg.agents);
450
+ console.log("模型重驗會逐一送出短請求,可能耗用少量 token。");
451
+ for (const name of names) {
452
+ const def = cfg.agents[name];
453
+ if (!def)
454
+ throw new Error(`未定義的 agent:${name}`);
455
+ for (const item of def.models ?? []) {
456
+ const checked = await probeModel(def, item.name);
457
+ const at = new Date().toISOString();
458
+ const mark = checked.status === "ok" ? "✅" : checked.status === "unverifiable" ? "⚪" : "❌";
459
+ const result = checked.status === "ok" ? `可呼叫${checked.resolvedModel ? ` → ${checked.resolvedModel}` : ""}${def.adapter === "command" ? "(由自訂探測命令回報)" : ""}` : checked.reason;
460
+ console.log(`${mark} ${at} ${name} ${item.name}: ${result}`);
461
+ }
462
+ }
463
+ });
464
+ model.command("mode [mode]").action((mode) => {
465
+ if (!mode)
466
+ return console.log(`模型模式:${loadRepoConfig().modelSelection.mode}`);
467
+ if (mode !== "balanced" && mode !== "adaptive")
468
+ throw new Error(`未知的模型模式:${mode}`);
469
+ writeRawConfig(configPath(), setModelMode(readRawConfig(configPath()), mode));
470
+ console.log(`✅ 模型模式已設為 ${mode}`);
471
+ });
472
+ model.command("stage [stage] [strength]").action((stage, strength) => {
473
+ if (!stage || !strength) {
474
+ const all = effectiveStageStrengths(loadRepoConfig());
475
+ const stages = stage ? [parseModelStage(stage)] : Object.keys(all);
476
+ for (const s of stages)
477
+ console.log(`${s}: ${all[s].strength}${all[s].custom ? "(自訂)" : "(預設)"}`);
478
+ return;
479
+ }
480
+ writeRawConfig(configPath(), setStageStrength(readRawConfig(configPath()), parseModelStage(stage), parseStrength(strength)));
481
+ console.log(`✅ ${stage} 的最低強度已設為 ${strength}`);
482
+ });
349
483
  /** 檢查設定的 agent 是否已安裝,印出參與的 agent 與主要設定 */
350
484
  async function doctor() {
351
485
  const cfg = loadRepoConfig();
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 });
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) {
@@ -215,7 +225,7 @@ function announceTasks(run, ordered) {
215
225
  info(run, `📋 共 ${ordered.length} 個任務:${ordered.map((t) => t.id).join(" → ")}`);
216
226
  ordered.forEach((t, i) => {
217
227
  const a = taskAgents(run.cycle, i, cfg.tddSplit, run.id);
218
- info(run, ` ${t.id} 測試:${a.tests} 實作:${a.code}`);
228
+ info(run, ` ${t.id} 測試:${a.tests} 實作:${a.code} 審查:${a.review}`);
219
229
  });
220
230
  }
221
231
  /** 計畫定案後:預設直接開始實作;--manual-plan 時才停下來等人 */
@@ -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")}`;
@@ -341,7 +356,8 @@ async function planFixStage(run) {
341
356
  const handoffError = finishHandoff(run, outcome, "writer");
342
357
  if (handoffError) {
343
358
  restorePlan(run, snap);
344
- return retry(run, "plan-fix", handoffError, "plan_fix");
359
+ // 計畫已還原,要保留原本的審查意見,否則下一次修正不知道要改什麼
360
+ return retry(run, "plan-fix", `${feedback}\n\n另外,交接回覆不合格,本次修改已還原:${handoffError}`, "plan_fix");
345
361
  }
346
362
  acceptPlan(run, ordered);
347
363
  writeFileSync(flowFile(run, "feedback.md"), feedback); // 保留審查意見,讓下一輪審查者知道上次提了什麼
@@ -529,6 +545,8 @@ async function taskReviewStep(run, task, progress, taskJson, acceptanceJson) {
529
545
  prompt: (reviewer, authors) => renderPrompt("task-review", { reviewer, authors, task: taskJson, acceptance: acceptanceJson }),
530
546
  saveAs: (reviewer) => `review-${task.id}-${reviewer}.json`,
531
547
  testAuthor: run.lastTestsAuthor,
548
+ // 輪流交換角色:優先由排定的審查者審查,讓各家用量平均
549
+ prefer: taskAgents(run.cycle, run.taskIndex, loadRepoConfig().tddSplit, run.id).review,
532
550
  // 未結交接事項可能屬於後面的任務,由最後的整體審查把關
533
551
  gate: false,
534
552
  runKey: `${task.id}:review-run`,
@@ -536,7 +554,7 @@ async function taskReviewStep(run, task, progress, taskJson, acceptanceJson) {
536
554
  });
537
555
  if ("run" in result)
538
556
  return result.run;
539
- const reviewed = succeed(run, `${task.id}:review-run`, "implement");
557
+ const reviewed = succeed(result.state, `${task.id}:review-run`, "implement");
540
558
  if (!result.objector)
541
559
  return { ...succeed(reviewed, key, "implement"), taskPhase: "verify" };
542
560
  return {
@@ -646,7 +664,7 @@ async function applyFix(run, opts) {
646
664
  const handoffError = finishHandoff(run, outcome, "writer");
647
665
  if (handoffError) {
648
666
  await resetTo(repo, before);
649
- return again(handoffError);
667
+ return again(`${feedback}\n\n另外,交接回覆不合格,本次修正已還原:${handoffError}`);
650
668
  }
651
669
  return { agent: actual };
652
670
  }
@@ -662,7 +680,7 @@ async function fixStage(run) {
662
680
  if ("run" in result)
663
681
  return result.run;
664
682
  // 修正者成為新的作者,下一輪審查會換成別人
665
- return { ...to(run, "verify"), lastWriter: result.agent };
683
+ return { ...succeed(run, "fix", "verify"), lastWriter: result.agent };
666
684
  }
667
685
  /**
668
686
  * 由作者以外的審查小組審查 base 之後的變更;回傳第一位要求修改的審查者與所有意見,
@@ -672,7 +690,7 @@ async function fixStage(run) {
672
690
  async function codeReview(run, opts) {
673
691
  const cfg = loadRepoConfig();
674
692
  const repo = worktreeDir(run.id);
675
- const panel = reviewers(run.cycle, run.lastWriter, cfg.reviewQuorum, opts.seed, opts.testAuthor);
693
+ const panel = reviewers(run.cycle, run.lastWriter, cfg.reviewQuorum, opts.seed, opts.testAuthor, opts.prefer);
676
694
  writeFileSync(flowFile(run, "diff.patch"), await git(repo, "diff", `${opts.base}...HEAD`));
677
695
  const authors = [...new Set((await git(repo, "log", "--format=%s", `${opts.base}..HEAD`)).match(/\[[^\]]+\]$/gm) ?? [])]
678
696
  .map((s) => s.slice(1, -1));
@@ -691,14 +709,16 @@ async function codeReview(run, opts) {
691
709
  if (tampered.length)
692
710
  info(run, ` ↩️ 已還原審查者修改的檔案:${tampered.join(", ")}`);
693
711
  if (!r.ok)
694
- 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) };
695
713
  const review = readJsonFile(flowFile(run, "review.json"), ConsistentReviewResult);
696
714
  if (!review.ok)
697
- 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) };
698
716
  const gate = opts.gate ? { target: "code", verdict: review.data.verdict } : undefined;
699
717
  const handoffError = finishHandoff(run, outcome, "reviewer", gate);
700
718
  if (handoffError)
701
- 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);
702
722
  renameSync(flowFile(run, "review.json"), flowFile(run, opts.saveAs(reviewer)));
703
723
  if (review.data.verdict === "approve") {
704
724
  info(run, ` ✓ ${reviewer} 核准`);
@@ -710,7 +730,11 @@ async function codeReview(run, opts) {
710
730
  .filter((i) => i.status !== "met")
711
731
  .map((i) => reviewIssue(i.criterion, i.status, i.note))));
712
732
  }
713
- 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 };
714
738
  }
715
739
  async function reviewStage(run) {
716
740
  const result = await codeReview(run, {
@@ -727,9 +751,9 @@ async function reviewStage(run) {
727
751
  if ("run" in result)
728
752
  return result.run;
729
753
  if (!result.objector)
730
- return succeed(run, "review", "pr");
754
+ return succeed(result.state, "review", "pr");
731
755
  return {
732
- ...retry(run, "review", `程式碼審查要求修改:\n\n${result.issues.join("\n\n")}`, "fix"),
756
+ ...retry(result.state, "review", `程式碼審查要求修改:\n\n${result.issues.join("\n\n")}`, "fix"),
733
757
  fixSource: "review",
734
758
  lastReviewer: result.objector,
735
759
  };
package/dist/handoff.js CHANGED
@@ -52,10 +52,9 @@ export function previewHandoff(ledger, callKey, source, response, role) {
52
52
  const issue = issues.find((item) => item.id === disposition.id);
53
53
  if (!issue)
54
54
  throw new Error(`找不到交接事項:${disposition.id}`);
55
- if (issue.kind !== "action")
56
- throw new Error(`參考資訊不可結案:${disposition.id}`);
57
- if (issue.status === "resolved" || issue.status === "accepted")
58
- throw new Error(`交接事項已結案:${disposition.id}`);
55
+ // 參考資訊沒有結案流程,已結案的事項也不需再處置:略過即可,不讓整個步驟因此重試
56
+ if (issue.kind !== "action" || issue.status === "resolved" || issue.status === "accepted")
57
+ continue;
59
58
  if (role === "writer" && disposition.status !== "proposed_resolved")
60
59
  throw new Error("作者只能提出已修正,不能自行結案");
61
60
  if (role === "reviewer" && disposition.status === "proposed_resolved")
@@ -74,13 +73,20 @@ export function mergeHandoff(id, callKey, source, response, role) {
74
73
  /** 只把目前步驟需要處理的事項投影給 agent。 */
75
74
  export function prepareHandoff(id, _callKey, target, blind) {
76
75
  const items = readHandoff(id).issues.filter((item) => item.targetStage === target && (item.kind === "info" || item.status === "open" || item.status === "proposed_resolved"));
77
- const lines = items.map((item) => {
76
+ const render = (item) => {
78
77
  const source = blind ? "" : `\n來源:${item.source.stage}/${item.source.agent}`;
79
78
  const resolution = item.resolution ? `\n處理理由:${item.resolution.reason}\n處理證據:${item.resolution.evidence}` : "";
80
- return `## ${item.id}:${item.summary}\n證據:${item.evidence}\n狀態:${item.status}${resolution}${source}`;
81
- });
79
+ const status = item.kind === "action" ? `\n狀態:${item.status}` : "";
80
+ return `### ${item.id}:${item.summary}\n類型:${item.kind}\n證據:${item.evidence}${status}${resolution}${source}`;
81
+ };
82
+ const actions = items.filter((item) => item.kind === "action").map(render);
83
+ const infos = items.filter((item) => item.kind !== "action").map(render);
84
+ const sections = [
85
+ actions.length ? `## 待處理事項(action,可在 dispositions 處置)\n\n${actions.join("\n\n")}` : "",
86
+ infos.length ? `## 參考資訊(info,只供參考,不要放進 dispositions)\n\n${infos.join("\n\n")}` : "",
87
+ ].filter(Boolean);
82
88
  mkdirSync(flowDir(id), { recursive: true });
83
- writeFileSync(join(flowDir(id), "handoff-context.md"), `# 待處理交接事項\n\n${lines.length ? lines.join("\n\n") : "目前沒有待處理事項。"}\n`);
89
+ writeFileSync(join(flowDir(id), "handoff-context.md"), `# 待處理交接事項\n\n${sections.length ? sections.join("\n\n") : "目前沒有待處理事項。"}\n`);
84
90
  rmSync(responsePath(id), { force: true });
85
91
  }
86
92
  export function validateHandoffResponse(id) {
@@ -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