agentflowctl 0.13.1 → 0.14.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.
@@ -0,0 +1,85 @@
1
+ import { RetryCategories } from "./store.js";
2
+ export const RETRY_LABEL = {
3
+ agent_error: "Agent 執行失敗",
4
+ missing_artifact: "缺少必要檔案",
5
+ format_invalid: "輸出格式錯誤",
6
+ handoff_invalid: "交接回覆不合格",
7
+ open_handoff: "未結交接事項",
8
+ review_changes: "審查要求修改",
9
+ arbitration_revise: "仲裁要求修訂",
10
+ plan_tampered: "改動已鎖定的計畫檔",
11
+ tests_not_written: "未寫測試",
12
+ tests_not_red: "紅燈測試未失敗",
13
+ tests_modified: "實作改了測試",
14
+ tests_not_green: "測試仍未通過",
15
+ tests_deleted: "刪除測試檔",
16
+ checks_failed: "專案檢查失敗",
17
+ };
18
+ export const FAILURE_LABEL = {
19
+ retry_limit: "關卡重試達上限",
20
+ arbitration_stop: "仲裁停止",
21
+ open_handoff: "PR 前仍有未結事項",
22
+ agent_budget: "agent 次數用完",
23
+ error: "執行時發生錯誤",
24
+ cancelled: "使用者取消",
25
+ unknown: "未分類(舊 run)",
26
+ };
27
+ /** 讀到較新版本寫入、這一版不認得的分類時,直接顯示代碼 */
28
+ export function retryLabel(category) {
29
+ return RETRY_LABEL[category] ?? category;
30
+ }
31
+ export function failureLabel(category) {
32
+ return FAILURE_LABEL[(category ?? "unknown")] ?? category;
33
+ }
34
+ const TASK_PHASES = ["tests", "code", "review", "review-run", "verify", "fix"];
35
+ /** 任務關卡的 key 帶有 task id(T1:tests),跨 run 彙總時合併成同一種關卡 */
36
+ export function gateOf(key) {
37
+ const i = key.lastIndexOf(":");
38
+ if (i < 0)
39
+ return key;
40
+ const phase = key.slice(i + 1);
41
+ return TASK_PHASES.includes(phase) ? `任務:${phase}` : key;
42
+ }
43
+ const OUTCOMES = ["done", "failed", "paused", "awaiting_approval"];
44
+ export function outcomeOf(stage) {
45
+ return OUTCOMES.includes(stage) ? stage : "active";
46
+ }
47
+ const countDesc = (a, b) => b.count - a.count;
48
+ export function computeInsights(runs) {
49
+ const byOutcome = { done: 0, failed: 0, paused: 0, awaiting_approval: 0, active: 0 };
50
+ const failures = new Map();
51
+ const cat = new Map();
52
+ const gates = new Map();
53
+ for (const run of runs) {
54
+ const outcome = outcomeOf(run.stage);
55
+ byOutcome[outcome] += 1;
56
+ if (outcome === "failed") {
57
+ const f = run.failureCategory ?? "unknown";
58
+ failures.set(f, (failures.get(f) ?? 0) + 1);
59
+ }
60
+ for (const r of run.retries) {
61
+ const cur = cat.get(r.category) ?? { count: 0, finals: 0 };
62
+ cur.count += 1;
63
+ if (r.final)
64
+ cur.finals += 1;
65
+ cat.set(r.category, cur);
66
+ const gate = gateOf(r.key);
67
+ gates.set(gate, (gates.get(gate) ?? 0) + 1);
68
+ }
69
+ }
70
+ const topOf = (retries) => {
71
+ const n = new Map();
72
+ for (const r of retries)
73
+ n.set(r.category, (n.get(r.category) ?? 0) + 1);
74
+ return [...n.entries()].sort((a, b) => b[1] - a[1] || RetryCategories.indexOf(a[0]) - RetryCategories.indexOf(b[0]))[0]?.[0];
75
+ };
76
+ return {
77
+ runCount: runs.length,
78
+ byOutcome,
79
+ byFailure: [...failures.entries()].map(([category, count]) => ({ category, count })).sort(countDesc),
80
+ byCategory: [...cat.entries()].map(([category, v]) => ({ category, ...v })).sort(countDesc),
81
+ byGate: [...gates.entries()].map(([gate, count]) => ({ gate, count })).sort(countDesc),
82
+ runs: runs.map((r) => ({ id: r.id, stage: r.stage, retries: r.retries.length, topCategory: topOf(r.retries) })),
83
+ };
84
+ }
85
+ //# sourceMappingURL=insights.js.map
@@ -8,27 +8,35 @@ export function effectiveStageStrengths(cfg) {
8
8
  const custom = cfg.modelSelection.stageStrength;
9
9
  return Object.fromEntries(Object.keys(DEFAULT_STAGE_STRENGTH).map((stage) => [stage, { strength: custom[stage] ?? DEFAULT_STAGE_STRENGTH[stage], custom: custom[stage] !== undefined }]));
10
10
  }
11
+ /**
12
+ * 審查失敗計數的鍵。scope 是任務群 id:一群失敗只讓同一群的下一次審查升級,不牽動同一輪的其他群。
13
+ * 鍵一律以步驟名稱開頭,clearModelReviewStage 才能用前綴一次清掉。
14
+ */
15
+ function reviewFailureKey(step, reviewer, scope) {
16
+ return scope ? `${step}:${scope}:${reviewer}` : `${step}:${reviewer}`;
17
+ }
11
18
  /** 小組審查失敗只影響該審查者的下一次選模。 */
12
- export function recordModelReviewFailure(run, step, reviewer) {
13
- const key = `${step}:${reviewer}`;
19
+ export function recordModelReviewFailure(run, step, reviewer, scope) {
20
+ const key = reviewFailureKey(step, reviewer, scope);
14
21
  return { ...run, modelRetryAttempts: { ...run.modelRetryAttempts, [key]: (run.modelRetryAttempts?.[key] ?? 0) + 1 } };
15
22
  }
16
- export function clearModelReviewFailure(run, step, reviewer) {
23
+ export function clearModelReviewFailure(run, step, reviewer, scope) {
17
24
  const attempts = { ...run.modelRetryAttempts };
18
- delete attempts[`${step}:${reviewer}`];
25
+ delete attempts[reviewFailureKey(step, reviewer, scope)];
19
26
  return { ...run, modelRetryAttempts: attempts };
20
27
  }
21
28
  export function clearModelReviewStage(run, step) {
22
- const attempts = Object.fromEntries(Object.entries(run.modelRetryAttempts ?? {}).filter(([key]) => !key.startsWith(`${step}:`)));
29
+ const prefixes = step === "plan-review" ? ["plan-review:", "plan-review-group:"] : ["review:"];
30
+ const attempts = Object.fromEntries(Object.entries(run.modelRetryAttempts ?? {}).filter(([key]) => !prefixes.some((prefix) => key.startsWith(prefix))));
23
31
  return { ...run, modelRetryAttempts: attempts };
24
32
  }
25
- /** 把步驟名稱(例如 `T-1-code`、`plan-review`)對應到階段鍵;不認得時回傳 undefined。 */
33
+ /** 把步驟名稱(例如 `T-1-code`、`plan-review`、`plan-review-group`)對應到階段鍵;不認得時回傳 undefined。 */
26
34
  export function stageOfStep(step) {
27
35
  const task = /^T-\d+-(tests|code|review|fix)$/.exec(step);
28
36
  if (task)
29
37
  return { tests: "taskTests", code: "taskCode", review: "taskReview", fix: "taskFix" }[task[1]];
30
38
  const stage = {
31
- spec: "spec", plan: "plan", "plan-review": "planReview", "plan-fix": "planFix",
39
+ spec: "spec", plan: "plan", "plan-review": "planReview", "plan-review-group": "planReview", "plan-fix": "planFix",
32
40
  "plan-arbiter": "planArbiter", fix: "fix", review: "review",
33
41
  };
34
42
  return stage[step];
@@ -39,10 +47,10 @@ function stepStage(step) {
39
47
  throw new Error(`未知的 LLM 步驟:${step}`);
40
48
  return found;
41
49
  }
42
- function escalation(run, stage, step, reviewer) {
50
+ function escalation(run, stage, step, reviewer, scope) {
43
51
  const a = run.attempts;
44
52
  if (stage === "planReview" || stage === "review")
45
- return run.modelRetryAttempts?.[`${step}:${reviewer ?? ""}`] ?? 0;
53
+ return run.modelRetryAttempts?.[reviewFailureKey(step, reviewer ?? "", scope)] ?? 0;
46
54
  if (stage === "planArbiter")
47
55
  return 0;
48
56
  if (stage === "planFix")
@@ -61,7 +69,7 @@ function escalation(run, stage, step, reviewer) {
61
69
  return a[step] ?? 0;
62
70
  }
63
71
  /** 每次呼叫前依目前設定與 run 狀態選擇模型;不修改任何狀態。 */
64
- export function selectModel(run, cfg, agent, step, complexity, reviewer) {
72
+ export function selectModel(run, cfg, agent, step, complexity, reviewer, scope) {
65
73
  const def = cfg.agents[agent];
66
74
  if (!def)
67
75
  throw new Error(`未定義的 agent:${agent}`);
@@ -75,7 +83,7 @@ export function selectModel(run, cfg, agent, step, complexity, reviewer) {
75
83
  const stage = stepStage(step);
76
84
  const floor = effectiveStageStrengths(cfg)[stage].strength;
77
85
  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))];
86
+ const targetStrength = STRENGTHS[Math.min(2, baseline + escalation(run, stage, step, reviewer, scope))];
79
87
  const candidates = def.models.filter((m) => LEVEL[m.strength] >= LEVEL[targetStrength]);
80
88
  const min = Math.min(...candidates.map((m) => LEVEL[m.strength]));
81
89
  const chosen = candidates.find((m) => LEVEL[m.strength] === min)
package/dist/paths.js CHANGED
@@ -18,6 +18,10 @@ export const runsDir = () => join(agentflowctlDir(), "runs");
18
18
  export const runDir = (id) => join(runsDir(), id);
19
19
  export const logDir = (id) => join(runDir(id), "logs");
20
20
  export const handoffPath = (id) => join(runDir(id), "handoff.json");
21
+ /** 分層計畫審查的結果與本輪進度:是關卡輸入,放在 worktree 外,agent 改不到 */
22
+ export const planReviewStatePath = (id) => join(runDir(id), "plan-review-state.json");
23
+ /** 已交付仲裁、尚未得出裁決:暫停後 resume 直接回到仲裁。放在 worktree 外,agent 無法偽造 */
24
+ export const planArbitrationPath = (id) => join(runDir(id), "plan-arbitration.json");
21
25
  /** 每個 run 一個 git worktree,Agent 只在這裡工作,不碰你正在編輯的檔案 */
22
26
  export const worktreesDir = () => join(agentflowctlDir(), "worktrees");
23
27
  export const worktreeDir = (id) => join(worktreesDir(), id);
@@ -0,0 +1,333 @@
1
+ import { createHash } from "node:crypto";
2
+ import { z } from "zod";
3
+ // 前面不能緊接路徑字元,中文標點與中文字後面的路徑才抓得到,URL 片段則不會被當成路徑
4
+ const FILE_TOKEN = /(?<![A-Za-z0-9_.@/-])((?:[A-Za-z0-9_.@-]+\/)+[A-Za-z0-9_.@-]+\.[A-Za-z0-9]+)/g;
5
+ const TASK_HEADING = /^(#{2,6}) (T-\d+)(?!\d)/;
6
+ const OVERVIEW_MAX_LINES = 120;
7
+ const EVIDENCE_MAX_LINES = 40;
8
+ const Verdict = z.enum(["approve", "changes_requested"]);
9
+ const ReviewedTask = z.object({
10
+ fingerprint: z.string(),
11
+ acceptance: z.array(z.string()),
12
+ dependsOn: z.array(z.string()),
13
+ verdict: Verdict,
14
+ });
15
+ export const PlanReviewed = z.object({
16
+ overview: z.string(),
17
+ tasks: z.record(z.string(), ReviewedTask),
18
+ });
19
+ export const PlanReviewCall = z.object({
20
+ key: z.string(),
21
+ reviewer: z.string(),
22
+ verdict: Verdict,
23
+ issueLines: z.array(z.string()),
24
+ taskIds: z.array(z.string()).optional(),
25
+ });
26
+ export const PlanReviewRound = z.object({
27
+ round: z.number().int(),
28
+ planKey: z.string(),
29
+ calls: z.array(PlanReviewCall),
30
+ });
31
+ /** reviewed 是上一次整輪完成的結果;round 是本輪進行中已成功的呼叫 */
32
+ export const PlanReviewState = z.object({
33
+ version: z.literal(1),
34
+ reviewed: PlanReviewed.optional(),
35
+ round: PlanReviewRound.optional(),
36
+ });
37
+ /** 已交付仲裁、尚未得出裁決的紀錄;planKey 對不上表示計畫在暫停期間改過 */
38
+ export const PendingArbitration = z.object({
39
+ planReviewer: z.string(),
40
+ planKey: z.string(),
41
+ });
42
+ function taskNum(id) {
43
+ return Number(id.slice(2));
44
+ }
45
+ /** 從任務描述抽出含目錄的檔案路徑(略過 .flow/),排序後去重 */
46
+ export function extractFiles(description) {
47
+ const found = new Set();
48
+ for (const match of description.matchAll(FILE_TOKEN)) {
49
+ const path = match[1];
50
+ if (!path || path.startsWith(".flow/"))
51
+ continue;
52
+ found.add(path);
53
+ }
54
+ return [...found].sort();
55
+ }
56
+ function toGroup(members, index) {
57
+ return {
58
+ id: `G-${index + 1}`,
59
+ taskIds: members.map((task) => task.id).sort((a, b) => taskNum(a) - taskNum(b)),
60
+ files: [...new Set(members.flatMap((task) => extractFiles(task.description)))].sort(),
61
+ };
62
+ }
63
+ /** 依共用路徑把任務併成自然群,抽不到路徑的依附相依目標或併成一群 */
64
+ export function taskClusters(tasks) {
65
+ const parent = new Map(tasks.map((task) => [task.id, task.id]));
66
+ const find = (id) => {
67
+ const next = parent.get(id) ?? id;
68
+ if (next === id)
69
+ return id;
70
+ const root = find(next);
71
+ parent.set(id, root);
72
+ return root;
73
+ };
74
+ const unite = (a, b) => {
75
+ const ra = find(a);
76
+ const rb = find(b);
77
+ if (ra !== rb)
78
+ parent.set(rb, ra);
79
+ };
80
+ const filesOf = new Map(tasks.map((task) => [task.id, extractFiles(task.description)]));
81
+ const hasFiles = (id) => (filesOf.get(id) ?? []).length > 0;
82
+ const owners = new Map();
83
+ for (const task of tasks) {
84
+ for (const file of filesOf.get(task.id) ?? []) {
85
+ const owner = owners.get(file);
86
+ if (owner)
87
+ unite(owner, task.id);
88
+ else
89
+ owners.set(file, task.id);
90
+ }
91
+ }
92
+ for (const task of tasks) {
93
+ if (hasFiles(task.id))
94
+ continue;
95
+ // 只併入 dependsOn 順序中第一個抽得到路徑的目標,避免同時依附多個群時把它們合併起來
96
+ for (const dep of task.dependsOn) {
97
+ if (hasFiles(dep)) {
98
+ unite(dep, task.id);
99
+ break;
100
+ }
101
+ }
102
+ }
103
+ // 沒依附到路徑的檔名任務併成同一群,避免每個都自成一群、每輪多出一次呼叫
104
+ const loose = tasks.filter((task) => !hasFiles(task.id) && find(task.id) === task.id);
105
+ for (const task of loose.slice(1))
106
+ unite(loose[0].id, task.id);
107
+ const buckets = new Map();
108
+ for (const task of tasks) {
109
+ const root = find(task.id);
110
+ buckets.set(root, [...(buckets.get(root) ?? []), task]);
111
+ }
112
+ return [...buckets.values()]
113
+ .map((members) => ({ members, order: Math.min(...members.map((task) => taskNum(task.id))) }))
114
+ .sort((a, b) => a.order - b.order)
115
+ .map((cluster, index) => toGroup(cluster.members, index));
116
+ }
117
+ /** 自然群超過上限時把相鄰的群合併,得到本輪要審的任務群 */
118
+ export function planReviewGroups(tasks, opts) {
119
+ const clusters = taskClusters(tasks);
120
+ // 群數不超過上限,也不讓每群平均少於 tasksPerGroup 個任務,避免拆出很多只有一兩個任務的呼叫
121
+ const limit = Math.max(1, Math.min(opts.maxGroups, Math.floor(tasks.length / opts.tasksPerGroup)));
122
+ if (clusters.length <= limit)
123
+ return clusters;
124
+ const packed = [];
125
+ clusters.forEach((cluster, index) => {
126
+ const slot = Math.floor((index * limit) / clusters.length);
127
+ packed[slot] = [...(packed[slot] ?? []), ...cluster.taskIds];
128
+ });
129
+ return packed.map((ids, index) => toGroup(tasks.filter((task) => ids.includes(task.id)), index));
130
+ }
131
+ /** plan.md 缺少「## T-<數字>」標題的任務 id */
132
+ export function missingTaskHeadings(planMd, taskIds) {
133
+ const found = new Set(planMd.split("\n").flatMap((line) => {
134
+ const match = TASK_HEADING.exec(line);
135
+ return match ? [match[2]] : [];
136
+ }));
137
+ return taskIds.filter((id) => !found.has(id));
138
+ }
139
+ /** 判斷是否走分層審查;不走時附上第一個不成立的原因 */
140
+ export function layeredReview(tasks, planMd, opts) {
141
+ if (!opts.enabled || tasks.length < opts.minTasks)
142
+ return { layered: false };
143
+ const clusters = taskClusters(tasks);
144
+ if (clusters.length < 2)
145
+ return { layered: false, reason: "任務要改的檔案互相重疊,只能分成一群" };
146
+ const largest = Math.max(...clusters.map((cluster) => cluster.taskIds.length));
147
+ // 一群占了大半時,分層只是多一次索引呼叫,也最容易漏掉那一群內部的問題
148
+ if (largest * 3 > tasks.length * 2) {
149
+ return { layered: false, reason: `最大的一群有 ${largest} 個任務,超過總數 ${tasks.length} 的三分之二` };
150
+ }
151
+ const groups = planReviewGroups(tasks, opts);
152
+ if (groups.length < 2)
153
+ return { layered: false, reason: `任務數 ${tasks.length} 不足以讓每群平均至少 ${opts.tasksPerGroup} 個` };
154
+ // 沒有任務標題就切不出難度證據,群審查者會缺資料
155
+ const missing = missingTaskHeadings(planMd, tasks.map((task) => task.id));
156
+ if (missing.length)
157
+ return { layered: false, reason: `plan.md 缺少 ${missing.join(", ")} 的「## T-<數字>」標題` };
158
+ return { layered: true, groups };
159
+ }
160
+ /** 索引審查用的任務清單:每個任務一行摘要加一行描述 */
161
+ export function planReviewIndex(tasks) {
162
+ return tasks.map((task) => {
163
+ const deps = task.dependsOn.length ? task.dependsOn.join(", ") : "(無)";
164
+ const description = task.description.trim().replace(/\s*\n\s*/g, " ");
165
+ return `- ${task.id} ${task.title} | ${task.acceptance.join(", ")} | depends:${deps}\n ${description}`;
166
+ }).join("\n");
167
+ }
168
+ /** 不在群內、但與群內任務直接相依的任務,依 tasks.json 順序 */
169
+ export function neighborTasks(tasks, taskIds) {
170
+ const inGroup = new Set(taskIds);
171
+ const linked = new Set();
172
+ for (const task of tasks) {
173
+ if (inGroup.has(task.id))
174
+ for (const dep of task.dependsOn)
175
+ linked.add(dep);
176
+ else if (task.dependsOn.some((dep) => inGroup.has(dep)))
177
+ linked.add(task.id);
178
+ }
179
+ return tasks.filter((task) => linked.has(task.id) && !inGroup.has(task.id));
180
+ }
181
+ /** 群內有 high 任務時由 quorum 位審查,否則一位 */
182
+ export function groupReviewerCount(groupTasks, quorum) {
183
+ return groupTasks.some((task) => task.complexity === "high") ? quorum : 1;
184
+ }
185
+ /** 任務指紋:任務內容、對應驗收條文與難度證據,任一變了就要重審 */
186
+ export function taskFingerprint(task, acceptance, planMd) {
187
+ const byId = new Map(acceptance.map((item) => [item.id, item.description]));
188
+ return JSON.stringify({
189
+ id: task.id,
190
+ title: task.title,
191
+ description: task.description,
192
+ complexity: task.complexity ?? null,
193
+ dependsOn: task.dependsOn,
194
+ acceptance: task.acceptance.map((id) => ({ id, description: byId.get(id) ?? "" })),
195
+ evidence: extractPlanEvidence(planMd, [task.id]),
196
+ });
197
+ }
198
+ /** 整體做法的指紋 */
199
+ export function overviewKey(planMd) {
200
+ return planContentKey([planOverview(planMd)]);
201
+ }
202
+ /** 解析審查狀態;壞掉或格式不符時回傳 undefined,當成沒有前次結果 */
203
+ export function readPlanReviewState(raw) {
204
+ try {
205
+ const parsed = PlanReviewState.safeParse(JSON.parse(raw));
206
+ return parsed.success ? parsed.data : undefined;
207
+ }
208
+ catch {
209
+ return undefined;
210
+ }
211
+ }
212
+ /** 解析仲裁待完成紀錄;壞掉或格式不符時回傳 undefined */
213
+ export function readPendingArbitration(raw) {
214
+ try {
215
+ const parsed = PendingArbitration.safeParse(JSON.parse(raw));
216
+ return parsed.success ? parsed.data : undefined;
217
+ }
218
+ catch {
219
+ return undefined;
220
+ }
221
+ }
222
+ /** 整輪完成後的新審查結果:審到的任務寫上群的 verdict,沒審到的沿用前次 */
223
+ export function applyReviewVerdicts(prev, tasks, acceptance, planMd, groupVerdicts) {
224
+ const verdictOf = new Map();
225
+ for (const group of groupVerdicts) {
226
+ for (const id of group.taskIds) {
227
+ // 同一群有多位審查者時,任一位要求修改就算要求修改
228
+ if (verdictOf.get(id) !== "changes_requested")
229
+ verdictOf.set(id, group.verdict);
230
+ }
231
+ }
232
+ const tasksState = {};
233
+ for (const task of tasks) {
234
+ const verdict = verdictOf.get(task.id) ?? prev?.tasks[task.id]?.verdict;
235
+ if (!verdict)
236
+ continue;
237
+ tasksState[task.id] = {
238
+ fingerprint: taskFingerprint(task, acceptance, planMd),
239
+ acceptance: task.acceptance,
240
+ dependsOn: task.dependsOn,
241
+ verdict,
242
+ };
243
+ }
244
+ return { overview: overviewKey(planMd), tasks: tasksState };
245
+ }
246
+ function sameList(a, b) {
247
+ return a.join("\0") === b.join("\0");
248
+ }
249
+ /** 這輪要重審的任務 id,依 tasks.json 順序 */
250
+ export function dirtyTaskIds(prev, tasks, acceptance, planMd) {
251
+ // 整體做法是每一群核准的前提,一變就全部重審
252
+ if (!prev || prev.overview !== overviewKey(planMd))
253
+ return tasks.map((task) => task.id);
254
+ const dirty = new Set();
255
+ for (const task of tasks) {
256
+ const prior = prev.tasks[task.id];
257
+ if (!prior || prior.verdict !== "approve" || prior.fingerprint !== taskFingerprint(task, acceptance, planMd))
258
+ dirty.add(task.id);
259
+ }
260
+ for (const task of tasks) {
261
+ const prior = prev.tasks[task.id];
262
+ if (!prior)
263
+ continue;
264
+ if (sameList(prior.acceptance, task.acceptance) && sameList(prior.dependsOn, task.dependsOn))
265
+ continue;
266
+ dirty.add(task.id);
267
+ for (const id of new Set([...prior.dependsOn, ...task.dependsOn]))
268
+ dirty.add(id);
269
+ for (const other of tasks) {
270
+ if (other.dependsOn.includes(task.id))
271
+ dirty.add(other.id);
272
+ }
273
+ }
274
+ return tasks.map((task) => task.id).filter((id) => dirty.has(id));
275
+ }
276
+ /** 含任一髒任務的群 */
277
+ export function dirtyGroups(groups, dirtyIds) {
278
+ const dirty = new Set(dirtyIds);
279
+ return groups.filter((group) => group.taskIds.some((id) => dirty.has(id)));
280
+ }
281
+ /** plan.md 第一個任務標題之前的整體做法,最多 OVERVIEW_MAX_LINES 行 */
282
+ export function planOverview(planMd) {
283
+ const lines = planMd.split("\n");
284
+ const end = lines.findIndex((line) => TASK_HEADING.test(line));
285
+ // trim:任務標題前的空行只是排版,不 trim 的話多一行、少一行空白都會改變整體做法指紋,讓所有群重審
286
+ return (end === -1 ? lines : lines.slice(0, end)).slice(0, OVERVIEW_MAX_LINES).join("\n").trim();
287
+ }
288
+ /** 各任務標題下的難度摘錄,依傳入的 id 順序接起來 */
289
+ export function extractPlanEvidence(planMd, taskIds) {
290
+ const wanted = new Set(taskIds);
291
+ const byId = new Map();
292
+ let current = null;
293
+ for (const line of planMd.split("\n")) {
294
+ const heading = /^(#{1,6}) /.exec(line);
295
+ const task = TASK_HEADING.exec(line);
296
+ if (task)
297
+ current = { id: task[2], level: task[1].length };
298
+ else if (heading && current && heading[1].length <= current.level)
299
+ current = null;
300
+ if (current && wanted.has(current.id))
301
+ byId.set(current.id, [...(byId.get(current.id) ?? []), line]);
302
+ }
303
+ return taskIds
304
+ .filter((id) => byId.has(id))
305
+ .map((id) => byId.get(id).slice(0, EVIDENCE_MAX_LINES).join("\n").trimEnd())
306
+ .join("\n\n");
307
+ }
308
+ /** 審查回應中標題剛好是這些任務 id 的節 */
309
+ export function repliesForTasks(replies, taskIds) {
310
+ if (!replies.trim())
311
+ return "";
312
+ const chunks = replies.split(/(?=^## )/m);
313
+ return chunks.filter((chunk) => {
314
+ const title = chunk.split("\n")[0] ?? "";
315
+ return taskIds.some((id) => new RegExp(`^## ${id}(?!\\d)`).test(title));
316
+ }).join("").trim();
317
+ }
318
+ /** 未通過意見排序後的文字,用來偵測僵持 */
319
+ export function reviewFingerprint(issueLines) {
320
+ return [...issueLines].sort().join("\n");
321
+ }
322
+ /** 多段文字以 \0 串接後的 sha256 */
323
+ export function planContentKey(texts) {
324
+ return createHash("sha256").update(texts.join("\0")).digest("hex");
325
+ }
326
+ /** 輪次與計畫雜湊都對得上時才沿用本輪進度 */
327
+ export function roundProgress(state, round, planKey) {
328
+ const progress = state?.round;
329
+ if (!progress || progress.round !== round || progress.planKey !== planKey)
330
+ return undefined;
331
+ return progress;
332
+ }
333
+ //# sourceMappingURL=planReview.js.map
package/dist/schemas.js CHANGED
@@ -141,6 +141,16 @@ export const RepoConfig = z.object({
141
141
  planReviewQuorum: z.number().int().min(1).default(1),
142
142
  /** 計畫審查僵持不下(達到重試上限或意見不再變化)時,交給第三方 agent 仲裁,而不是停下來等人 */
143
143
  planArbiter: z.boolean().default(true),
144
+ /** 任務夠多、能依檔案分群時,計畫審查改成每輪一次索引加上只審有變動的任務群 */
145
+ planReviewLayers: z.strictObject({
146
+ enabled: z.boolean().default(true),
147
+ /** 任務數達到這個值才考慮分層 */
148
+ minTasks: z.number().int().min(2).default(7),
149
+ /** 每輪最多幾群 */
150
+ maxGroups: z.number().int().min(2).default(5),
151
+ /** 群數也不超過任務數除以這個值,避免拆出很多只有一兩個任務的呼叫 */
152
+ tasksPerGroup: z.number().int().min(1).default(3),
153
+ }).default({ enabled: true, minTasks: 7, maxGroups: 5, tasksPerGroup: 3 }),
144
154
  /**
145
155
  * 仲裁意見分歧時怎麼辦(只有兩家時由雙方各自仲裁,才可能分歧):
146
156
  * proceed=繼續實作,爭議記錄在計畫裡,後面還有測試、驗證與程式碼審查把關;stop=停下來等人
@@ -160,6 +170,8 @@ export const RepoConfig = z.object({
160
170
  { name: "build", cmd: "npx vite build" },
161
171
  ]),
162
172
  });
173
+ /** 讓 run 進入 failed 的原因:關卡重試達上限以外,還有仲裁停止、PR 前仍有未結事項、agent 次數用完、例外與使用者取消 */
174
+ export const FailureCategory = z.enum(["retry_limit", "arbitration_stop", "open_handoff", "agent_budget", "error", "cancelled"]);
163
175
  export const FlowRun = z.object({
164
176
  id: z.string(),
165
177
  baseBranch: z.string(),
@@ -198,6 +210,8 @@ export const FlowRun = z.object({
198
210
  lastTestsAuthor: z.string().optional(),
199
211
  failedStage: Stage.optional(),
200
212
  failureReason: z.string().optional(),
213
+ /** run 為什麼失敗,供 insights 跨 run 彙總;舊 run 沒有這個欄位 */
214
+ failureCategory: FailureCategory.optional(),
201
215
  prUrl: z.string().optional(),
202
216
  createdAt: z.string(),
203
217
  updatedAt: z.string(),
package/dist/stats.js CHANGED
@@ -38,6 +38,32 @@ export function computeStats(entries) {
38
38
  const steps = [...byKey.values()].sort((a, b) => b.totalMs - a.totalMs);
39
39
  return { steps, agentMs, cmdMs, wallMs: last > first ? last - first : 0, unfinished };
40
40
  }
41
+ /** task id 只在同一個 run 內有意義;跨 run 合併時 T-1-green、T-3-green 都算「任務:green」 */
42
+ export const stepKind = (step) => step.replace(/^T-\d+-/, "任務:");
43
+ export function mergeStats(runs) {
44
+ const byKey = new Map();
45
+ let agentMs = 0;
46
+ let cmdMs = 0;
47
+ let unfinished = 0;
48
+ for (const run of runs) {
49
+ agentMs += run.agentMs;
50
+ cmdMs += run.cmdMs;
51
+ unfinished += run.unfinished;
52
+ for (const s of run.steps) {
53
+ const step = stepKind(s.step);
54
+ const key = `${s.kind}:${step}`;
55
+ const acc = byKey.get(key) ?? { step, kind: s.kind, runs: 0, failed: 0, unfinished: 0, totalMs: 0, maxMs: 0 };
56
+ acc.runs += s.runs;
57
+ acc.failed += s.failed;
58
+ acc.unfinished += s.unfinished;
59
+ acc.totalMs += s.totalMs;
60
+ acc.maxMs = Math.max(acc.maxMs, s.maxMs);
61
+ byKey.set(key, acc);
62
+ }
63
+ }
64
+ const steps = [...byKey.values()].sort((a, b) => b.failed - a.failed || b.runs - a.runs || b.totalMs - a.totalMs);
65
+ return { steps, agentMs, cmdMs, unfinished };
66
+ }
41
67
  /** 毫秒轉成 1h02m、3m05s、12s */
42
68
  export function formatDuration(ms) {
43
69
  const sec = Math.round(ms / 1000);