@kkelly-offical/kkcode 0.1.7 → 0.2.3-preview.1

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.
Files changed (166) hide show
  1. package/LICENSE +674 -674
  2. package/README.md +474 -387
  3. package/package.json +50 -46
  4. package/src/agent/agent.mjs +228 -220
  5. package/src/agent/custom-agent-loader.mjs +6 -3
  6. package/src/agent/generator.mjs +2 -2
  7. package/src/agent/prompt/assistant.txt +12 -0
  8. package/src/agent/prompt/bug-hunter.txt +89 -89
  9. package/src/agent/prompt/frontend-designer.txt +58 -58
  10. package/src/agent/prompt/guide.txt +1 -1
  11. package/src/agent/prompt/longagent-blueprint-agent.txt +83 -83
  12. package/src/agent/prompt/longagent-coding-agent.txt +37 -37
  13. package/src/agent/prompt/longagent-debugging-agent.txt +46 -46
  14. package/src/agent/prompt/longagent-preview-agent.txt +63 -63
  15. package/src/command/custom-commands.mjs +2 -2
  16. package/src/commands/agent.mjs +1 -1
  17. package/src/commands/background.mjs +145 -4
  18. package/src/commands/chat.mjs +117 -76
  19. package/src/commands/config.mjs +148 -1
  20. package/src/commands/doctor.mjs +30 -6
  21. package/src/commands/init.mjs +32 -6
  22. package/src/commands/longagent.mjs +117 -0
  23. package/src/commands/mcp.mjs +275 -43
  24. package/src/commands/permission.mjs +1 -1
  25. package/src/commands/session.mjs +195 -140
  26. package/src/commands/skill.mjs +63 -0
  27. package/src/commands/theme.mjs +1 -1
  28. package/src/commands/update.mjs +32 -0
  29. package/src/config/defaults.mjs +289 -260
  30. package/src/config/import-config.mjs +1 -1
  31. package/src/config/load-config.mjs +61 -4
  32. package/src/config/schema.mjs +604 -574
  33. package/src/context.mjs +4 -1
  34. package/src/core/constants.mjs +97 -91
  35. package/src/core/types.mjs +1 -1
  36. package/src/github/api.mjs +78 -78
  37. package/src/github/auth.mjs +294 -286
  38. package/src/github/flow.mjs +298 -298
  39. package/src/github/workspace.mjs +225 -212
  40. package/src/index.mjs +87 -82
  41. package/src/knowledge/frontend-aesthetics.txt +38 -38
  42. package/src/mcp/client-http.mjs +139 -141
  43. package/src/mcp/client-sse.mjs +297 -288
  44. package/src/mcp/client-stdio.mjs +534 -533
  45. package/src/mcp/constants.mjs +4 -2
  46. package/src/mcp/registry.mjs +498 -479
  47. package/src/mcp/stdio-framing.mjs +135 -133
  48. package/src/mcp/tool-result.mjs +24 -24
  49. package/src/observability/edit-diagnostics.mjs +449 -0
  50. package/src/observability/index.mjs +42 -42
  51. package/src/observability/metrics.mjs +165 -137
  52. package/src/observability/tracer.mjs +137 -137
  53. package/src/onboarding.mjs +209 -0
  54. package/src/orchestration/background-manager.mjs +567 -372
  55. package/src/orchestration/background-worker.mjs +419 -305
  56. package/src/orchestration/interruption-reason.mjs +21 -0
  57. package/src/orchestration/longagent-manager.mjs +197 -171
  58. package/src/orchestration/stage-scheduler.mjs +733 -728
  59. package/src/orchestration/subagent-router.mjs +7 -1
  60. package/src/orchestration/task-scheduler.mjs +219 -7
  61. package/src/permission/engine.mjs +1 -1
  62. package/src/permission/exec-policy.mjs +370 -370
  63. package/src/permission/file-edit-policy.mjs +108 -0
  64. package/src/permission/prompt.mjs +1 -1
  65. package/src/permission/rules.mjs +116 -7
  66. package/src/plugin/builtin-hooks/post-edit-format.mjs +2 -1
  67. package/src/plugin/builtin-hooks/post-edit-typecheck.mjs +104 -40
  68. package/src/plugin/hook-bus.mjs +19 -5
  69. package/src/plugin/manifest-loader.mjs +222 -0
  70. package/src/provider/anthropic.mjs +396 -390
  71. package/src/provider/ollama.mjs +7 -1
  72. package/src/provider/openai.mjs +382 -340
  73. package/src/provider/retry-policy.mjs +74 -68
  74. package/src/provider/router.mjs +242 -241
  75. package/src/provider/sse.mjs +104 -104
  76. package/src/provider/wizard.mjs +556 -0
  77. package/src/repl/capability-facade.mjs +30 -0
  78. package/src/repl/command-surface.mjs +23 -0
  79. package/src/repl/controller-entry.mjs +40 -0
  80. package/src/repl/core-shell.mjs +208 -0
  81. package/src/repl/dialog-router.mjs +87 -0
  82. package/src/repl/input-engine.mjs +76 -0
  83. package/src/repl/keymap.mjs +7 -0
  84. package/src/repl/operator-surface.mjs +15 -0
  85. package/src/repl/permission-flow.mjs +49 -0
  86. package/src/repl/runtime-facade.mjs +36 -0
  87. package/src/repl/slash-router.mjs +62 -0
  88. package/src/repl/state-store.mjs +29 -0
  89. package/src/repl/turn-controller.mjs +58 -0
  90. package/src/repl/verification.mjs +23 -0
  91. package/src/repl.mjs +3371 -2981
  92. package/src/rules/load-rules.mjs +3 -3
  93. package/src/runtime.mjs +1 -1
  94. package/src/session/agent-transaction.mjs +86 -0
  95. package/src/session/checkpoint.mjs +302 -302
  96. package/src/session/compaction.mjs +298 -298
  97. package/src/session/engine.mjs +417 -232
  98. package/src/session/longagent-4stage.mjs +467 -460
  99. package/src/session/longagent-hybrid.mjs +1344 -1097
  100. package/src/session/longagent-plan.mjs +376 -365
  101. package/src/session/longagent-project-memory.mjs +53 -53
  102. package/src/session/longagent-scaffold.mjs +291 -291
  103. package/src/session/longagent-task-bus.mjs +138 -54
  104. package/src/session/longagent-utils.mjs +828 -472
  105. package/src/session/longagent.mjs +911 -900
  106. package/src/session/loop.mjs +1005 -930
  107. package/src/session/prompt/agent.txt +25 -25
  108. package/src/session/prompt/anthropic.txt +150 -150
  109. package/src/session/prompt/beast.txt +1 -1
  110. package/src/session/prompt/plan.txt +31 -31
  111. package/src/session/prompt/qwen.txt +46 -46
  112. package/src/session/recovery.mjs +21 -0
  113. package/src/session/rollback.mjs +196 -195
  114. package/src/session/routing-observability.mjs +72 -0
  115. package/src/session/runtime-state.mjs +47 -0
  116. package/src/session/store.mjs +523 -519
  117. package/src/session/system-prompt.mjs +308 -273
  118. package/src/session/task-validator.mjs +267 -267
  119. package/src/session/usability-gates.mjs +2 -2
  120. package/src/skill/builtin/commit.mjs +64 -64
  121. package/src/skill/builtin/design.mjs +76 -76
  122. package/src/skill/generator.mjs +18 -2
  123. package/src/skill/registry.mjs +642 -390
  124. package/src/storage/audit-store.mjs +18 -11
  125. package/src/storage/event-log.mjs +7 -1
  126. package/src/storage/ghost-commit-store.mjs +243 -245
  127. package/src/storage/paths.mjs +17 -0
  128. package/src/theme/default-theme.mjs +1 -1
  129. package/src/theme/markdown.mjs +4 -0
  130. package/src/theme/schema.mjs +1 -1
  131. package/src/theme/status-bar.mjs +162 -158
  132. package/src/tool/audit-wrapper.mjs +18 -2
  133. package/src/tool/edit-transaction.mjs +23 -0
  134. package/src/tool/executor.mjs +26 -1
  135. package/src/tool/file-read-state.mjs +65 -0
  136. package/src/tool/git-auto.mjs +526 -526
  137. package/src/tool/git-full-auto.mjs +487 -478
  138. package/src/tool/mutation-guard.mjs +54 -0
  139. package/src/tool/prompt/edit.txt +3 -3
  140. package/src/tool/prompt/multiedit.txt +1 -0
  141. package/src/tool/prompt/notebookedit.txt +2 -1
  142. package/src/tool/prompt/patch.txt +25 -24
  143. package/src/tool/prompt/read.txt +3 -3
  144. package/src/tool/prompt/sysinfo.txt +29 -0
  145. package/src/tool/prompt/task.txt +66 -4
  146. package/src/tool/prompt/write.txt +2 -2
  147. package/src/tool/question-prompt.mjs +99 -93
  148. package/src/tool/registry.mjs +1701 -1343
  149. package/src/tool/task-tool.mjs +14 -6
  150. package/src/ui/activity-renderer.mjs +667 -664
  151. package/src/ui/repl-background-panel.mjs +7 -0
  152. package/src/ui/repl-capability-panel.mjs +9 -0
  153. package/src/ui/repl-dashboard.mjs +54 -4
  154. package/src/ui/repl-help.mjs +110 -0
  155. package/src/ui/repl-operator-panel.mjs +12 -0
  156. package/src/ui/repl-route-feedback.mjs +35 -0
  157. package/src/ui/repl-status-view.mjs +76 -0
  158. package/src/ui/repl-task-panel.mjs +5 -0
  159. package/src/ui/repl-transcript-panel.mjs +56 -0
  160. package/src/ui/repl-turn-summary.mjs +135 -0
  161. package/src/update/checker.mjs +184 -0
  162. package/src/usage/pricing.mjs +122 -121
  163. package/src/usage/usage-meter.mjs +1 -0
  164. package/src/util/git.mjs +562 -519
  165. package/src/util/template.mjs +6 -1
  166. package/src/version.mjs +3 -0
@@ -1,1097 +1,1344 @@
1
- /**
2
- * LongAgent Hybrid 模式
3
- * 融合 4-Stage 的只读探索/规划/调试回滚 + Parallel 的脚手架/并行执行/门控
4
- *
5
- * 流程: H0:Intake → H1:Preview → H2:Blueprint → H2.5:Git → H3:Scaffold → H4:Coding(并行) → H5:Debugging(回滚) → H5.5:Validation → H6:Gates → H7:GitMerge
6
- */
7
- import { LongAgentManager } from "../orchestration/longagent-manager.mjs"
8
- import { processTurnLoop } from "./loop.mjs"
9
- import { markSessionStatus } from "./store.mjs"
10
- import { EventBus } from "../core/events.mjs"
11
- import { EVENT_TYPES, LONGAGENT_4STAGE_STAGES } from "../core/constants.mjs"
12
- import { saveCheckpoint, loadCheckpoint, saveTaskCheckpoint, loadTaskCheckpoints, cleanupCheckpoints } from "./checkpoint.mjs"
13
- import { getAgent } from "../agent/agent.mjs"
14
- import { runStageBarrier } from "../orchestration/stage-scheduler.mjs"
15
- import { runScaffoldPhase } from "./longagent-scaffold.mjs"
16
- import {
17
- runUsabilityGates,
18
- hasGatePreferences,
19
- getGatePreferences,
20
- saveGatePreferences,
21
- buildGatePromptText,
22
- parseGateSelection
23
- } from "./usability-gates.mjs"
24
- import { runIntakeDialogue, validateAndNormalizeStagePlan, defaultStagePlan } from "./longagent-plan.mjs"
25
- import { createValidator } from "./task-validator.mjs"
26
- import { detectStageComplete, detectReturnToCoding, buildStageWrapper } from "./longagent-4stage.mjs"
27
- import {
28
- isComplete,
29
- isLikelyActionableObjective,
30
- mergeCappedFileChanges,
31
- stageProgressStats,
32
- summarizeGateFailures,
33
- LONGAGENT_FILE_CHANGES_LIMIT,
34
- createStuckTracker,
35
- classifyError,
36
- ERROR_CATEGORIES,
37
- createSemanticErrorTracker,
38
- createDegradationChain,
39
- generateRecoverySuggestions,
40
- stripFence,
41
- parseJsonLoose
42
- } from "./longagent-utils.mjs"
43
- import { TaskBus } from "./longagent-task-bus.mjs"
44
- import { loadProjectMemory, saveProjectMemory, memoryToContext, parseMemoryFromPreview } from "./longagent-project-memory.mjs"
45
- import * as git from "../util/git.mjs"
46
-
47
- // Checkpoint 结构校验
48
- function validateCheckpoint(cp) {
49
- if (!cp || !cp.stagePlan || !Array.isArray(cp.stagePlan.stages)) return false
50
- if (typeof cp.stageIndex !== "number" || cp.stageIndex < 0) return false
51
- if (cp.stageIndex > cp.stagePlan.stages.length) return false
52
- // Verify the previous stage exists for task checkpoint loading
53
- if (cp.stageIndex > 0 && !cp.stagePlan.stages[cp.stageIndex - 1]) return false
54
- return true
55
- }
56
-
57
- // Gate 修复策略路由 (Phase 8)
58
- function getGateFixStrategy(failures) {
59
- const gateTypes = (failures || []).map(f => f.gate).filter(Boolean)
60
- if (gateTypes.includes("test")) return { agent: "debugging-agent", prefix: "Analyze test failures and fix:" }
61
- if (gateTypes.every(g => g === "build")) return { agent: "coding-agent", prefix: "Fix build errors:" }
62
- if (gateTypes.every(g => g === "lint")) return { autoFix: "npx eslint --fix .", agent: "coding-agent", prefix: "Fix lint errors:" }
63
- return { agent: "coding-agent", prefix: "Fix gate failures:" }
64
- }
65
-
66
- // #13 上下文压缩
67
- async function compressContext(text, limit, { model, providerType, sessionId, configState, baseUrl, apiKeyEnv, signal, toolContext }) {
68
- if (text.length <= limit) return text
69
- const out = await processTurnLoop({
70
- prompt: [
71
- `Compress the following engineering context to max ${Math.round(limit * 0.6)} characters.`,
72
- "Preserve ONLY:",
73
- "- Concrete decisions made (technology choices, architecture patterns, API contracts)",
74
- "- File paths and function signatures that were created or modified",
75
- "- Error messages and their resolutions",
76
- "- Cross-task dependencies and integration points",
77
- "- Test results (pass/fail with specific failure reasons)",
78
- "Discard: exploration logs, verbose tool output, repeated information, reasoning chains.",
79
- "Output the compressed context directly — no preamble or explanation.",
80
- "",
81
- text.slice(0, limit * 2)
82
- ].join("\n"),
83
- mode: "ask", model, providerType, sessionId, configState, baseUrl, apiKeyEnv, signal, allowQuestion: false, toolContext
84
- })
85
- return (out.reply || text.slice(0, limit)).slice(0, limit)
86
- }
87
-
88
- // #3 动态计划修订解析
89
- function parseReplanMarker(text) {
90
- const match = String(text || "").match(/\[REPLAN:\s*([\s\S]*?)\]/i)
91
- if (!match) return null
92
- try { return JSON.parse(match[1]) } catch { return null }
93
- }
94
-
95
- // #1 细粒度回滚:从 debugging 输出中提取失败的 taskId
96
- function extractFailedTaskIds(text) {
97
- const ids = []
98
- const pattern = /\[FAILED_TASK:\s*(\S+)\]/gi
99
- let m
100
- while ((m = pattern.exec(text)) !== null) ids.push(m[1])
101
- return ids
102
- }
103
-
104
-
105
- function parseBlueprintOutput(reply, objective, defaults) {
106
- // 1. 尝试提取 ```stage_plan_json ... ``` 块
107
- const jsonMatch = reply.match(/```stage_plan_json\s*([\s\S]*?)```/)
108
- if (jsonMatch) {
109
- const parsed = parseJsonLoose(jsonMatch[1])
110
- if (parsed?.stages) {
111
- const { plan, errors } = validateAndNormalizeStagePlan(parsed, { objective, defaults })
112
- if (!errors.length) {
113
- return { architectureText: reply.replace(/```stage_plan_json[\s\S]*?```/g, "").trim(), stagePlan: plan }
114
- }
115
- }
116
- }
117
- // 2. 回退:尝试任意 JSON
118
- const anyJson = reply.match(/```(?:json)?\s*([\s\S]*?)```/g)
119
- if (anyJson) {
120
- for (const block of anyJson) {
121
- const inner = block.replace(/```(?:json|stage_plan_json)?\s*/g, "").replace(/```/g, "").trim()
122
- const parsed = parseJsonLoose(inner)
123
- if (parsed?.stages) {
124
- const { plan, errors } = validateAndNormalizeStagePlan(parsed, { objective, defaults })
125
- if (!errors.length) return { architectureText: reply, stagePlan: plan }
126
- }
127
- }
128
- }
129
- // 3. 最终回退:单任务默认计划
130
- return { architectureText: reply, stagePlan: defaultStagePlan(objective, defaults) }
131
- }
132
-
133
- export async function runHybridLongAgent({
134
- prompt, model, providerType, sessionId, configState,
135
- baseUrl = null, apiKeyEnv = null, agent = null,
136
- maxIterations = 0, signal = null, output = null,
137
- allowQuestion = true, toolContext = {}
138
- }) {
139
- const longagentConfig = configState.config.agent.longagent || {}
140
- const hybridConfig = longagentConfig.hybrid || {}
141
- const parallelConfig = longagentConfig.parallel || {}
142
- const gitConfig = longagentConfig.git || {}
143
- const noProgressLimit = Number(longagentConfig.no_progress_limit || 5)
144
- const maxGateAttempts = Number(longagentConfig.max_gate_attempts || 5)
145
- const fileChangesLimit = Math.max(20, Number(longagentConfig.file_changes_limit || LONGAGENT_FILE_CHANGES_LIMIT))
146
-
147
- // 每阶段模型选择
148
- const separateModels = hybridConfig.separate_models || {}
149
- const useSeparateModels = separateModels.enabled === true
150
- const adaptiveModels = hybridConfig.adaptive_models || {}
151
- const useAdaptiveModels = adaptiveModels.enabled === true
152
- function getModelForStage(stage) {
153
- if (!useSeparateModels) return { model, providerType }
154
- const m = { preview: separateModels.preview_model, blueprint: separateModels.blueprint_model, debugging: separateModels.debugging_model }
155
- return m[stage] ? { model: m[stage], providerType } : { model, providerType }
156
- }
157
- // #8 自适应模型路由:根据 task complexity 选择模型
158
- function getModelForTask(task) {
159
- if (!useAdaptiveModels) return model
160
- const tier = task?.complexity || "medium"
161
- return adaptiveModels[tier] || model
162
- }
163
-
164
- let iteration = 0, recoveryCount = 0, stageIndex = 0
165
- let currentPhase = "H0", currentGate = "init"
166
- let gateStatus = {}, lastGateFailures = []
167
- let lastProgress = { percentage: 0, currentStep: 0, totalSteps: 0 }
168
- let finalReply = "", planFrozen = false, stagePlan = null
169
- let taskProgress = {}, fileChanges = []
170
- let completionMarkerSeen = false
171
- let gitBranch = null, gitBaseBranch = null, gitActive = false
172
- const aggregateUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
173
- const toolEvents = []
174
- const startTime = Date.now()
175
- const stuckTracker = createStuckTracker()
176
- // Phase 6: 降级链
177
- const degradationChain = createDegradationChain(hybridConfig.degradation || {})
178
- // Phase 2: 阶段超时配置
179
- const codingPhaseTimeoutMs = Number(hybridConfig.coding_phase_timeout_ms || 1800000)
180
- const debuggingPhaseTimeoutMs = Number(hybridConfig.debugging_phase_timeout_ms || 600000)
181
- // #4 TaskBus
182
- const taskBus = hybridConfig.task_bus !== false ? new TaskBus() : null
183
- // #5 Project Memory
184
- const cwd = process.cwd()
185
- let projectMemory = null
186
- if (hybridConfig.project_memory !== false) {
187
- try { projectMemory = await loadProjectMemory(cwd) } catch { projectMemory = null }
188
- }
189
-
190
- function accumulateUsage(turn) {
191
- aggregateUsage.input += turn.usage?.input || 0
192
- aggregateUsage.output += turn.usage?.output || 0
193
- aggregateUsage.cacheRead += turn.usage?.cacheRead || 0
194
- aggregateUsage.cacheWrite += turn.usage?.cacheWrite || 0
195
- if (turn.toolEvents?.length) toolEvents.push(...turn.toolEvents)
196
- }
197
-
198
- async function setPhase(next, reason = "") {
199
- if (currentPhase === next) return
200
- const prev = currentPhase
201
- currentPhase = next
202
- await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_PHASE_CHANGED, sessionId, payload: { prevPhase: prev, nextPhase: next, reason, iteration } })
203
- }
204
-
205
- async function syncState(patch = {}) {
206
- const stats = stageProgressStats(taskProgress)
207
- await LongAgentManager.update(sessionId, {
208
- status: patch.status || "running", phase: currentPhase, gateStatus, currentGate,
209
- recoveryCount, lastGateFailures, iterations: iteration, heartbeatAt: Date.now(),
210
- progress: lastProgress, planFrozen, stageIndex,
211
- stageCount: stagePlan?.stages?.length || 0,
212
- taskProgress, stageProgress: { done: stats.done, total: stats.total },
213
- remainingFilesCount: stats.remainingFilesCount,
214
- ...patch
215
- })
216
- }
217
-
218
- await markSessionStatus(sessionId, "running-longagent")
219
- await syncState({ status: "running", lastMessage: "hybrid mode started" })
220
-
221
- // 前置检查
222
- if (!isLikelyActionableObjective(prompt)) {
223
- const blocked = "LongAgent 需要明确的编码目标。请直接描述要实现/修复的内容。"
224
- await LongAgentManager.update(sessionId, { status: "blocked", phase: "H0", lastMessage: blocked })
225
- await markSessionStatus(sessionId, "active")
226
- return { sessionId, turnId: `turn_long_${Date.now()}`, reply: blocked, usage: aggregateUsage, toolEvents, iterations: 0, status: "blocked", phase: "H0", gateStatus: {}, currentGate: "init", lastGateFailures: [], recoveryCount: 0, progress: lastProgress, elapsed: 0, stageIndex: 0, stageCount: 0, planFrozen: false, taskProgress: {}, fileChanges: [], stageProgress: { done: 0, total: 0 }, remainingFilesCount: 0 }
227
- }
228
-
229
- // #15 Checkpoint 恢复:如果有之前的检查点,跳过已完成阶段
230
- // #22: 增强为 task 级粒度恢复
231
- if (hybridConfig.checkpoint_resume !== false) {
232
- try {
233
- const cp = await loadCheckpoint(sessionId)
234
- if (cp?.stageIndex > 0 && cp?.stagePlan) {
235
- if (!validateCheckpoint(cp)) {
236
- // Invalid checkpoint structure — discard and start fresh
237
- await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_CHECKPOINT_INVALID, sessionId, payload: { reason: "structure_validation_failed" } })
238
- } else {
239
- stagePlan = cp.stagePlan; stageIndex = cp.stageIndex; planFrozen = true
240
- taskProgress = cp.taskProgress || {}; lastProgress = cp.lastProgress || lastProgress
241
- iteration = cp.iteration || 0
242
- // #22: Load task-level checkpoints to recover intra-stage progress
243
- if (stageIndex > 0) {
244
- const prevStage = cp.stagePlan.stages[stageIndex - 1]
245
- if (prevStage) {
246
- const taskCps = await loadTaskCheckpoints(sessionId, prevStage.stageId)
247
- for (const [tid, tData] of Object.entries(taskCps)) {
248
- if (!taskProgress[tid] || taskProgress[tid].status !== "completed") {
249
- taskProgress[tid] = { ...taskProgress[tid], ...tData }
250
- }
251
- }
252
- }
253
- }
254
- await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_CHECKPOINT_RESUMED, sessionId, payload: { stageIndex, iteration } })
255
- await syncState({ lastMessage: `resumed from checkpoint at stage ${stageIndex}` })
256
- }
257
- }
258
- } catch { /* no checkpoint, start fresh */ }
259
- }
260
-
261
- // #5 Memory 事件
262
- if (projectMemory?.techStack?.length) {
263
- await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_MEMORY_LOADED, sessionId, payload: { techStack: projectMemory.techStack } })
264
- }
265
-
266
- // ========== H0: INTAKE (需求澄清) ==========
267
- let intakeSummary = prompt
268
- if (hybridConfig.intake !== false && !planFrozen) {
269
- await setPhase("H0", "intake")
270
- await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_INTAKE_STARTED, sessionId, payload: { objective: prompt } })
271
- await syncState({ lastMessage: "H0: intake dialogue — clarifying requirements" })
272
-
273
- const plannerConfig = longagentConfig.planner || {}
274
- const intakeConfig = plannerConfig.intake_questions || {}
275
- const intake = await runIntakeDialogue({
276
- objective: prompt,
277
- model, providerType, sessionId, configState,
278
- baseUrl, apiKeyEnv, agent, signal,
279
- maxRounds: Number(intakeConfig.max_rounds || 6)
280
- })
281
- intakeSummary = intake.summary || prompt
282
- accumulateUsage(intake)
283
- gateStatus.intake = { status: "pass", rounds: intake.transcript.length, summary: intakeSummary.slice(0, 500) }
284
- await syncState({ lastMessage: `H0: intake complete (${intake.transcript.length} qa pairs)` })
285
- }
286
-
287
- // ========== H1: PREVIEW (只读探索) ==========
288
- await setPhase("H1", "preview")
289
- currentGate = "preview"
290
- await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_PREVIEW_START, sessionId, payload: { objective: prompt } })
291
- await syncState({ lastMessage: "H1: preview agent exploring codebase" })
292
-
293
- const previewModel = getModelForStage("preview")
294
- // #5 注入 project memory preview prompt
295
- const memCtx = projectMemory ? memoryToContext(projectMemory) : ""
296
- const previewPrompt = buildStageWrapper(LONGAGENT_4STAGE_STAGES.PREVIEW, { preview: null, blueprint: null, coding: null }, memCtx ? `${memCtx}\n\n${intakeSummary}` : intakeSummary)
297
- const previewOut = await processTurnLoop({
298
- prompt: previewPrompt, mode: "agent", agent: getAgent("preview-agent"),
299
- model: previewModel.model, providerType: previewModel.providerType,
300
- sessionId, configState, baseUrl, apiKeyEnv, signal, output, allowQuestion, toolContext
301
- })
302
- accumulateUsage(previewOut)
303
- const previewFindings = previewOut.reply || ""
304
-
305
- gateStatus.preview = { status: "pass", findingsLength: previewFindings.length }
306
- await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_PREVIEW_COMPLETE, sessionId, payload: { findingsLength: previewFindings.length } })
307
- await syncState({ lastMessage: `H1: preview complete (${previewFindings.length} chars)` })
308
-
309
- // ========== H2: BLUEPRINT (只读规划 + 结构化 stagePlan) ==========
310
- await setPhase("H2", "blueprint")
311
- currentGate = "blueprint"
312
- await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_BLUEPRINT_START, sessionId, payload: {} })
313
- await syncState({ lastMessage: "H2: blueprint agent designing architecture" })
314
-
315
- const blueprintModel = getModelForStage("blueprint")
316
- const blueprintPrompt = buildStageWrapper(LONGAGENT_4STAGE_STAGES.BLUEPRINT, { preview: previewFindings, blueprint: null, coding: null }, prompt)
317
- + [
318
- "\n\n## HYBRID MODE: STRUCTURED EXECUTION PLAN (REQUIRED)",
319
- "In addition to your architecture design, you MUST output a machine-parseable stage plan.",
320
- "",
321
- "Wrap it in a ```stage_plan_json ... ``` fenced block. Schema:",
322
- '{"planId":"...","objective":"...","stages":[{"stageId":"...","name":"...","passRule":"all_success","tasks":[{"taskId":"...","prompt":"detailed task prompt for sub-agent","plannedFiles":["file1.mjs","file2.mjs"],"acceptance":["node --check file1.mjs","node --test test/file1.test.mjs"],"timeoutMs":600000,"maxRetries":2,"complexity":"low|medium|high"}]}]}',
323
- "",
324
- "Rules for the stage plan:",
325
- "- Each task prompt must be SELF-CONTAINED: the sub-agent has NO access to your blueprint text",
326
- "- plannedFiles must list EVERY file the task will create or modify (no file in multiple tasks)",
327
- "- acceptance must be machine-verifiable commands (not subjective criteria)",
328
- "- Files that import each other MUST be in the same task",
329
- "- A module and its test file MUST be in the same task",
330
- "- Order stages by dependency: shared types → core logic → integration → validation"
331
- ].join("\n")
332
- const blueprintOut = await processTurnLoop({
333
- prompt: blueprintPrompt, mode: "agent", agent: getAgent("blueprint-agent"),
334
- model: blueprintModel.model, providerType: blueprintModel.providerType,
335
- sessionId, configState, baseUrl, apiKeyEnv, signal, output, allowQuestion, toolContext
336
- })
337
- accumulateUsage(blueprintOut)
338
-
339
- const planDefaults = { timeoutMs: Number(parallelConfig.task_timeout_ms || 600000), maxRetries: Number(parallelConfig.task_max_retries ?? 2) }
340
- const { architectureText, stagePlan: parsedPlan } = parseBlueprintOutput(blueprintOut.reply || "", prompt, planDefaults)
341
- stagePlan = parsedPlan
342
- planFrozen = true
343
-
344
- gateStatus.blueprint = { status: "pass", hasArchitecture: architectureText.length > 100, stageCount: stagePlan.stages.length }
345
- await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_BLUEPRINT_COMPLETE, sessionId, payload: { planId: stagePlan.planId, stageCount: stagePlan.stages.length } })
346
- await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_PLAN_FROZEN, sessionId, payload: { planId: stagePlan.planId, stageCount: stagePlan.stages.length, errors: [] } })
347
- await syncState({ planFrozen: true, lastMessage: `H2: blueprint complete, ${stagePlan.stages.length} stage(s)` })
348
-
349
- // #9 Blueprint 语义验证
350
- if (hybridConfig.blueprint_validation !== false && stagePlan.stages.length > 0) {
351
- const totalTasks = stagePlan.stages.reduce((s, st) => s + (st.tasks?.length || 0), 0)
352
- const totalFiles = new Set(stagePlan.stages.flatMap(st => (st.tasks || []).flatMap(t => t.plannedFiles || []))).size
353
- const valid = totalTasks > 0 && totalFiles > 0
354
- gateStatus.blueprintValidation = { status: valid ? "pass" : "warn", totalTasks, totalFiles }
355
- await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_BLUEPRINT_VALIDATED, sessionId, payload: { totalTasks, totalFiles, valid } })
356
- }
357
-
358
- // #2 人工审查检查点
359
- if (hybridConfig.blueprint_review === true && allowQuestion) {
360
- await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_BLUEPRINT_REVIEW, sessionId, payload: { planId: stagePlan.planId } })
361
- const reviewOut = await processTurnLoop({
362
- prompt: `[SYSTEM] Blueprint 已生成,包含 ${stagePlan.stages.length} 个阶段。架构摘要:\n${architectureText.slice(0, 1500)}\n\n请确认是否继续执行?回复 yes/是 继续,no/否 中止。`,
363
- mode: "ask", model, providerType, sessionId, configState, baseUrl, apiKeyEnv, agent, signal, allowQuestion: true, toolContext
364
- })
365
- accumulateUsage(reviewOut)
366
- const answer = String(reviewOut.reply || "").toLowerCase().trim()
367
- if (["no", "否", "n", "取消", "abort"].some(k => answer.includes(k))) {
368
- await LongAgentManager.update(sessionId, { status: "aborted", lastMessage: "user rejected blueprint" })
369
- await markSessionStatus(sessionId, "active")
370
- return { sessionId, turnId: `turn_long_${Date.now()}`, reply: "用户中止了 Blueprint 审查。", usage: aggregateUsage, toolEvents, iterations: iteration, status: "aborted", phase: "H2", gateStatus, currentGate, lastGateFailures: [], recoveryCount: 0, progress: lastProgress, elapsed: Math.round((Date.now() - startTime) / 1000), stageIndex: 0, stageCount: stagePlan.stages.length, planFrozen, taskProgress: {}, fileChanges: [], stageProgress: { done: 0, total: 0 }, remainingFilesCount: 0 }
371
- }
372
- }
373
-
374
- // ========== H2.5: GIT BRANCH (可选) ==========
375
- const gitEnabled = gitConfig.enabled === true || gitConfig.enabled === "ask"
376
- const gitAsk = gitConfig.enabled === "ask"
377
- const inGitRepo = gitEnabled && await git.isGitRepo(cwd)
378
-
379
- if (inGitRepo) {
380
- await setPhase("H2.5", "git_branch")
381
- let userWantsGit = !gitAsk
382
- if (gitAsk && allowQuestion) {
383
- const askResult = await processTurnLoop({
384
- prompt: "[SYSTEM] 是否为本次 Hybrid LongAgent 创建独立 Git 分支?回复 yes/是 启用,no/否 跳过。",
385
- mode: "ask", model, providerType, sessionId, configState, baseUrl, apiKeyEnv, agent, signal, allowQuestion: true, toolContext
386
- })
387
- const answer = String(askResult.reply || "").toLowerCase().trim()
388
- userWantsGit = ["yes", "是", "y", "ok", "好", "确认"].some(k => answer.includes(k))
389
- accumulateUsage(askResult)
390
- }
391
- if (userWantsGit) {
392
- gitBaseBranch = await git.currentBranch(cwd)
393
- // Guard: skip git flow if branch is empty or HEAD detached
394
- if (!gitBaseBranch || gitBaseBranch === "HEAD") {
395
- gateStatus.git = { status: "warn", reason: "detached HEAD or no branch" }
396
- } else {
397
- const branchName = git.generateBranchName(sessionId, prompt)
398
- const clean = await git.isClean(cwd)
399
- let stashed = false
400
- try {
401
- if (!clean) {
402
- const sr = await git.stash("kkcode-auto-stash", cwd)
403
- stashed = sr.ok
404
- if (!stashed) {
405
- // Stash failed — skip branch creation
406
- gateStatus.git = { status: "warn", reason: "git stash failed" }
407
- }
408
- }
409
- if (!stashed && !clean) {
410
- // stash failed, skip branch creation (already set gateStatus above)
411
- } else {
412
- const created = await git.createBranch(branchName, cwd)
413
- if (created.ok) {
414
- gitBranch = branchName; gitActive = true
415
- gateStatus.git = { status: "pass", branch: branchName, baseBranch: gitBaseBranch }
416
- await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_GIT_BRANCH_CREATED, sessionId, payload: { branch: branchName, baseBranch: gitBaseBranch } })
417
- } else {
418
- gateStatus.git = { status: "warn", reason: created.message }
419
- }
420
- }
421
- } finally {
422
- // Always restore stash on any exit path
423
- if (stashed) await git.stashPop(cwd).catch(() => {})
424
- }
425
- }
426
- }
427
- }
428
-
429
- // ========== H3: SCAFFOLD (脚手架) ==========
430
- const scaffoldEnabled = longagentConfig.scaffold?.enabled !== false
431
- if (scaffoldEnabled && stagePlan.stages.length > 0) {
432
- await setPhase("H3", "scaffolding")
433
- currentGate = "scaffold"
434
- await syncState({ lastMessage: "H3: creating stub files" })
435
-
436
- const scaffoldResult = await runScaffoldPhase({
437
- objective: `${prompt}\n\n=== BLUEPRINT ARCHITECTURE ===\n${architectureText.slice(0, 4000)}`,
438
- stagePlan, model, providerType, sessionId, configState,
439
- baseUrl, apiKeyEnv, agent, signal, toolContext,
440
- tddMode: hybridConfig.tdd_mode === true
441
- })
442
-
443
- gateStatus.scaffold = { status: scaffoldResult.scaffolded ? "pass" : "skip", fileCount: scaffoldResult.fileCount }
444
- if (scaffoldResult.usage) accumulateUsage(scaffoldResult)
445
- if (scaffoldResult.files?.length) {
446
- fileChanges = mergeCappedFileChanges(fileChanges,
447
- scaffoldResult.files.map(f => ({ path: f, addedLines: 0, removedLines: 0, stageId: "scaffold", taskId: "scaffold" })),
448
- fileChangesLimit)
449
- }
450
- await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_SCAFFOLD_COMPLETE, sessionId, payload: { fileCount: scaffoldResult.fileCount, files: scaffoldResult.files || [] } })
451
- await syncState({ lastMessage: `H3: scaffolded ${scaffoldResult.fileCount} file(s)` })
452
- }
453
-
454
- // ========== H4+H5: CODING(并行) + DEBUGGING(回滚) 循环 ==========
455
- const gatesConfig = longagentConfig.usability_gates || {}
456
- let priorContext = [
457
- "### Preview Findings", previewFindings.slice(0, 2000), "",
458
- "### Blueprint Architecture", architectureText.slice(0, 3000)
459
- ].join("\n")
460
- const seenFilePaths = new Set() // #3 去重:跨阶段文件路径去重
461
-
462
- let codingRollbackCount = 0
463
- const maxCodingRollbacks = Number(hybridConfig.max_coding_rollbacks || 2)
464
- const maxDebugIterations = Number(hybridConfig.debugging_max_iterations || 20)
465
- let rerunCoding = true
466
-
467
- while (rerunCoding && codingRollbackCount <= maxCodingRollbacks) {
468
- rerunCoding = false
469
-
470
- // --- H4: CODING (并行 stage 执行) ---
471
- await setPhase("H4", "coding")
472
- currentGate = "coding"
473
- stageIndex = 0
474
- const codingPhaseStart = Date.now()
475
-
476
- while (stageIndex < stagePlan.stages.length) {
477
- const state = await LongAgentManager.get(sessionId)
478
- if (state?.stopRequested || signal?.aborted) break
479
-
480
- // Phase 2: 阶段超时检测
481
- if (Date.now() - codingPhaseStart > codingPhaseTimeoutMs) {
482
- await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_PHASE_TIMEOUT, sessionId, payload: { phase: "H4", elapsed: Date.now() - codingPhaseStart } })
483
- if (degradationChain.canDegrade()) {
484
- const degCtx = { model, taskProgress, configState, shouldStop: false }
485
- const deg = degradationChain.apply(degCtx)
486
- if (degCtx.model !== model) model = degCtx.model
487
- await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_DEGRADATION_APPLIED, sessionId, payload: { strategy: deg.strategy, phase: "H4" } })
488
- if (deg.applied && deg.strategy === "graceful_stop") break
489
- } else {
490
- break
491
- }
492
- }
493
-
494
- iteration++
495
- const stage = stagePlan.stages[stageIndex]
496
- currentGate = `stage:${stage.stageId}`
497
- await syncState({ stageStatus: "running", lastMessage: `H4: running ${stage.stageId} (${stageIndex + 1}/${stagePlan.stages.length})` })
498
-
499
- const seeded = Object.fromEntries(
500
- stage.tasks.map(t => [t.taskId, taskProgress[t.taskId]]).filter(([, v]) => Boolean(v))
501
- )
502
-
503
- // #4 计划锚点 — 每阶段动态构建,不存入 priorContext 避免被压缩掉
504
- const stageStatuses = stagePlan.stages.map((s, i) => {
505
- const marker = i < stageIndex ? "✓" : i === stageIndex ? "→" : " "
506
- return `[${marker}] 阶段${i + 1}: ${s.name || s.stageId}`
507
- }).join("\n")
508
- const planAnchor = `## 计划锚点\n目标: ${stagePlan.objective || prompt}\n进度: ${stageIndex + 1}/${stagePlan.stages.length}\n${stageStatuses}\n\n`
509
-
510
- const stageResult = await runStageBarrier({
511
- stage, sessionId, config: configState.config, model, providerType,
512
- seedTaskProgress: seeded, objective: prompt,
513
- stageIndex, stageCount: stagePlan.stages.length, priorContext: planAnchor + priorContext,
514
- stuckTracker,
515
- onTaskComplete: async (taskData) => {
516
- await saveTaskCheckpoint(sessionId, taskData.stageId, taskData.taskId, taskData)
517
- },
518
- taskBus
519
- })
520
-
521
- // 合并结果
522
- for (const [taskId, progress] of Object.entries(stageResult.taskProgress || {})) {
523
- taskProgress[taskId] = { ...taskProgress[taskId], ...progress }
524
- if (String(progress.lastReply || "").toLowerCase().includes("[task_complete]")) completionMarkerSeen = true
525
- // #4 TaskBus: 解析 task 输出中的广播消息
526
- if (taskBus && progress.lastReply) taskBus.parseTaskOutput(taskId, progress.lastReply)
527
- // #3 动态重规划: 检测 [REPLAN:...] 标记
528
- const replan = parseReplanMarker(progress.lastReply)
529
- if (replan?.stages) {
530
- const { plan, errors } = validateAndNormalizeStagePlan(replan, { objective: prompt, defaults: planDefaults })
531
- if (!errors.length) {
532
- stagePlan = plan
533
- await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_REPLAN, sessionId, payload: { newStageCount: plan.stages.length } })
534
- }
535
- }
536
- }
537
- if (stageResult.completionMarkerSeen) completionMarkerSeen = true
538
- if (stageResult.fileChanges?.length) {
539
- fileChanges = mergeCappedFileChanges(fileChanges, stageResult.fileChanges, fileChangesLimit)
540
- }
541
-
542
- gateStatus[stage.stageId] = {
543
- status: stageResult.allSuccess ? "pass" : "fail",
544
- successCount: stageResult.successCount, failCount: stageResult.failCount
545
- }
546
-
547
- // #1 阶段级压缩 + #3 文件去重 — 结构化摘要,跨阶段去重文件路径
548
- const taskSummaries = Object.values(stageResult.taskProgress || {})
549
- .filter(t => t.lastReply)
550
- .map(t => ` - [${t.taskId}] ${t.status}: ${t.lastReply.slice(0, 250)}`)
551
- const stageFiles = (stageResult.fileChanges || [])
552
- .map(f => (typeof f === "string" ? f : (f.path || f.file || "")))
553
- .filter(Boolean)
554
- const newFiles = stageFiles.filter(f => !seenFilePaths.has(f))
555
- newFiles.forEach(f => seenFilePaths.add(f))
556
- if (taskSummaries.length || newFiles.length) {
557
- const fileNote = newFiles.length ? `\n 新增/修改文件: ${newFiles.join(", ")}` : ""
558
- const failNote = !stageResult.allSuccess ? ` 失败任务数: ${stageResult.failCount}` : ""
559
- priorContext += `\n### 阶段${stageIndex + 1}: ${stage.name || stage.stageId} (${stageResult.allSuccess ? "PASS" : "FAIL"}${failNote})\n${taskSummaries.join("\n")}${fileNote}\n`
560
- }
561
- // #4 TaskBus 注入到 priorContext
562
- if (taskBus) {
563
- const busCtx = taskBus.toContextString()
564
- if (busCtx) priorContext += `\n${busCtx}\n`
565
- }
566
- // #13 上下文压缩
567
- const pressureLimit = Number(hybridConfig.context_pressure_limit || 8000)
568
- if (priorContext.length > pressureLimit) {
569
- priorContext = await compressContext(priorContext, pressureLimit, { model, providerType, sessionId, configState, baseUrl, apiKeyEnv, signal, toolContext })
570
- await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_CONTEXT_COMPRESSED, sessionId, payload: { newLength: priorContext.length } })
571
- }
572
-
573
- lastProgress = {
574
- percentage: Math.round(((stageIndex + (stageResult.allSuccess ? 1 : 0)) / Math.max(1, stagePlan.stages.length)) * 100),
575
- currentStep: stageIndex + (stageResult.allSuccess ? 1 : 0),
576
- totalSteps: stagePlan.stages.length
577
- }
578
-
579
- // Git: 每 stage 自动 commit
580
- if (gitActive && stageResult.allSuccess && gitConfig.auto_commit_stages !== false) {
581
- const msg = `[kkcode-hybrid] stage ${stage.stageId} completed (${stageIndex + 1}/${stagePlan.stages.length})`
582
- await git.commitAll(msg, cwd)
583
- }
584
-
585
- // #10 增量门控:每个 stage 完成后运行轻量检查
586
- if (hybridConfig.incremental_gates !== false && stageResult.allSuccess && stageIndex < stagePlan.stages.length - 1) {
587
- const stageFiles = (stageResult.fileChanges || []).map(f => f.path).filter(Boolean)
588
- if (stageFiles.length > 0) {
589
- const miniGate = await runUsabilityGates({
590
- sessionId, configState, model, providerType, baseUrl, apiKeyEnv, signal, toolContext,
591
- objective: `Verify stage ${stage.stageId}: ${stage.name || ""}`, fileChanges: stageResult.fileChanges || [],
592
- gatesConfig: { ...gatesConfig, lint: true, typecheck: true, test: false, security: false, build: false }, allowQuestion: false
593
- })
594
- if (miniGate.usage) accumulateUsage(miniGate)
595
- gateStatus[`gate_${stage.stageId}`] = { status: miniGate.allPassed ? "pass" : "warn" }
596
- await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_INCREMENTAL_GATE, sessionId, payload: { stageId: stage.stageId, passed: miniGate.allPassed } })
597
- // #18: Feed gate results into priorContext so subsequent stages see lint/typecheck feedback
598
- if (!miniGate.allPassed && miniGate.failures?.length) {
599
- const gateFeedback = miniGate.failures.slice(0, 3).map(f => `${f.gate}: ${(f.reason || "").slice(0, 150)}`).join("; ")
600
- priorContext += `\n### Incremental Gate Warning (${stage.stageId})\n${gateFeedback}\n`
601
- }
602
- }
603
- }
604
-
605
- // #14 预算感知:检查 token 消耗是否超限
606
- // #21: 增加基于历史平均值的预算预测
607
- if (hybridConfig.budget_awareness !== false) {
608
- const totalTokens = aggregateUsage.input + aggregateUsage.output
609
- const budgetLimit = Number(longagentConfig.token_budget || 2000000)
610
-
611
- // #21: Predict remaining budget based on average per-stage cost
612
- const completedStages = stageIndex + (stageResult.allSuccess ? 1 : 0)
613
- const remainingStages = stagePlan.stages.length - completedStages
614
- if (completedStages > 0 && remainingStages > 0) {
615
- const avgPerStage = totalTokens / completedStages
616
- const predicted = totalTokens + avgPerStage * remainingStages
617
- if (predicted > budgetLimit && totalTokens <= budgetLimit * 0.9) {
618
- await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_BUDGET_WARNING, sessionId, payload: { totalTokens, budgetLimit, predicted: Math.round(predicted), percentage: Math.round(totalTokens / budgetLimit * 100), forecast: true } })
619
- await syncState({ lastMessage: `H4: budget forecast — predicted ${Math.round(predicted / 1000)}k tokens (limit ${Math.round(budgetLimit / 1000)}k)` })
620
- }
621
- }
622
-
623
- if (totalTokens > budgetLimit * 0.9) {
624
- await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_BUDGET_WARNING, sessionId, payload: { totalTokens, budgetLimit, percentage: Math.round(totalTokens / budgetLimit * 100) } })
625
- await syncState({ lastMessage: `H4: budget warning — ${Math.round(totalTokens / budgetLimit * 100)}% used` })
626
- }
627
- if (totalTokens > budgetLimit) {
628
- // Phase 6: 尝试降级而非直接 break
629
- if (degradationChain.canDegrade()) {
630
- const degCtx2 = { model, taskProgress, configState, shouldStop: false }
631
- const deg = degradationChain.apply(degCtx2)
632
- if (degCtx2.model !== model) model = degCtx2.model
633
- await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_DEGRADATION_APPLIED, sessionId, payload: { strategy: deg.strategy, phase: "H4", reason: "budget_exceeded" } })
634
- if (deg.applied && deg.strategy === "graceful_stop") {
635
- await syncState({ status: "budget_exceeded", lastMessage: `H4: budget exceeded, graceful stop` })
636
- break
637
- }
638
- } else {
639
- await syncState({ status: "budget_exceeded", lastMessage: `H4: budget exceeded (${totalTokens}/${budgetLimit})` })
640
- break
641
- }
642
- }
643
- }
644
-
645
- if (!stageResult.allSuccess) {
646
- recoveryCount++
647
- const backoffMs = Math.min(1000 * 2 ** (recoveryCount - 1), 30000)
648
- await new Promise(r => setTimeout(r, backoffMs))
649
- const maxStageRecoveries = Number(longagentConfig.max_stage_recoveries ?? 3)
650
- if (recoveryCount >= maxStageRecoveries) {
651
- // Phase 6: 尝试降级而非直接 abort
652
- if (degradationChain.canDegrade()) {
653
- const degCtx3 = { model, taskProgress, configState, shouldStop: false }
654
- const deg = degradationChain.apply(degCtx3)
655
- if (degCtx3.model !== model) model = degCtx3.model
656
- await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_DEGRADATION_APPLIED, sessionId, payload: { strategy: deg.strategy, phase: "H4", reason: "max_recoveries" } })
657
- if (deg.applied && deg.strategy === "graceful_stop") {
658
- await syncState({ status: "error", lastMessage: `stage ${stage.stageId} aborted after degradation` })
659
- break
660
- }
661
- // 降级成功但非 graceful_stop,重置 recoveryCount 继续
662
- recoveryCount = 0
663
- } else {
664
- await syncState({ status: "error", lastMessage: `stage ${stage.stageId} aborted after ${recoveryCount} recoveries` })
665
- break
666
- }
667
- }
668
- // Phase 1: 根据错误类别决定是否重试
669
- for (const [taskId, tp] of Object.entries(taskProgress)) {
670
- if (tp.status === "error") {
671
- const category = classifyError(tp.lastError)
672
- if (category === ERROR_CATEGORIES.PERMANENT || category === ERROR_CATEGORIES.UNKNOWN) {
673
- taskProgress[taskId] = { ...tp, status: "error", skipReason: `${category} error` }
674
- } else {
675
- taskProgress[taskId] = { ...tp, status: "retrying", attempt: 0 }
676
- }
677
- }
678
- }
679
- continue
680
- }
681
-
682
- stageIndex++
683
- recoveryCount = 0 // reset per-stage recovery counter after successful stage
684
- await saveCheckpoint(sessionId, { name: `hybrid_stage_${stage.stageId}`, iteration, currentPhase, stageIndex, stagePlan, taskProgress, planFrozen, lastProgress })
685
- }
686
-
687
- // #11 Cross-review:H4 完成后、H5 之前,让独立 agent 审查代码
688
- if (hybridConfig.cross_review !== false && fileChanges.length > 0) {
689
- await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_CROSS_REVIEW, sessionId, payload: { fileCount: fileChanges.length } })
690
- const reviewFiles = fileChanges.slice(0, 20).map(f => f.path).join(", ")
691
- const reviewOut = await processTurnLoop({
692
- prompt: [
693
- "You are the CROSS-REVIEW agent. Multiple parallel sub-agents just completed their coding tasks independently.",
694
- "Your job: verify that their outputs are compatible, correct, and integrate properly.",
695
- "",
696
- "## Files to review:",
697
- reviewFiles,
698
- "",
699
- "## Review Checklist",
700
- "1. IMPORT RESOLUTION: Do all cross-file imports resolve? Are exported symbols correct?",
701
- "2. INTERFACE COMPATIBILITY: Do function signatures match what callers expect?",
702
- "3. ERROR HANDLING: Are errors properly caught, propagated, or thrown? No silent failures?",
703
- "4. RESOURCE CLEANUP: Are timers cleared, listeners removed, handles closed in all code paths?",
704
- "5. EDGE CASES: Null/undefined checks, empty arrays, concurrent access guards?",
705
- "6. CONSISTENCY: Same naming conventions, error patterns, async style across files?",
706
- "",
707
- `## Original Objective: ${prompt}`,
708
- "",
709
- "## Output Format",
710
- "For each issue found, output: [FAILED_TASK: taskId] with a description of the problem.",
711
- "If no issues found, state that the cross-review passed.",
712
- "Focus on REAL bugs that would cause runtime failures not style preferences."
713
- ].join("\n"),
714
- mode: "agent", agent: getAgent("debugging-agent"),
715
- model, providerType, sessionId, configState, baseUrl, apiKeyEnv, signal, output, allowQuestion: false, toolContext
716
- })
717
- accumulateUsage(reviewOut)
718
- // 将审查发现注入 priorContext
719
- if (reviewOut.reply) priorContext += `\n### Cross-Review Findings\n${reviewOut.reply.slice(0, 1500)}\n`
720
- }
721
-
722
- // --- H5: DEBUGGING (回滚检测) ---
723
- await setPhase("H5", "debugging")
724
- currentGate = "debugging"
725
- await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_DEBUGGING_START, sessionId, payload: { codingRollbackCount } })
726
- await syncState({ lastMessage: "H5: debugging agent verifying implementation" })
727
-
728
- const debugModel = getModelForStage("debugging")
729
- const debugPrompt = buildStageWrapper(LONGAGENT_4STAGE_STAGES.DEBUGGING, {
730
- preview: previewFindings.slice(0, 2000),
731
- blueprint: architectureText.slice(0, 3000),
732
- coding: priorContext.slice(0, 4000)
733
- }, prompt)
734
-
735
- let debugIter = 0
736
- let debugDone = false
737
- const semanticTracker = createSemanticErrorTracker(3)
738
- const debugPhaseStart = Date.now()
739
-
740
- while (!debugDone && debugIter < maxDebugIterations) {
741
- debugIter++
742
- iteration++
743
- const state = await LongAgentManager.get(sessionId)
744
- if (state?.stopRequested || signal?.aborted) break
745
-
746
- // Phase 2: debugging 阶段超时检测
747
- if (Date.now() - debugPhaseStart > debuggingPhaseTimeoutMs) {
748
- await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_PHASE_TIMEOUT, sessionId, payload: { phase: "H5", elapsed: Date.now() - debugPhaseStart } })
749
- if (degradationChain.canDegrade()) {
750
- const degCtx4 = { model, taskProgress, configState, shouldStop: false }
751
- const deg = degradationChain.apply(degCtx4)
752
- if (degCtx4.model !== model) model = degCtx4.model
753
- await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_DEGRADATION_APPLIED, sessionId, payload: { strategy: deg.strategy, phase: "H5" } })
754
- if (deg.applied && deg.strategy === "graceful_stop") break
755
- } else {
756
- break
757
- }
758
- }
759
-
760
- const debugOut = await processTurnLoop({
761
- prompt: debugPrompt, mode: "agent", agent: getAgent("debugging-agent"),
762
- model: debugModel.model, providerType: debugModel.providerType,
763
- sessionId, configState, baseUrl, apiKeyEnv, signal, output, allowQuestion, toolContext
764
- })
765
- accumulateUsage(debugOut)
766
- finalReply = debugOut.reply || ""
767
-
768
- // 防卡死检测
769
- if (debugOut.toolEvents?.length) {
770
- const stuckResult = stuckTracker.track(debugOut.toolEvents)
771
- if (stuckResult.isStuck) {
772
- stuckTracker.resetReadOnlyCount()
773
- await EventBus.emit({
774
- type: EVENT_TYPES.LONGAGENT_ALERT, sessionId,
775
- payload: { kind: "stuck_warning", stage: "H5:debugging", reason: stuckResult.reason, debugIter }
776
- })
777
- await syncState({ lastMessage: `H5: stuck detected (${stuckResult.reason}), iter ${debugIter}` })
778
- }
779
- }
780
-
781
- // Phase 5: 语义级错误检测
782
- const semResult = semanticTracker.track(finalReply)
783
- if (semResult.isRepeated) {
784
- await EventBus.emit({
785
- type: EVENT_TYPES.LONGAGENT_SEMANTIC_ERROR_REPEATED, sessionId,
786
- payload: { error: semResult.error, count: semResult.count, debugIter }
787
- })
788
- // 注入更详细的错误分析提示,避免无限循环
789
- await syncState({ lastMessage: `H5: repeated error detected (${semResult.count}x): ${(semResult.error || "").slice(0, 80)}` })
790
- }
791
-
792
- if (detectStageComplete(finalReply, LONGAGENT_4STAGE_STAGES.DEBUGGING)) {
793
- debugDone = true
794
- gateStatus.debugging = { status: "pass", iterations: debugIter }
795
- }
796
-
797
- if (detectReturnToCoding(finalReply)) {
798
- codingRollbackCount++
799
- rerunCoding = true
800
- // #1 细粒度回滚:优先只重置被标记的失败 task
801
- const failedIds = extractFailedTaskIds(finalReply)
802
- if (failedIds.length > 0) {
803
- for (const fid of failedIds) {
804
- if (taskProgress[fid]) taskProgress[fid] = { ...taskProgress[fid], status: "retrying", attempt: 0 }
805
- }
806
- } else {
807
- // 回退:重置所有 error 状态的 task
808
- for (const [taskId, tp] of Object.entries(taskProgress)) {
809
- if (tp.status === "error") taskProgress[taskId] = { ...tp, status: "retrying", attempt: 0 }
810
- }
811
- }
812
- gateStatus.debugging = { status: "rollback", iterations: debugIter, rollbackCount: codingRollbackCount, failedTaskIds: failedIds }
813
- await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_RETURN_TO_CODING, sessionId, payload: { rollbackCount: codingRollbackCount, failedTaskIds: failedIds } })
814
- break
815
- }
816
-
817
- if (/\[TASK_COMPLETE\]/i.test(finalReply)) { completionMarkerSeen = true; debugDone = true }
818
- await syncState({ lastMessage: `H5: debugging iteration ${debugIter}/${maxDebugIterations}` })
819
- }
820
-
821
- if (!debugDone && !rerunCoding) {
822
- gateStatus.debugging = { status: "timeout", iterations: debugIter }
823
- }
824
-
825
- await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_DEBUGGING_COMPLETE, sessionId, payload: { debugIter, rollback: rerunCoding } })
826
- await syncState({ lastMessage: rerunCoding ? `H5: rollback to coding (attempt ${codingRollbackCount})` : `H5: debugging complete` })
827
- } // end while(rerunCoding)
828
-
829
- // ========== H5.5: COMPLETION VALIDATION ==========
830
- if (hybridConfig.completion_validation !== false) {
831
- await setPhase("H5.5", "completion_validation")
832
- await syncState({ lastMessage: "H5.5: validating completion" })
833
-
834
- const cwd = process.cwd()
835
- try {
836
- const validator = await createValidator({ cwd, configState })
837
- const report = await validator.validate({ todoState: toolContext?._todoState, level: "standard" })
838
- gateStatus.completionValidation = {
839
- status: report.verdict === "BLOCK" ? "fail" : "pass",
840
- verdict: report.verdict,
841
- failedChecks: report.results?.filter(r => !r.passed).length || 0
842
- }
843
-
844
- if (report.verdict === "BLOCK" && !completionMarkerSeen) {
845
- const fixPrompt = [
846
- "## Completion Validation Failed Fix Required",
847
- "",
848
- `Original objective: ${prompt}`,
849
- "",
850
- "## Validation Issues Found:",
851
- report.message,
852
- "",
853
- "## Fix Instructions",
854
- "1. Read each failing check and identify the root cause",
855
- "2. Fix the issue in the source code (not by suppressing the check)",
856
- "3. Re-run the relevant verification command to confirm the fix",
857
- "4. If a fix requires changes to multiple files, ensure cross-file consistency",
858
- "",
859
- "When ALL issues are resolved and verified, include [TASK_COMPLETE] in your response."
860
- ].join("\n")
861
- const fixOut = await processTurnLoop({
862
- prompt: fixPrompt, mode: "agent", agent: getAgent("coding-agent"),
863
- model, providerType, sessionId, configState,
864
- baseUrl, apiKeyEnv, signal, output, allowQuestion, toolContext
865
- })
866
- accumulateUsage(fixOut)
867
- iteration++
868
- if (/\[TASK_COMPLETE\]/i.test(fixOut.reply || "")) completionMarkerSeen = true
869
- finalReply = fixOut.reply || finalReply
870
- }
871
- } catch (valErr) {
872
- gateStatus.completionValidation = { status: "warn", reason: `skipped: ${valErr.message}` }
873
- }
874
- }
875
-
876
- // ========== H6: USABILITY GATES ==========
877
- await setPhase("H6", "gates")
878
- currentGate = "gates"
879
- await syncState({ lastMessage: "H6: running usability gates" })
880
-
881
- // Gate 偏好提示(首次运行时询问用户)
882
- const shouldPromptGates = gatesConfig.prompt_user === "first_run" || gatesConfig.prompt_user === "always"
883
- if (shouldPromptGates && allowQuestion) {
884
- const hasPrefs = await hasGatePreferences()
885
- if (!hasPrefs || gatesConfig.prompt_user === "always") {
886
- const gateAskResult = await processTurnLoop({
887
- prompt: buildGatePromptText(),
888
- mode: "ask", model, providerType, sessionId, configState,
889
- baseUrl, apiKeyEnv, agent, signal, allowQuestion: true, toolContext
890
- })
891
- accumulateUsage(gateAskResult)
892
- const gatePrefs = parseGateSelection(gateAskResult.reply)
893
- await saveGatePreferences(gatePrefs)
894
- for (const [gate, enabled] of Object.entries(gatePrefs)) {
895
- if (configState.config.agent.longagent.usability_gates[gate]) {
896
- configState.config.agent.longagent.usability_gates[gate].enabled = enabled
897
- }
898
- }
899
- } else {
900
- const savedPrefs = await getGatePreferences()
901
- if (savedPrefs) {
902
- for (const [gate, enabled] of Object.entries(savedPrefs)) {
903
- if (configState.config.agent.longagent.usability_gates[gate]) {
904
- configState.config.agent.longagent.usability_gates[gate].enabled = enabled
905
- }
906
- }
907
- }
908
- }
909
- }
910
-
911
- let gateAttempt = 0
912
-
913
- while (gateAttempt < maxGateAttempts) {
914
- gateAttempt++
915
- const state = await LongAgentManager.get(sessionId)
916
- if (state?.stopRequested || signal?.aborted) break
917
-
918
- const gateResult = await runUsabilityGates({
919
- sessionId, configState, model, providerType,
920
- baseUrl, apiKeyEnv, signal, toolContext,
921
- objective: prompt, fileChanges,
922
- gatesConfig, allowQuestion
923
- })
924
- if (gateResult.usage) accumulateUsage(gateResult)
925
-
926
- if (gateResult.allPassed) {
927
- gateStatus.usabilityGates = { status: "pass", attempt: gateAttempt }
928
- break
929
- }
930
-
931
- lastGateFailures = gateResult.failures || []
932
- gateStatus.usabilityGates = { status: "fixing", attempt: gateAttempt, failures: summarizeGateFailures(lastGateFailures) }
933
- await syncState({ lastMessage: `H6: gate failures (attempt ${gateAttempt}/${maxGateAttempts}), fixing...` })
934
-
935
- // 修复循环:根据 gate 类型选择修复策略 (Phase 8)
936
- const strategy = getGateFixStrategy(lastGateFailures)
937
-
938
- // lint 失败时先尝试自动修复
939
- if (strategy.autoFix) {
940
- try {
941
- const { execSync } = await import("node:child_process")
942
- execSync(strategy.autoFix, { cwd: process.cwd(), timeout: 30000, stdio: "ignore" })
943
- } catch { /* autofix failed, fall through to agent */ }
944
- }
945
-
946
- const gateFailureSummary = summarizeGateFailures(lastGateFailures)
947
- const fixPrompt = [
948
- `## Quality Gate Failures — Attempt ${gateAttempt}/${maxGateAttempts}`,
949
- "",
950
- `${strategy.prefix || "Fix the following quality gate failures:"}`,
951
- "",
952
- gateFailureSummary,
953
- "",
954
- "## Fix Protocol",
955
- "1. Read the error output carefully — identify the ROOT CAUSE, not just the symptom",
956
- "2. Fix the source code (do NOT disable or skip the gate check)",
957
- "3. Re-run the failing command to verify the fix works",
958
- "4. If the fix touches shared code, verify no regressions in other modules",
959
- "",
960
- `Original objective: ${prompt}`
961
- ].join("\n")
962
- const fixOut = await processTurnLoop({
963
- prompt: fixPrompt, mode: "agent", agent: getAgent(strategy.agent || "coding-agent"),
964
- model, providerType, sessionId, configState,
965
- baseUrl, apiKeyEnv, signal, output, allowQuestion, toolContext
966
- })
967
- accumulateUsage(fixOut)
968
- iteration++
969
- }
970
-
971
- if (gateAttempt >= maxGateAttempts && lastGateFailures.length) {
972
- gateStatus.usabilityGates = { status: "fail", attempt: gateAttempt, failures: summarizeGateFailures(lastGateFailures) }
973
- }
974
-
975
- // ========== H7: GIT MERGE ==========
976
- if (gitActive && gitBaseBranch && gitBranch) {
977
- await setPhase("H7", "git_merge")
978
- try {
979
- await git.commitAll(`[kkcode-hybrid] session ${sessionId} completed`, cwd)
980
- if (gitConfig.auto_merge !== false) {
981
- await git.checkoutBranch(gitBaseBranch, cwd)
982
- await git.mergeBranch(gitBranch, cwd)
983
- await git.deleteBranch(gitBranch, cwd)
984
- gateStatus.gitMerge = { status: "pass", branch: gitBranch, baseBranch: gitBaseBranch }
985
- await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_GIT_MERGED, sessionId, payload: { branch: gitBranch, baseBranch: gitBaseBranch } })
986
- }
987
- } catch (err) {
988
- // Phase 9: 自愈式 Git 操作
989
- if (git.isConflictError(err)) {
990
- try {
991
- const conflictFiles = await git.getConflictFiles(cwd)
992
- if (conflictFiles.length > 0) {
993
- await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_GIT_CONFLICT_RESOLUTION, sessionId, payload: { files: conflictFiles } })
994
- const conflictPrompt = [
995
- "## Git Merge Conflict Resolution",
996
- "",
997
- "The following files have merge conflicts that must be resolved:",
998
- ...conflictFiles.map(f => `- ${f}`),
999
- "",
1000
- "## Resolution Protocol",
1001
- "1. Read each conflicted file and locate ALL conflict markers (<<<<<<< ======= >>>>>>>)",
1002
- "2. For each conflict block:",
1003
- " - Understand what BOTH sides intended (ours = feature branch, theirs = base branch)",
1004
- " - Keep the feature branch changes (our work) unless they break base branch functionality",
1005
- " - If both sides modified the same logic, merge them intelligently (not just pick one)",
1006
- " - Remove ALL conflict markers — no <<<<<<< or ======= or >>>>>>> should remain",
1007
- "3. After resolving, run syntax check on each file (node --check / python -m py_compile)",
1008
- "4. Verify imports still resolve correctly across resolved files"
1009
- ].join("\n")
1010
- const conflictOut = await processTurnLoop({
1011
- prompt: conflictPrompt, mode: "agent", agent: getAgent("coding-agent"),
1012
- model, providerType, sessionId, configState,
1013
- baseUrl, apiKeyEnv, signal, output, allowQuestion: false, toolContext
1014
- })
1015
- accumulateUsage(conflictOut)
1016
- const commitResult = await git.commitAll(`[kkcode-hybrid] resolved merge conflicts`, cwd)
1017
- if (commitResult.ok) {
1018
- gateStatus.gitMerge = { status: "pass", branch: gitBranch, baseBranch: gitBaseBranch, conflictsResolved: true }
1019
- await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_GIT_MERGED, sessionId, payload: { branch: gitBranch, baseBranch: gitBaseBranch } })
1020
- } else {
1021
- await git.mergeAbort(cwd)
1022
- gateStatus.gitMerge = { status: "warn", reason: "conflict resolution failed, staying on feature branch" }
1023
- }
1024
- } else {
1025
- gateStatus.gitMerge = { status: "warn", reason: err.message }
1026
- }
1027
- } catch (resolveErr) {
1028
- await git.mergeAbort(cwd).catch(() => {})
1029
- gateStatus.gitMerge = { status: "warn", reason: `conflict resolution error: ${resolveErr.message}` }
1030
- }
1031
- } else {
1032
- gateStatus.gitMerge = { status: "warn", reason: err.message }
1033
- }
1034
- }
1035
- }
1036
-
1037
- // #5 保存 project memory
1038
- if (hybridConfig.project_memory !== false && previewFindings) {
1039
- try {
1040
- const newMemory = parseMemoryFromPreview(previewFindings)
1041
- if (newMemory.techStack.length) {
1042
- const merged = { ...projectMemory, techStack: [...new Set([...(projectMemory?.techStack || []), ...newMemory.techStack])].slice(0, 20), patterns: [...new Set([...(projectMemory?.patterns || []), ...newMemory.patterns])].slice(0, 20), conventions: projectMemory?.conventions || [] }
1043
- await saveProjectMemory(cwd, merged)
1044
- await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_MEMORY_SAVED, sessionId, payload: { techStackCount: merged.techStack.length } })
1045
- }
1046
- } catch { /* ignore memory save errors */ }
1047
- }
1048
-
1049
- // Phase 10: Checkpoint 清理
1050
- if (hybridConfig.checkpoint_cleanup !== false) {
1051
- try {
1052
- const cleanResult = await cleanupCheckpoints(sessionId, {
1053
- maxKeep: Number(hybridConfig.checkpoint_max_keep || 10),
1054
- keepStageCheckpoints: true
1055
- })
1056
- if (cleanResult.removed > 0) {
1057
- await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_CHECKPOINT_CLEANED, sessionId, payload: { removed: cleanResult.removed } })
1058
- }
1059
- } catch { /* ignore cleanup errors */ }
1060
- }
1061
-
1062
- // ========== 完成 ==========
1063
- const elapsed = Math.round((Date.now() - startTime) / 1000)
1064
- const finalStatus = completionMarkerSeen ? "completed" : "done"
1065
- await LongAgentManager.update(sessionId, { status: finalStatus, lastMessage: "hybrid longagent complete", elapsed })
1066
- await markSessionStatus(sessionId, finalStatus === "completed" ? "completed" : "active")
1067
-
1068
- const stats = stageProgressStats(taskProgress)
1069
-
1070
- // Phase 11: 恢复建议生成
1071
- let recoverySuggestions = null
1072
- if (finalStatus !== "completed") {
1073
- recoverySuggestions = generateRecoverySuggestions({
1074
- status: finalStatus,
1075
- taskProgress,
1076
- gateStatus,
1077
- phase: currentPhase,
1078
- recoveryCount,
1079
- fileChanges
1080
- })
1081
- }
1082
-
1083
- return {
1084
- sessionId, turnId: `turn_long_${Date.now()}`,
1085
- reply: finalReply || "hybrid longagent complete",
1086
- usage: aggregateUsage, toolEvents, iterations: iteration,
1087
- status: finalStatus, phase: currentPhase,
1088
- gateStatus, currentGate, lastGateFailures, recoveryCount,
1089
- progress: lastProgress, elapsed,
1090
- stageIndex, stageCount: stagePlan?.stages?.length || 0,
1091
- planFrozen, taskProgress, fileChanges,
1092
- stageProgress: { done: stats.done, total: stats.total },
1093
- remainingFilesCount: stats.remainingFilesCount,
1094
- gitBranch, gitBaseBranch,
1095
- recoverySuggestions
1096
- }
1097
- }
1
+ /**
2
+ * LongAgent Hybrid 模式
3
+ * 融合 4-Stage 的只读探索/规划/调试回滚 + Parallel 的脚手架/并行执行/门控
4
+ *
5
+ * 流程: H0:Intake → H1:Preview → H2:Blueprint → H2.5:Git → H3:Scaffold → H4:Coding(并行) → H5:Debugging(回滚) → H5.5:Validation → H6:Gates → H7:GitMerge
6
+ */
7
+ import { LongAgentManager } from "../orchestration/longagent-manager.mjs"
8
+ import { processTurnLoop } from "./loop.mjs"
9
+ import { markSessionStatus } from "./store.mjs"
10
+ import { EventBus } from "../core/events.mjs"
11
+ import { EVENT_TYPES, LONGAGENT_4STAGE_STAGES } from "../core/constants.mjs"
12
+ import { saveCheckpoint, loadCheckpoint, saveTaskCheckpoint, loadTaskCheckpoints, cleanupCheckpoints } from "./checkpoint.mjs"
13
+ import { getAgent } from "../agent/agent.mjs"
14
+ import { runStageBarrier } from "../orchestration/stage-scheduler.mjs"
15
+ import { runScaffoldPhase } from "./longagent-scaffold.mjs"
16
+ import {
17
+ runUsabilityGates,
18
+ hasGatePreferences,
19
+ getGatePreferences,
20
+ saveGatePreferences,
21
+ buildGatePromptText,
22
+ parseGateSelection
23
+ } from "./usability-gates.mjs"
24
+ import { runIntakeDialogue, validateAndNormalizeStagePlan, defaultStagePlan } from "./longagent-plan.mjs"
25
+ import { createValidator } from "./task-validator.mjs"
26
+ import { detectStageComplete, detectReturnToCoding, buildStageWrapper } from "./longagent-4stage.mjs"
27
+ import {
28
+ isComplete,
29
+ isLikelyActionableObjective,
30
+ mergeCappedFileChanges,
31
+ stageProgressStats,
32
+ summarizeGateFailures,
33
+ LONGAGENT_FILE_CHANGES_LIMIT,
34
+ createStuckTracker,
35
+ classifyError,
36
+ ERROR_CATEGORIES,
37
+ createSemanticErrorTracker,
38
+ createDegradationChain,
39
+ generateRecoverySuggestions,
40
+ stripFence,
41
+ parseJsonLoose,
42
+ detectFrontendTask,
43
+ buildFrontendDesignPrompt
44
+ } from "./longagent-utils.mjs"
45
+ import { TaskBus } from "./longagent-task-bus.mjs"
46
+ import { loadProjectMemory, saveProjectMemory, memoryToContext, parseMemoryFromPreview } from "./longagent-project-memory.mjs"
47
+ import YAML from "yaml"
48
+ import * as git from "../util/git.mjs"
49
+
50
+ // Checkpoint 结构校验
51
+ function validateCheckpoint(cp) {
52
+ if (!cp || !cp.stagePlan || !Array.isArray(cp.stagePlan.stages)) return false
53
+ if (typeof cp.stageIndex !== "number" || cp.stageIndex < 0) return false
54
+ if (cp.stageIndex > cp.stagePlan.stages.length) return false
55
+ // Verify the previous stage exists for task checkpoint loading
56
+ if (cp.stageIndex > 0 && !cp.stagePlan.stages[cp.stageIndex - 1]) return false
57
+ return true
58
+ }
59
+
60
+ // Gate 修复策略路由 (Phase 8)
61
+ function getGateFixStrategy(failures) {
62
+ const gateTypes = (failures || []).map(f => f.gate).filter(Boolean)
63
+ if (gateTypes.includes("test")) return { agent: "debugging-agent", prefix: "Analyze test failures and fix:" }
64
+ if (gateTypes.every(g => g === "build")) return { agent: "coding-agent", prefix: "Fix build errors:" }
65
+ if (gateTypes.every(g => g === "lint")) return { autoFix: "npx eslint --fix .", agent: "coding-agent", prefix: "Fix lint errors:" }
66
+ return { agent: "coding-agent", prefix: "Fix gate failures:" }
67
+ }
68
+
69
+ // #13 上下文压缩
70
+ async function compressContext(text, limit, { model, providerType, sessionId, configState, baseUrl, apiKeyEnv, signal, toolContext }) {
71
+ if (text.length <= limit) return text
72
+ const out = await processTurnLoop({
73
+ prompt: [
74
+ `Compress the following engineering context to max ${Math.round(limit * 0.6)} characters.`,
75
+ "Preserve ONLY:",
76
+ "- Concrete decisions made (technology choices, architecture patterns, API contracts)",
77
+ "- File paths and function signatures that were created or modified",
78
+ "- Error messages and their resolutions",
79
+ "- Cross-task dependencies and integration points",
80
+ "- Test results (pass/fail with specific failure reasons)",
81
+ "Discard: exploration logs, verbose tool output, repeated information, reasoning chains.",
82
+ "Output the compressed context directly — no preamble or explanation.",
83
+ "",
84
+ text.slice(0, limit * 2)
85
+ ].join("\n"),
86
+ mode: "assistant", model, providerType, sessionId, configState, baseUrl, apiKeyEnv, signal, allowQuestion: false, toolContext
87
+ })
88
+ return (out.reply || text.slice(0, limit)).slice(0, limit)
89
+ }
90
+
91
+ // #3 动态计划修订解析
92
+ function parseReplanMarker(text) {
93
+ const match = String(text || "").match(/\[REPLAN:\s*([\s\S]*?)\]/i)
94
+ if (!match) return null
95
+ try { return JSON.parse(match[1]) } catch { return null }
96
+ }
97
+
98
+ // #1 细粒度回滚:从 debugging 输出中提取失败的 taskId
99
+ function extractFailedTaskIds(text) {
100
+ const ids = []
101
+ const pattern = /\[FAILED_TASK:\s*(\S+)\]/gi
102
+ let m
103
+ while ((m = pattern.exec(text)) !== null) ids.push(m[1])
104
+ return ids
105
+ }
106
+
107
+ function buildConflictResolutionPrompt(conflictFiles) {
108
+ return [
109
+ "## Git Merge Conflict Resolution",
110
+ "",
111
+ "The following files have merge conflicts that must be resolved:",
112
+ ...conflictFiles.map(f => `- ${f}`),
113
+ "",
114
+ "## Resolution Protocol",
115
+ "1. Read each conflicted file and locate ALL conflict markers (<<<<<<< ======= >>>>>>>)",
116
+ "2. For each conflict block:",
117
+ " - Understand what BOTH sides intended (ours = feature branch, theirs = base branch)",
118
+ " - Keep the feature branch changes (our work) unless they break base branch functionality",
119
+ " - If both sides modified the same logic, merge them intelligently (not just pick one)",
120
+ " - Remove ALL conflict markers — no <<<<<<< or ======= or >>>>>>> should remain",
121
+ "3. After resolving, run syntax check on each file (node --check / python -m py_compile)",
122
+ "4. Verify imports still resolve correctly across resolved files"
123
+ ].join("\n")
124
+ }
125
+
126
+
127
+ function parseBlueprintOutput(reply, objective, defaults) {
128
+ const parseErrors = []
129
+
130
+ // 1. 尝试提取 ```stage_plan_json ... ```
131
+ const jsonMatch = reply.match(/```stage_plan_json\s*([\s\S]*?)```/)
132
+ if (jsonMatch) {
133
+ const parsed = parseJsonLoose(jsonMatch[1])
134
+ if (parsed?.stages) {
135
+ const { plan, errors } = validateAndNormalizeStagePlan(parsed, { objective, defaults })
136
+ if (!errors.length) {
137
+ return { architectureText: reply.replace(/```stage_plan_json[\s\S]*?```/g, "").trim(), stagePlan: plan, parseErrors: [] }
138
+ }
139
+ parseErrors.push(`stage_plan_json block validation: ${errors.join("; ")}`)
140
+ } else {
141
+ parseErrors.push("stage_plan_json block found but no stages field")
142
+ }
143
+ }
144
+
145
+ // 2. 回退:尝试任意 JSON 围栏块(排除已处理的 stage_plan_json)
146
+ const anyJson = reply.match(/```(?:json)?\s*([\s\S]*?)```/g)
147
+ if (anyJson) {
148
+ for (const block of anyJson) {
149
+ if (/```stage_plan_json/.test(block)) continue
150
+ const inner = block.replace(/```(?:json)?\s*/g, "").replace(/```/g, "").trim()
151
+ const parsed = parseJsonLoose(inner)
152
+ if (parsed?.stages) {
153
+ const { plan, errors } = validateAndNormalizeStagePlan(parsed, { objective, defaults })
154
+ if (!errors.length) return { architectureText: reply, stagePlan: plan, parseErrors: [] }
155
+ parseErrors.push(`json block validation: ${errors.join("; ")}`)
156
+ }
157
+ }
158
+ }
159
+
160
+ // 3. 回退:裸 JSON 定位含 "stages" 的最外层 {} 块
161
+ const stripped = reply.replace(/```[\s\S]*?```/g, "")
162
+ let braceDepth = 0, objStart = -1
163
+ for (let i = 0; i < stripped.length; i++) {
164
+ if (stripped[i] === "{") { if (braceDepth === 0) objStart = i; braceDepth++ }
165
+ else if (stripped[i] === "}") {
166
+ braceDepth--
167
+ if (braceDepth === 0 && objStart >= 0) {
168
+ const candidate = stripped.slice(objStart, i + 1)
169
+ if (candidate.includes('"stages"')) {
170
+ const parsed = parseJsonLoose(candidate)
171
+ if (parsed?.stages) {
172
+ const { plan, errors } = validateAndNormalizeStagePlan(parsed, { objective, defaults })
173
+ if (!errors.length) return { architectureText: reply, stagePlan: plan, parseErrors: [] }
174
+ parseErrors.push(`bare JSON validation: ${errors.join("; ")}`)
175
+ }
176
+ }
177
+ objStart = -1
178
+ }
179
+ }
180
+ }
181
+
182
+ // 4. 回退:YAML 围栏块(```yaml ... ```)
183
+ const yamlBlocks = reply.match(/```ya?ml\s*([\s\S]*?)```/g)
184
+ if (yamlBlocks) {
185
+ for (const block of yamlBlocks) {
186
+ const inner = block.replace(/```ya?ml?\s*/g, "").replace(/```/g, "").trim()
187
+ try {
188
+ const parsed = YAML.parse(inner)
189
+ if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) continue
190
+ if (parsed?.stages) {
191
+ const { plan, errors } = validateAndNormalizeStagePlan(parsed, { objective, defaults })
192
+ if (!errors.length) return { architectureText: reply, stagePlan: plan, parseErrors: [] }
193
+ parseErrors.push(`yaml block validation: ${errors.join("; ")}`)
194
+ }
195
+ } catch (e) {
196
+ parseErrors.push(`yaml parse error: ${e.message}`)
197
+ }
198
+ }
199
+ }
200
+
201
+ // 5. 最终回退:单任务默认计划
202
+ if (!parseErrors.length) parseErrors.push("no JSON/YAML with stages field found in reply")
203
+ return { architectureText: reply, stagePlan: defaultStagePlan(objective, defaults), parseErrors }
204
+ }
205
+
206
+ export async function runHybridLongAgent({
207
+ prompt, model, providerType, sessionId, configState,
208
+ baseUrl = null, apiKeyEnv = null, agent = null,
209
+ maxIterations = 0, signal = null, output = null,
210
+ allowQuestion = true, toolContext = {}
211
+ }) {
212
+ const longagentConfig = configState.config.agent.longagent || {}
213
+ const hybridConfig = longagentConfig.hybrid || {}
214
+ const parallelConfig = longagentConfig.parallel || {}
215
+ const gitConfig = longagentConfig.git || {}
216
+ const noProgressLimit = Number(longagentConfig.no_progress_limit || 5)
217
+ const maxGateAttempts = Number(longagentConfig.max_gate_attempts || 5)
218
+ const fileChangesLimit = Math.max(20, Number(longagentConfig.file_changes_limit || LONGAGENT_FILE_CHANGES_LIMIT))
219
+
220
+ // 每阶段模型选择
221
+ const separateModels = hybridConfig.separate_models || {}
222
+ const useSeparateModels = separateModels.enabled === true
223
+ const adaptiveModels = hybridConfig.adaptive_models || {}
224
+ const useAdaptiveModels = adaptiveModels.enabled === true
225
+ function getModelForStage(stage) {
226
+ if (!useSeparateModels) return { model, providerType }
227
+ const m = { preview: separateModels.preview_model, blueprint: separateModels.blueprint_model, debugging: separateModels.debugging_model }
228
+ return m[stage] ? { model: m[stage], providerType } : { model, providerType }
229
+ }
230
+ // #8 自适应模型路由:根据 task complexity 选择模型
231
+ function getModelForTask(task) {
232
+ if (!useAdaptiveModels) return model
233
+ const tier = task?.complexity || "medium"
234
+ return adaptiveModels[tier] || model
235
+ }
236
+
237
+ let iteration = 0, recoveryCount = 0, stageIndex = 0
238
+ let currentPhase = "H0", currentGate = "init"
239
+ let gateStatus = {}, lastGateFailures = []
240
+ let lastProgress = { percentage: 0, currentStep: 0, totalSteps: 0 }
241
+ let finalReply = "", planFrozen = false, stagePlan = null
242
+ let taskProgress = {}, fileChanges = []
243
+ let completionMarkerSeen = false
244
+ let gitBranch = null, gitBaseBranch = null, gitActive = false
245
+ const aggregateUsage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
246
+ const toolEvents = []
247
+ const startTime = Date.now()
248
+ const stuckTracker = createStuckTracker()
249
+ // Phase 6: 降级链
250
+ const degradationChain = createDegradationChain(hybridConfig.degradation || {})
251
+ // Phase 2: 阶段超时配置
252
+ const codingPhaseTimeoutMs = Number(hybridConfig.coding_phase_timeout_ms || 1800000)
253
+ const debuggingPhaseTimeoutMs = Number(hybridConfig.debugging_phase_timeout_ms || 600000)
254
+ // #4 TaskBus
255
+ const taskBus = hybridConfig.task_bus !== false ? new TaskBus() : null
256
+ // #5 Project Memory
257
+ const cwd = process.cwd()
258
+ let projectMemory = null
259
+ if (hybridConfig.project_memory !== false) {
260
+ try { projectMemory = await loadProjectMemory(cwd) } catch { projectMemory = null }
261
+ }
262
+
263
+ function accumulateUsage(turn) {
264
+ aggregateUsage.input += turn.usage?.input || 0
265
+ aggregateUsage.output += turn.usage?.output || 0
266
+ aggregateUsage.cacheRead += turn.usage?.cacheRead || 0
267
+ aggregateUsage.cacheWrite += turn.usage?.cacheWrite || 0
268
+ if (turn.toolEvents?.length) toolEvents.push(...turn.toolEvents)
269
+ }
270
+
271
+ async function setPhase(next, reason = "") {
272
+ if (currentPhase === next) return
273
+ const prev = currentPhase
274
+ currentPhase = next
275
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_PHASE_CHANGED, sessionId, payload: { prevPhase: prev, nextPhase: next, reason, iteration } })
276
+ }
277
+
278
+ async function syncState(patch = {}) {
279
+ const stats = stageProgressStats(taskProgress)
280
+ await LongAgentManager.update(sessionId, {
281
+ status: patch.status || "running", phase: currentPhase, gateStatus, currentGate,
282
+ recoveryCount, lastGateFailures, iterations: iteration, heartbeatAt: Date.now(),
283
+ progress: lastProgress, planFrozen, stageIndex,
284
+ stageCount: stagePlan?.stages?.length || 0,
285
+ taskProgress, stageProgress: { done: stats.done, total: stats.total },
286
+ remainingFilesCount: stats.remainingFilesCount,
287
+ ...patch
288
+ })
289
+ }
290
+
291
+ // Phase 2: 事件驱动 stop 检测 用内存标志替代磁盘轮询
292
+ let stopFlag = false
293
+ const unsubscribeStop = EventBus.subscribe((evt) => {
294
+ if (evt.type === EVENT_TYPES.LONGAGENT_STOP_REQUESTED && evt.sessionId === sessionId) {
295
+ stopFlag = true
296
+ }
297
+ })
298
+
299
+ await markSessionStatus(sessionId, "running-longagent")
300
+ await syncState({ status: "running", lastMessage: "hybrid mode started" })
301
+
302
+ // 前置检查
303
+ if (!isLikelyActionableObjective(prompt)) {
304
+ const blocked = "LongAgent 需要明确的编码目标。请直接描述要实现/修复的内容。"
305
+ await LongAgentManager.update(sessionId, { status: "blocked", phase: "H0", lastMessage: blocked })
306
+ await markSessionStatus(sessionId, "active")
307
+ unsubscribeStop()
308
+ return { sessionId, turnId: `turn_long_${Date.now()}`, reply: blocked, usage: aggregateUsage, toolEvents, iterations: 0, status: "blocked", phase: "H0", gateStatus: {}, currentGate: "init", lastGateFailures: [], recoveryCount: 0, progress: lastProgress, elapsed: 0, stageIndex: 0, stageCount: 0, planFrozen: false, taskProgress: {}, fileChanges: [], stageProgress: { done: 0, total: 0 }, remainingFilesCount: 0 }
309
+ }
310
+
311
+ // #15 Checkpoint 恢复:如果有之前的检查点,跳过已完成阶段
312
+ // #22: 增强为 task 级粒度恢复
313
+ if (hybridConfig.checkpoint_resume !== false) {
314
+ try {
315
+ const cp = await loadCheckpoint(sessionId)
316
+ if (cp?.stageIndex > 0 && cp?.stagePlan) {
317
+ if (!validateCheckpoint(cp)) {
318
+ // Invalid checkpoint structure discard and start fresh
319
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_CHECKPOINT_INVALID, sessionId, payload: { reason: "structure_validation_failed" } })
320
+ } else {
321
+ stagePlan = cp.stagePlan; stageIndex = cp.stageIndex; planFrozen = true
322
+ taskProgress = cp.taskProgress || {}; lastProgress = cp.lastProgress || lastProgress
323
+ iteration = cp.iteration || 0
324
+ // #22: Load task-level checkpoints to recover intra-stage progress
325
+ if (stageIndex > 0) {
326
+ const prevStage = cp.stagePlan.stages[stageIndex - 1]
327
+ if (prevStage) {
328
+ const taskCps = await loadTaskCheckpoints(sessionId, prevStage.stageId)
329
+ for (const [tid, tData] of Object.entries(taskCps)) {
330
+ if (!taskProgress[tid] || taskProgress[tid].status !== "completed") {
331
+ taskProgress[tid] = { ...taskProgress[tid], ...tData }
332
+ }
333
+ }
334
+ }
335
+ }
336
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_CHECKPOINT_RESUMED, sessionId, payload: { stageIndex, iteration } })
337
+ await syncState({ lastMessage: `resumed from checkpoint at stage ${stageIndex}` })
338
+ }
339
+ }
340
+ } catch { /* no checkpoint, start fresh */ }
341
+ }
342
+
343
+ // #5 Memory 事件
344
+ if (projectMemory?.techStack?.length) {
345
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_MEMORY_LOADED, sessionId, payload: { techStack: projectMemory.techStack } })
346
+ }
347
+
348
+ // ========== H0: INTAKE (需求澄清) ==========
349
+ let intakeSummary = prompt
350
+ if (hybridConfig.intake !== false && !planFrozen) {
351
+ await setPhase("H0", "intake")
352
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_INTAKE_STARTED, sessionId, payload: { objective: prompt } })
353
+ await syncState({ lastMessage: "H0: intake dialogue clarifying requirements" })
354
+
355
+ const plannerConfig = longagentConfig.planner || {}
356
+ const intakeConfig = plannerConfig.intake_questions || {}
357
+ const intake = await runIntakeDialogue({
358
+ objective: prompt,
359
+ model, providerType, sessionId, configState,
360
+ baseUrl, apiKeyEnv, agent, signal,
361
+ maxRounds: Number(intakeConfig.max_rounds || 6)
362
+ })
363
+ intakeSummary = intake.summary || prompt
364
+ accumulateUsage(intake)
365
+ gateStatus.intake = { status: "pass", rounds: intake.transcript.length, summary: intakeSummary.slice(0, 500) }
366
+ await syncState({ lastMessage: `H0: intake complete (${intake.transcript.length} qa pairs)` })
367
+
368
+ // Task 2: 用户可见的需求确认 展示 intake 摘要,请用户确认或补充
369
+ if (allowQuestion && hybridConfig.intake_user_confirm !== false) {
370
+ const confirmPrompt = [
371
+ "[SYSTEM] H0 需求分析完成。以下是对任务的理解摘要:",
372
+ "",
373
+ intakeSummary.slice(0, 1200),
374
+ "",
375
+ "请使用 question 工具询问用户:",
376
+ "1. 以上需求理解是否准确?",
377
+ "2. 是否有需要补充或修改的地方?",
378
+ "3. 如果没有补充,回复 [确认] 或 [继续] 即可开始执行。",
379
+ "",
380
+ "根据用户的回复更新 intakeSummary(如有补充则合并到需求中)。"
381
+ ].join("\n")
382
+ const confirmOut = await processTurnLoop({
383
+ prompt: confirmPrompt,
384
+ mode: "assistant", model, providerType, sessionId, configState,
385
+ baseUrl, apiKeyEnv, agent, signal, allowQuestion: true, toolContext, output
386
+ })
387
+ accumulateUsage(confirmOut)
388
+ // 如果用户提供了补充,将其合并到 intakeSummary
389
+ const userAddition = String(confirmOut.reply || "").trim()
390
+ const cancelKeywords = ["abort", "cancel", "取消", "中止", "停止"]
391
+ if (cancelKeywords.some(k => userAddition.toLowerCase().includes(k))) {
392
+ await LongAgentManager.update(sessionId, { status: "aborted", lastMessage: "user cancelled at intake confirmation" })
393
+ await markSessionStatus(sessionId, "active")
394
+ unsubscribeStop()
395
+ return { sessionId, turnId: `turn_long_${Date.now()}`, reply: "用户在需求确认阶段取消了任务。", usage: aggregateUsage, toolEvents, iterations: iteration, status: "aborted", phase: "H0", gateStatus, currentGate, lastGateFailures: [], recoveryCount: 0, progress: lastProgress, elapsed: Math.round((Date.now() - startTime) / 1000), stageIndex: 0, stageCount: 0, planFrozen: false, taskProgress: {}, fileChanges: [], stageProgress: { done: 0, total: 0 }, remainingFilesCount: 0 }
396
+ }
397
+ if (userAddition && !["确认", "继续", "ok", "yes", "是", "好", "没有", "no addition"].some(k => userAddition.toLowerCase().includes(k))) {
398
+ intakeSummary = `${intakeSummary}\n\n[用户补充]\n${userAddition}`
399
+ gateStatus.intake = { ...gateStatus.intake, userConfirmed: true, userAddition: userAddition.slice(0, 200) }
400
+ } else {
401
+ gateStatus.intake = { ...gateStatus.intake, userConfirmed: true }
402
+ }
403
+ await syncState({ lastMessage: "H0: user confirmed requirements" })
404
+ }
405
+ }
406
+
407
+ // ========== H1: PREVIEW (只读探索) ==========
408
+ await setPhase("H1", "preview")
409
+ currentGate = "preview"
410
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_PREVIEW_START, sessionId, payload: { objective: prompt } })
411
+ await syncState({ lastMessage: "H1: preview agent exploring codebase" })
412
+
413
+ const previewModel = getModelForStage("preview")
414
+ // #5 注入 project memory 到 preview prompt
415
+ const memCtx = projectMemory ? memoryToContext(projectMemory) : ""
416
+ const previewPrompt = buildStageWrapper(LONGAGENT_4STAGE_STAGES.PREVIEW, { preview: null, blueprint: null, coding: null }, memCtx ? `${memCtx}\n\n${intakeSummary}` : intakeSummary)
417
+ const previewOut = await processTurnLoop({
418
+ prompt: previewPrompt, mode: "agent", agent: getAgent("preview-agent"),
419
+ model: previewModel.model, providerType: previewModel.providerType,
420
+ sessionId, configState, baseUrl, apiKeyEnv, signal, output, allowQuestion, toolContext
421
+ })
422
+ accumulateUsage(previewOut)
423
+ const previewFindings = previewOut.reply || ""
424
+
425
+ gateStatus.preview = { status: "pass", findingsLength: previewFindings.length }
426
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_PREVIEW_COMPLETE, sessionId, payload: { findingsLength: previewFindings.length } })
427
+ await syncState({ lastMessage: `H1: preview complete (${previewFindings.length} chars)` })
428
+
429
+ // ========== H2: BLUEPRINT (只读规划 + 结构化 stagePlan) ==========
430
+ await setPhase("H2", "blueprint")
431
+ currentGate = "blueprint"
432
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_BLUEPRINT_START, sessionId, payload: {} })
433
+ await syncState({ lastMessage: "H2: blueprint agent designing architecture" })
434
+
435
+ const blueprintModel = getModelForStage("blueprint")
436
+ // Task 4: 检测前端任务,注入设计风格提示词
437
+ const isFrontend = detectFrontendTask(prompt)
438
+ const frontendBlock = isFrontend
439
+ ? "\n\n" + buildFrontendDesignPrompt(configState.config.agent?.design_style || "")
440
+ : ""
441
+ const blueprintPrompt = buildStageWrapper(LONGAGENT_4STAGE_STAGES.BLUEPRINT, { preview: previewFindings, blueprint: null, coding: null }, prompt)
442
+ + frontendBlock
443
+ + [
444
+ "\n\n## HYBRID MODE: STRUCTURED EXECUTION PLAN (REQUIRED)",
445
+ "In addition to your architecture design, you MUST output a machine-parseable stage plan.",
446
+ "",
447
+ "Wrap it in a ```stage_plan_json ... ``` fenced block. Schema:",
448
+ '{"planId":"...","objective":"...","stages":[{"stageId":"...","name":"...","passRule":"all_success","tasks":[{"taskId":"...","prompt":"detailed task prompt for sub-agent","plannedFiles":["file1.mjs","file2.mjs"],"acceptance":["node --check file1.mjs","node --test test/file1.test.mjs"],"timeoutMs":600000,"maxRetries":2,"complexity":"low|medium|high"}]}]}',
449
+ "",
450
+ "Rules for the stage plan:",
451
+ "- Each task prompt must be SELF-CONTAINED: the sub-agent has NO access to your blueprint text",
452
+ "- plannedFiles must list EVERY file the task will create or modify (no file in multiple tasks)",
453
+ "- acceptance must be machine-verifiable commands (not subjective criteria)",
454
+ "- Files that import each other MUST be in the same task",
455
+ "- A module and its test file MUST be in the same task",
456
+ "- Order stages by dependency: shared types → core logic → integration → validation"
457
+ ].join("\n")
458
+ const blueprintOut = await processTurnLoop({
459
+ prompt: blueprintPrompt, mode: "agent", agent: getAgent("blueprint-agent"),
460
+ model: blueprintModel.model, providerType: blueprintModel.providerType,
461
+ sessionId, configState, baseUrl, apiKeyEnv, signal, output, allowQuestion, toolContext
462
+ })
463
+ accumulateUsage(blueprintOut)
464
+
465
+ const planDefaults = { timeoutMs: Number(parallelConfig.task_timeout_ms || 600000), maxRetries: Number(parallelConfig.task_max_retries ?? 2) }
466
+ let { architectureText, stagePlan: parsedPlan, parseErrors } = parseBlueprintOutput(blueprintOut.reply || "", prompt, planDefaults)
467
+
468
+ // Blueprint 解析失败重试:用 repair prompt 要求 LLM 只输出合法 JSON
469
+ const maxBlueprintRetries = Number(hybridConfig.blueprint_parse_retries || 1)
470
+ if (parseErrors.length > 0 && maxBlueprintRetries > 0) {
471
+ for (let retryIdx = 0; retryIdx < maxBlueprintRetries; retryIdx++) {
472
+ await EventBus.emit({
473
+ type: EVENT_TYPES.LONGAGENT_ALERT, sessionId,
474
+ payload: { kind: "blueprint_parse_retry", attempt: retryIdx + 1, errors: parseErrors }
475
+ })
476
+ const repairPrompt = [
477
+ "Your previous blueprint output could not be parsed into a valid stage plan.",
478
+ `Parse errors: ${parseErrors.join("; ")}`,
479
+ "",
480
+ "Output ONLY a valid JSON object (no markdown, no explanation) with this schema:",
481
+ '{"planId":"...","objective":"...","stages":[{"stageId":"...","name":"...","tasks":[{"taskId":"...","prompt":"...","plannedFiles":["..."],"acceptance":["..."],"timeoutMs":600000,"maxRetries":2,"complexity":"medium"}]}]}',
482
+ "",
483
+ `Objective: ${prompt}`
484
+ ].join("\n")
485
+ const repairOut = await processTurnLoop({
486
+ prompt: repairPrompt, mode: "assistant",
487
+ model: blueprintModel.model, providerType: blueprintModel.providerType,
488
+ sessionId, configState, baseUrl, apiKeyEnv, signal,
489
+ output: { write: () => {} }, allowQuestion: false
490
+ })
491
+ accumulateUsage(repairOut)
492
+ const retry = parseBlueprintOutput(repairOut.reply || "", prompt, planDefaults)
493
+ if (retry.parseErrors.length === 0) {
494
+ architectureText = architectureText || retry.architectureText
495
+ parsedPlan = retry.stagePlan
496
+ parseErrors = []
497
+ break
498
+ }
499
+ parseErrors = retry.parseErrors
500
+ }
501
+ }
502
+
503
+ stagePlan = parsedPlan
504
+ planFrozen = true
505
+
506
+ const blueprintFellBack = parseErrors.length > 0
507
+ gateStatus.blueprint = {
508
+ status: blueprintFellBack ? "warn" : "pass",
509
+ hasArchitecture: architectureText.length > 100,
510
+ stageCount: stagePlan.stages.length,
511
+ parseErrors: blueprintFellBack ? parseErrors : undefined
512
+ }
513
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_BLUEPRINT_COMPLETE, sessionId, payload: { planId: stagePlan.planId, stageCount: stagePlan.stages.length } })
514
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_PLAN_FROZEN, sessionId, payload: { planId: stagePlan.planId, stageCount: stagePlan.stages.length, errors: [] } })
515
+ await syncState({ planFrozen: true, lastMessage: `H2: blueprint complete, ${stagePlan.stages.length} stage(s)` })
516
+
517
+ // #9 Blueprint 语义验证
518
+ if (hybridConfig.blueprint_validation !== false && stagePlan.stages.length > 0) {
519
+ const totalTasks = stagePlan.stages.reduce((s, st) => s + (st.tasks?.length || 0), 0)
520
+ const totalFiles = new Set(stagePlan.stages.flatMap(st => (st.tasks || []).flatMap(t => t.plannedFiles || []))).size
521
+ const valid = totalTasks > 0 && totalFiles > 0
522
+ gateStatus.blueprintValidation = { status: valid ? "pass" : "warn", totalTasks, totalFiles }
523
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_BLUEPRINT_VALIDATED, sessionId, payload: { totalTasks, totalFiles, valid } })
524
+ }
525
+
526
+ // #2 人工审查检查点(Task 2: 默认 ON,用户可见的 Blueprint 确认)
527
+ if (hybridConfig.blueprint_review !== false && allowQuestion) {
528
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_BLUEPRINT_REVIEW, sessionId, payload: { planId: stagePlan.planId } })
529
+ const stageList = stagePlan.stages.map((s, i) => ` ${i + 1}. ${s.name || s.stageId} (${(s.tasks || []).length} 个任务)`).join("\n")
530
+ const reviewOut = await processTurnLoop({
531
+ prompt: [
532
+ `[SYSTEM] H2 Blueprint 已生成,包含 ${stagePlan.stages.length} 个执行阶段:`,
533
+ stageList,
534
+ "",
535
+ "架构摘要:",
536
+ architectureText.slice(0, 1200),
537
+ "",
538
+ "请使用 question 工具询问用户:",
539
+ "1. 以上执行计划是否符合预期?",
540
+ "2. 是否有需要调整的阶段或任务?",
541
+ "3. 确认后将开始执行,输入 [确认]/[继续]/yes 开始,输入 [取消]/abort 中止。",
542
+ "",
543
+ "根据用户回复决定是否继续执行。"
544
+ ].join("\n"),
545
+ mode: "assistant", model, providerType, sessionId, configState, baseUrl, apiKeyEnv, agent, signal, allowQuestion: true, toolContext, output
546
+ })
547
+ accumulateUsage(reviewOut)
548
+ const answer = String(reviewOut.reply || "").toLowerCase().trim()
549
+ if (["no", "否", "n", "取消", "abort", "cancel", "中止", "停止"].some(k => answer.includes(k))) {
550
+ await LongAgentManager.update(sessionId, { status: "aborted", lastMessage: "user rejected blueprint" })
551
+ await markSessionStatus(sessionId, "active")
552
+ return { sessionId, turnId: `turn_long_${Date.now()}`, reply: "用户中止了 Blueprint 审查。", usage: aggregateUsage, toolEvents, iterations: iteration, status: "aborted", phase: "H2", gateStatus, currentGate, lastGateFailures: [], recoveryCount: 0, progress: lastProgress, elapsed: Math.round((Date.now() - startTime) / 1000), stageIndex: 0, stageCount: stagePlan.stages.length, planFrozen, taskProgress: {}, fileChanges: [], stageProgress: { done: 0, total: 0 }, remainingFilesCount: 0 }
553
+ }
554
+ gateStatus.blueprintReview = { status: "pass", userConfirmed: true }
555
+ }
556
+
557
+ // ========== H2.5: GIT BRANCH (可选) ==========
558
+ const gitEnabled = gitConfig.enabled === true || gitConfig.enabled === "ask"
559
+ const gitAsk = gitConfig.enabled === "ask"
560
+ const inGitRepo = gitEnabled && await git.isGitRepo(cwd)
561
+
562
+ if (inGitRepo) {
563
+ await setPhase("H2.5", "git_branch")
564
+ let userWantsGit = !gitAsk
565
+ if (gitAsk && allowQuestion) {
566
+ const askResult = await processTurnLoop({
567
+ prompt: "[SYSTEM] 是否为本次 Hybrid LongAgent 创建独立 Git 分支?回复 yes/是 启用,no/否 跳过。",
568
+ mode: "assistant", model, providerType, sessionId, configState, baseUrl, apiKeyEnv, agent, signal, allowQuestion: true, toolContext
569
+ })
570
+ const answer = String(askResult.reply || "").toLowerCase().trim()
571
+ userWantsGit = ["yes", "是", "y", "ok", "好", "确认"].some(k => answer.includes(k))
572
+ accumulateUsage(askResult)
573
+ }
574
+ if (userWantsGit) {
575
+ gitBaseBranch = await git.currentBranch(cwd)
576
+ // Guard: skip git flow if branch is empty or HEAD detached
577
+ if (!gitBaseBranch || gitBaseBranch === "HEAD") {
578
+ gateStatus.git = { status: "warn", reason: "detached HEAD or no branch" }
579
+ } else {
580
+ const branchName = git.generateBranchName(sessionId, prompt)
581
+ const clean = await git.isClean(cwd)
582
+ let stashed = false
583
+ try {
584
+ if (!clean) {
585
+ const sr = await git.stash("kkcode-auto-stash", cwd)
586
+ stashed = sr.ok
587
+ if (!stashed) {
588
+ // Stash failed skip branch creation
589
+ gateStatus.git = { status: "warn", reason: "git stash failed" }
590
+ }
591
+ }
592
+ if (!stashed && !clean) {
593
+ // stash failed, skip branch creation (already set gateStatus above)
594
+ } else {
595
+ const created = await git.createBranch(branchName, cwd)
596
+ if (created.ok) {
597
+ gitBranch = branchName; gitActive = true
598
+ gateStatus.git = { status: "pass", branch: branchName, baseBranch: gitBaseBranch }
599
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_GIT_BRANCH_CREATED, sessionId, payload: { branch: branchName, baseBranch: gitBaseBranch } })
600
+ } else {
601
+ gateStatus.git = { status: "warn", reason: created.message }
602
+ }
603
+ }
604
+ } finally {
605
+ // Always restore stash on any exit path
606
+ if (stashed) await git.stashPop(cwd).catch(() => {})
607
+ }
608
+ }
609
+ }
610
+ }
611
+
612
+ // ========== H3: SCAFFOLD (脚手架) ==========
613
+ const scaffoldEnabled = longagentConfig.scaffold?.enabled !== false
614
+ if (scaffoldEnabled && stagePlan.stages.length > 0) {
615
+ await setPhase("H3", "scaffolding")
616
+ currentGate = "scaffold"
617
+ await syncState({ lastMessage: "H3: creating stub files" })
618
+
619
+ const scaffoldResult = await runScaffoldPhase({
620
+ objective: `${prompt}\n\n=== BLUEPRINT ARCHITECTURE ===\n${architectureText.slice(0, 4000)}`,
621
+ stagePlan, model, providerType, sessionId, configState,
622
+ baseUrl, apiKeyEnv, agent, signal, toolContext,
623
+ tddMode: hybridConfig.tdd_mode === true
624
+ })
625
+
626
+ gateStatus.scaffold = { status: scaffoldResult.scaffolded ? "pass" : "skip", fileCount: scaffoldResult.fileCount }
627
+ if (scaffoldResult.usage) accumulateUsage(scaffoldResult)
628
+ if (scaffoldResult.files?.length) {
629
+ fileChanges = mergeCappedFileChanges(fileChanges,
630
+ scaffoldResult.files.map(f => ({ path: f, addedLines: 0, removedLines: 0, stageId: "scaffold", taskId: "scaffold" })),
631
+ fileChangesLimit)
632
+ }
633
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_SCAFFOLD_COMPLETE, sessionId, payload: { fileCount: scaffoldResult.fileCount, files: scaffoldResult.files || [] } })
634
+ await syncState({ lastMessage: `H3: scaffolded ${scaffoldResult.fileCount} file(s)` })
635
+ }
636
+
637
+ // ========== H4+H5: CODING(并行) + DEBUGGING(回滚) 循环 ==========
638
+ const gatesConfig = longagentConfig.usability_gates || {}
639
+ let priorContext = [
640
+ "### Preview Findings", previewFindings.slice(0, 2000), "",
641
+ "### Blueprint Architecture", architectureText.slice(0, 3000)
642
+ ].join("\n")
643
+ const seenFilePaths = new Set() // #3 去重:跨阶段文件路径去重
644
+
645
+ let codingRollbackCount = 0
646
+ const maxCodingRollbacks = Number(hybridConfig.max_coding_rollbacks || 2)
647
+ const maxDebugIterations = Number(hybridConfig.debugging_max_iterations || 20)
648
+ let rerunCoding = true
649
+
650
+ while (rerunCoding && codingRollbackCount <= maxCodingRollbacks) {
651
+ rerunCoding = false
652
+
653
+ // --- H4: CODING (并行 stage 执行) ---
654
+ await setPhase("H4", "coding")
655
+ currentGate = "coding"
656
+ stageIndex = 0
657
+ const codingPhaseStart = Date.now()
658
+
659
+ while (stageIndex < stagePlan.stages.length) {
660
+ if (stopFlag || signal?.aborted) break
661
+
662
+ // Phase 2: 阶段超时检测
663
+ if (Date.now() - codingPhaseStart > codingPhaseTimeoutMs) {
664
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_PHASE_TIMEOUT, sessionId, payload: { phase: "H4", elapsed: Date.now() - codingPhaseStart } })
665
+ if (degradationChain.canDegrade()) {
666
+ const degCtx = { model, taskProgress, configState, shouldStop: false }
667
+ const deg = degradationChain.apply(degCtx)
668
+ if (degCtx.model !== model) model = degCtx.model
669
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_DEGRADATION_APPLIED, sessionId, payload: { strategy: deg.strategy, phase: "H4" } })
670
+ if (deg.applied && deg.strategy === "graceful_stop") break
671
+ } else {
672
+ break
673
+ }
674
+ }
675
+
676
+ iteration++
677
+ const stage = stagePlan.stages[stageIndex]
678
+ currentGate = `stage:${stage.stageId}`
679
+ await syncState({ stageStatus: "running", lastMessage: `H4: running ${stage.stageId} (${stageIndex + 1}/${stagePlan.stages.length})` })
680
+
681
+ const seeded = Object.fromEntries(
682
+ stage.tasks.map(t => [t.taskId, taskProgress[t.taskId]]).filter(([, v]) => Boolean(v))
683
+ )
684
+
685
+ // #4 计划锚点 — 每阶段动态构建,不存入 priorContext 避免被压缩掉
686
+ const stageStatuses = stagePlan.stages.map((s, i) => {
687
+ const marker = i < stageIndex ? "✓" : i === stageIndex ? "→" : " "
688
+ return `[${marker}] 阶段${i + 1}: ${s.name || s.stageId}`
689
+ }).join("\n")
690
+ const planAnchor = `## 计划锚点\n目标: ${stagePlan.objective || prompt}\n进度: ${stageIndex + 1}/${stagePlan.stages.length}\n${stageStatuses}\n\n`
691
+
692
+ const stageResult = await runStageBarrier({
693
+ stage, sessionId, config: configState.config, model, providerType,
694
+ seedTaskProgress: seeded, objective: prompt,
695
+ stageIndex, stageCount: stagePlan.stages.length, priorContext: planAnchor + priorContext,
696
+ stuckTracker,
697
+ onTaskComplete: async (taskData) => {
698
+ await saveTaskCheckpoint(sessionId, taskData.stageId, taskData.taskId, taskData)
699
+ },
700
+ taskBus
701
+ })
702
+
703
+ // 合并结果
704
+ for (const [taskId, progress] of Object.entries(stageResult.taskProgress || {})) {
705
+ taskProgress[taskId] = { ...taskProgress[taskId], ...progress }
706
+ if (String(progress.lastReply || "").toLowerCase().includes("[task_complete]")) completionMarkerSeen = true
707
+ // #4 TaskBus: 解析 task 输出中的广播消息
708
+ if (taskBus && progress.lastReply) taskBus.parseTaskOutput(taskId, progress.lastReply)
709
+ // #3 动态重规划: 检测 [REPLAN:...] 标记
710
+ const replan = parseReplanMarker(progress.lastReply)
711
+ if (replan?.stages) {
712
+ const { plan, errors } = validateAndNormalizeStagePlan(replan, { objective: prompt, defaults: planDefaults })
713
+ if (!errors.length) {
714
+ stagePlan = plan
715
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_REPLAN, sessionId, payload: { newStageCount: plan.stages.length } })
716
+ }
717
+ }
718
+ }
719
+ if (stageResult.completionMarkerSeen) completionMarkerSeen = true
720
+ if (stageResult.fileChanges?.length) {
721
+ fileChanges = mergeCappedFileChanges(fileChanges, stageResult.fileChanges, fileChangesLimit)
722
+ }
723
+
724
+ gateStatus[stage.stageId] = {
725
+ status: stageResult.allSuccess ? "pass" : "fail",
726
+ successCount: stageResult.successCount, failCount: stageResult.failCount
727
+ }
728
+
729
+ // #1 阶段级压缩 + #3 文件去重 — 结构化摘要,跨阶段去重文件路径
730
+ const taskSummaries = Object.values(stageResult.taskProgress || {})
731
+ .filter(t => t.lastReply)
732
+ .map(t => ` - [${t.taskId}] ${t.status}: ${t.lastReply.slice(0, 250)}`)
733
+ const stageFiles = (stageResult.fileChanges || [])
734
+ .map(f => (typeof f === "string" ? f : (f.path || f.file || "")))
735
+ .filter(Boolean)
736
+ const newFiles = stageFiles.filter(f => !seenFilePaths.has(f))
737
+ newFiles.forEach(f => seenFilePaths.add(f))
738
+ if (taskSummaries.length || newFiles.length) {
739
+ const fileNote = newFiles.length ? `\n 新增/修改文件: ${newFiles.join(", ")}` : ""
740
+ const failNote = !stageResult.allSuccess ? ` 失败任务数: ${stageResult.failCount}` : ""
741
+ priorContext += `\n### 阶段${stageIndex + 1}: ${stage.name || stage.stageId} (${stageResult.allSuccess ? "PASS" : "FAIL"}${failNote})\n${taskSummaries.join("\n")}${fileNote}\n`
742
+ }
743
+ // #4 TaskBus 增量注入到 priorContext(只包含本阶段新消息)
744
+ if (taskBus) {
745
+ const busCtx = taskBus.toDeltaString()
746
+ if (busCtx) priorContext += `\n${busCtx}\n`
747
+ }
748
+ // #13 上下文压缩
749
+ const pressureLimit = Number(hybridConfig.context_pressure_limit || 8000)
750
+ if (priorContext.length > pressureLimit) {
751
+ priorContext = await compressContext(priorContext, pressureLimit, { model, providerType, sessionId, configState, baseUrl, apiKeyEnv, signal, toolContext })
752
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_CONTEXT_COMPRESSED, sessionId, payload: { newLength: priorContext.length } })
753
+ }
754
+
755
+ lastProgress = {
756
+ percentage: Math.round(((stageIndex + (stageResult.allSuccess ? 1 : 0)) / Math.max(1, stagePlan.stages.length)) * 100),
757
+ currentStep: stageIndex + (stageResult.allSuccess ? 1 : 0),
758
+ totalSteps: stagePlan.stages.length
759
+ }
760
+
761
+ // Git: stage 自动 commit
762
+ if (gitActive && stageResult.allSuccess && gitConfig.auto_commit_stages !== false) {
763
+ const msg = `[kkcode-hybrid] stage ${stage.stageId} completed (${stageIndex + 1}/${stagePlan.stages.length})`
764
+ await git.commitAll(msg, cwd)
765
+ }
766
+
767
+ // #10 增量门控:每个 stage 完成后运行轻量检查
768
+ if (hybridConfig.incremental_gates !== false && stageResult.allSuccess && stageIndex < stagePlan.stages.length - 1) {
769
+ const stageFiles = (stageResult.fileChanges || []).map(f => f.path).filter(Boolean)
770
+ if (stageFiles.length > 0) {
771
+ const miniGate = await runUsabilityGates({
772
+ sessionId, configState, model, providerType, baseUrl, apiKeyEnv, signal, toolContext,
773
+ objective: `Verify stage ${stage.stageId}: ${stage.name || ""}`, fileChanges: stageResult.fileChanges || [],
774
+ gatesConfig: { ...gatesConfig, lint: true, typecheck: true, test: false, security: false, build: false }, allowQuestion: false
775
+ })
776
+ if (miniGate.usage) accumulateUsage(miniGate)
777
+ gateStatus[`gate_${stage.stageId}`] = { status: miniGate.allPassed ? "pass" : "warn" }
778
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_INCREMENTAL_GATE, sessionId, payload: { stageId: stage.stageId, passed: miniGate.allPassed } })
779
+ // #18: Feed gate results into priorContext so subsequent stages see lint/typecheck feedback
780
+ if (!miniGate.allPassed && miniGate.failures?.length) {
781
+ const gateFeedback = miniGate.failures.slice(0, 3).map(f => `${f.gate}: ${(f.reason || "").slice(0, 150)}`).join("; ")
782
+ priorContext += `\n### Incremental Gate Warning (${stage.stageId})\n${gateFeedback}\n`
783
+ }
784
+ }
785
+ }
786
+
787
+ // #14 预算感知:检查 token 消耗是否超限
788
+ // #21: 增加基于历史平均值的预算预测
789
+ if (hybridConfig.budget_awareness !== false) {
790
+ const totalTokens = aggregateUsage.input + aggregateUsage.output
791
+ const budgetLimit = Number(longagentConfig.token_budget || 2000000)
792
+
793
+ // #21: Predict remaining budget based on average per-stage cost
794
+ const completedStages = stageIndex + (stageResult.allSuccess ? 1 : 0)
795
+ const remainingStages = stagePlan.stages.length - completedStages
796
+ if (completedStages > 0 && remainingStages > 0) {
797
+ const avgPerStage = totalTokens / completedStages
798
+ const predicted = totalTokens + avgPerStage * remainingStages
799
+ if (predicted > budgetLimit && totalTokens <= budgetLimit * 0.9) {
800
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_BUDGET_WARNING, sessionId, payload: { totalTokens, budgetLimit, predicted: Math.round(predicted), percentage: Math.round(totalTokens / budgetLimit * 100), forecast: true } })
801
+ await syncState({ lastMessage: `H4: budget forecast — predicted ${Math.round(predicted / 1000)}k tokens (limit ${Math.round(budgetLimit / 1000)}k)` })
802
+ }
803
+ }
804
+
805
+ if (totalTokens > budgetLimit * 0.9) {
806
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_BUDGET_WARNING, sessionId, payload: { totalTokens, budgetLimit, percentage: Math.round(totalTokens / budgetLimit * 100) } })
807
+ await syncState({ lastMessage: `H4: budget warning — ${Math.round(totalTokens / budgetLimit * 100)}% used` })
808
+ }
809
+ if (totalTokens > budgetLimit) {
810
+ // Phase 6: 尝试降级而非直接 break
811
+ if (degradationChain.canDegrade()) {
812
+ const degCtx2 = { model, taskProgress, configState, shouldStop: false }
813
+ const deg = degradationChain.apply(degCtx2)
814
+ if (degCtx2.model !== model) model = degCtx2.model
815
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_DEGRADATION_APPLIED, sessionId, payload: { strategy: deg.strategy, phase: "H4", reason: "budget_exceeded" } })
816
+ if (deg.applied && deg.strategy === "graceful_stop") {
817
+ await syncState({ status: "budget_exceeded", lastMessage: `H4: budget exceeded, graceful stop` })
818
+ break
819
+ }
820
+ } else {
821
+ await syncState({ status: "budget_exceeded", lastMessage: `H4: budget exceeded (${totalTokens}/${budgetLimit})` })
822
+ break
823
+ }
824
+ }
825
+ }
826
+
827
+ if (!stageResult.allSuccess) {
828
+ recoveryCount++
829
+ const backoffMs = Math.min(1000 * 2 ** (recoveryCount - 1), 30000)
830
+ await new Promise(r => setTimeout(r, backoffMs))
831
+ const maxStageRecoveries = Number(longagentConfig.max_stage_recoveries ?? 3)
832
+ if (recoveryCount >= maxStageRecoveries) {
833
+ // Phase 6: 尝试降级而非直接 abort
834
+ if (degradationChain.canDegrade()) {
835
+ const degCtx3 = { model, taskProgress, configState, shouldStop: false }
836
+ const deg = degradationChain.apply(degCtx3)
837
+ if (degCtx3.model !== model) model = degCtx3.model
838
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_DEGRADATION_APPLIED, sessionId, payload: { strategy: deg.strategy, phase: "H4", reason: "max_recoveries" } })
839
+ if (deg.applied && deg.strategy === "graceful_stop") {
840
+ await syncState({ status: "error", lastMessage: `stage ${stage.stageId} aborted after degradation` })
841
+ break
842
+ }
843
+ // 降级成功但非 graceful_stop,重置 recoveryCount 继续
844
+ recoveryCount = 0
845
+ } else {
846
+ await syncState({ status: "error", lastMessage: `stage ${stage.stageId} aborted after ${recoveryCount} recoveries` })
847
+ break
848
+ }
849
+ }
850
+ // Phase 1: 根据错误类别决定是否重试
851
+ for (const [taskId, tp] of Object.entries(taskProgress)) {
852
+ if (tp.status === "error") {
853
+ const category = classifyError(tp.lastError)
854
+ if (category === ERROR_CATEGORIES.PERMANENT || category === ERROR_CATEGORIES.UNKNOWN) {
855
+ taskProgress[taskId] = { ...tp, status: "error", skipReason: `${category} error` }
856
+ } else {
857
+ taskProgress[taskId] = { ...tp, status: "retrying", attempt: 0 }
858
+ }
859
+ }
860
+ }
861
+ continue
862
+ }
863
+
864
+ stageIndex++
865
+ recoveryCount = 0 // reset per-stage recovery counter after successful stage
866
+ await saveCheckpoint(sessionId, { name: `hybrid_stage_${stage.stageId}`, iteration, currentPhase, stageIndex, stagePlan, taskProgress, planFrozen, lastProgress })
867
+ }
868
+
869
+ // #11 Cross-review + H5 ghost commit 并行化
870
+ // Phase 2 改进: ghost commit 不依赖 cross-review 结果,提前启动并行执行
871
+ const ghostCommitPromise = gitActive
872
+ ? git.createGhostCommit(cwd, `[kkcode] pre-debug savepoint session ${sessionId}`).catch(() => null)
873
+ : Promise.resolve(null)
874
+
875
+ if (hybridConfig.cross_review !== false && fileChanges.length > 0) {
876
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_CROSS_REVIEW, sessionId, payload: { fileCount: fileChanges.length } })
877
+ const reviewFiles = fileChanges.slice(0, 20).map(f => f.path).join(", ")
878
+ const reviewOut = await processTurnLoop({
879
+ prompt: [
880
+ "You are the CROSS-REVIEW agent. Multiple parallel sub-agents just completed their coding tasks independently.",
881
+ "Your job: verify that their outputs are compatible, correct, and integrate properly.",
882
+ "",
883
+ "## Files to review:",
884
+ reviewFiles,
885
+ "",
886
+ "## Review Checklist",
887
+ "1. IMPORT RESOLUTION: Do all cross-file imports resolve? Are exported symbols correct?",
888
+ "2. INTERFACE COMPATIBILITY: Do function signatures match what callers expect?",
889
+ "3. ERROR HANDLING: Are errors properly caught, propagated, or thrown? No silent failures?",
890
+ "4. RESOURCE CLEANUP: Are timers cleared, listeners removed, handles closed in all code paths?",
891
+ "5. EDGE CASES: Null/undefined checks, empty arrays, concurrent access guards?",
892
+ "6. CONSISTENCY: Same naming conventions, error patterns, async style across files?",
893
+ "",
894
+ `## Original Objective: ${prompt}`,
895
+ "",
896
+ "## Output Format",
897
+ "For each issue found, output: [FAILED_TASK: taskId] with a description of the problem.",
898
+ "If no issues found, state that the cross-review passed.",
899
+ "Focus on REAL bugs that would cause runtime failures — not style preferences."
900
+ ].join("\n"),
901
+ mode: "agent", agent: getAgent("debugging-agent"),
902
+ model, providerType, sessionId, configState, baseUrl, apiKeyEnv, signal, output, allowQuestion: false, toolContext
903
+ })
904
+ accumulateUsage(reviewOut)
905
+ if (reviewOut.reply) priorContext += `\n### Cross-Review Findings\n${reviewOut.reply.slice(0, 1500)}\n`
906
+ }
907
+
908
+ // --- H5: DEBUGGING (回滚检测) ---
909
+ await setPhase("H5", "debugging")
910
+ currentGate = "debugging"
911
+
912
+ // 等待并行启动的 ghost commit 完成
913
+ const gcResult = await ghostCommitPromise
914
+ const debugSavepoint = gcResult?.ok ? (gcResult.ghostCommit?.commitHash || null) : null
915
+
916
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_DEBUGGING_START, sessionId, payload: { codingRollbackCount, debugSavepoint } })
917
+ await syncState({ lastMessage: "H5: debugging agent verifying implementation" })
918
+
919
+ const debugModel = getModelForStage("debugging")
920
+ const debugPrompt = buildStageWrapper(LONGAGENT_4STAGE_STAGES.DEBUGGING, {
921
+ preview: previewFindings.slice(0, 2000),
922
+ blueprint: architectureText.slice(0, 3000),
923
+ coding: priorContext.slice(0, 4000)
924
+ }, prompt)
925
+
926
+ let debugIter = 0
927
+ let debugDone = false
928
+ let debugRecoveryHint = "" // Phase 2 改进: stuck 恢复提示注入
929
+ const semanticTracker = createSemanticErrorTracker(3)
930
+ const debugPhaseStart = Date.now()
931
+
932
+ while (!debugDone && debugIter < maxDebugIterations) {
933
+ debugIter++
934
+ iteration++
935
+ if (stopFlag || signal?.aborted) break
936
+
937
+ // Phase 2: debugging 阶段超时检测
938
+ if (Date.now() - debugPhaseStart > debuggingPhaseTimeoutMs) {
939
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_PHASE_TIMEOUT, sessionId, payload: { phase: "H5", elapsed: Date.now() - debugPhaseStart } })
940
+ if (degradationChain.canDegrade()) {
941
+ const degCtx4 = { model, taskProgress, configState, shouldStop: false }
942
+ const deg = degradationChain.apply(degCtx4)
943
+ if (degCtx4.model !== model) model = degCtx4.model
944
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_DEGRADATION_APPLIED, sessionId, payload: { strategy: deg.strategy, phase: "H5" } })
945
+ if (deg.applied && deg.strategy === "graceful_stop") break
946
+ } else {
947
+ break
948
+ }
949
+ }
950
+
951
+ const effectiveDebugPrompt = debugRecoveryHint ? `${debugRecoveryHint}\n\n${debugPrompt}` : debugPrompt
952
+ const debugOut = await processTurnLoop({
953
+ prompt: effectiveDebugPrompt, mode: "agent", agent: getAgent("debugging-agent"),
954
+ model: debugModel.model, providerType: debugModel.providerType,
955
+ sessionId, configState, baseUrl, apiKeyEnv, signal, output, allowQuestion, toolContext
956
+ })
957
+ accumulateUsage(debugOut)
958
+ finalReply = debugOut.reply || ""
959
+ debugRecoveryHint = "" // 每次迭代后清空恢复提示
960
+
961
+ // 防卡死检测
962
+ if (debugOut.toolEvents?.length) {
963
+ const stuckResult = stuckTracker.track(debugOut.toolEvents)
964
+ if (stuckResult.isStuck) {
965
+ stuckTracker.resetReadOnlyCount()
966
+ await EventBus.emit({
967
+ type: EVENT_TYPES.LONGAGENT_ALERT, sessionId,
968
+ payload: { kind: "stuck_warning", stage: "H5:debugging", reason: stuckResult.reason, debugIter }
969
+ })
970
+ await syncState({ lastMessage: `H5: stuck detected (${stuckResult.reason}), iter ${debugIter}` })
971
+ // Phase 2 改进: 注入恢复提示,引导 agent 换策略
972
+ debugRecoveryHint = [
973
+ "## Recovery Hint — Stuck Pattern Detected",
974
+ `Previous iteration was stuck: ${stuckResult.reason}.`,
975
+ "You MUST change your approach. Try one of these strategies:",
976
+ "1. If reading the same files repeatedly — stop reading and start making changes",
977
+ "2. If the same test keeps failing — re-read the error, check a different root cause",
978
+ "3. If edits are not taking effect — verify the file path and check for syntax errors",
979
+ "4. Consider reverting recent changes and trying a fundamentally different fix"
980
+ ].join("\n")
981
+ }
982
+ }
983
+
984
+ // Phase 5: 语义级错误检测
985
+ const semResult = semanticTracker.track(finalReply)
986
+ if (semResult.isRepeated) {
987
+ await EventBus.emit({
988
+ type: EVENT_TYPES.LONGAGENT_SEMANTIC_ERROR_REPEATED, sessionId,
989
+ payload: { error: semResult.error, count: semResult.count, debugIter }
990
+ })
991
+ await syncState({ lastMessage: `H5: repeated error detected (${semResult.count}x): ${(semResult.error || "").slice(0, 80)}` })
992
+ // Phase 2 改进: 语义重复错误超阈值强制退出,防止无限循环
993
+ const maxSemanticRepeats = 5
994
+ if (semResult.count >= maxSemanticRepeats) {
995
+ debugDone = true
996
+ gateStatus.debugging = { status: "force_exit", reason: "semantic_repeat_limit", error: (semResult.error || "").slice(0, 200), iterations: debugIter }
997
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_ALERT, sessionId, payload: { kind: "semantic_force_exit", count: semResult.count, error: semResult.error, debugIter } })
998
+ await syncState({ lastMessage: `H5: force exit — same error repeated ${semResult.count} times` })
999
+ }
1000
+ }
1001
+
1002
+ if (detectStageComplete(finalReply, LONGAGENT_4STAGE_STAGES.DEBUGGING)) {
1003
+ debugDone = true
1004
+ gateStatus.debugging = { status: "pass", iterations: debugIter }
1005
+ }
1006
+
1007
+ if (detectReturnToCoding(finalReply)) {
1008
+ codingRollbackCount++
1009
+ rerunCoding = true
1010
+ // #1 细粒度回滚:优先只重置被标记的失败 task
1011
+ const failedIds = extractFailedTaskIds(finalReply)
1012
+ if (failedIds.length > 0) {
1013
+ for (const fid of failedIds) {
1014
+ if (taskProgress[fid]) taskProgress[fid] = { ...taskProgress[fid], status: "retrying", attempt: 0 }
1015
+ }
1016
+ } else {
1017
+ // 回退:重置所有 error 状态的 task
1018
+ for (const [taskId, tp] of Object.entries(taskProgress)) {
1019
+ if (tp.status === "error") taskProgress[taskId] = { ...tp, status: "retrying", attempt: 0 }
1020
+ }
1021
+ }
1022
+ gateStatus.debugging = { status: "rollback", iterations: debugIter, rollbackCount: codingRollbackCount, failedTaskIds: failedIds }
1023
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_RETURN_TO_CODING, sessionId, payload: { rollbackCount: codingRollbackCount, failedTaskIds: failedIds } })
1024
+ break
1025
+ }
1026
+
1027
+ if (/\[TASK_COMPLETE\]/i.test(finalReply)) { completionMarkerSeen = true; debugDone = true }
1028
+ await syncState({ lastMessage: `H5: debugging iteration ${debugIter}/${maxDebugIterations}` })
1029
+ }
1030
+
1031
+ if (!debugDone && !rerunCoding) {
1032
+ gateStatus.debugging = { status: "timeout", iterations: debugIter }
1033
+ }
1034
+
1035
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_DEBUGGING_COMPLETE, sessionId, payload: { debugIter, rollback: rerunCoding } })
1036
+ await syncState({ lastMessage: rerunCoding ? `H5: rollback to coding (attempt ${codingRollbackCount})` : `H5: debugging complete` })
1037
+ } // end while(rerunCoding)
1038
+
1039
+ // ========== H5.5: COMPLETION VALIDATION ==========
1040
+ if (hybridConfig.completion_validation !== false) {
1041
+ await setPhase("H5.5", "completion_validation")
1042
+ await syncState({ lastMessage: "H5.5: validating completion" })
1043
+
1044
+ const cwd = process.cwd()
1045
+ try {
1046
+ const validator = await createValidator({ cwd, configState })
1047
+ const report = await validator.validate({ todoState: toolContext?._todoState, level: "standard" })
1048
+ gateStatus.completionValidation = {
1049
+ status: report.verdict === "BLOCK" ? "fail" : "pass",
1050
+ verdict: report.verdict,
1051
+ failedChecks: report.results?.filter(r => !r.passed).length || 0
1052
+ }
1053
+
1054
+ if (report.verdict === "BLOCK" && !completionMarkerSeen) {
1055
+ const fixPrompt = [
1056
+ "## Completion Validation Failed — Fix Required",
1057
+ "",
1058
+ `Original objective: ${prompt}`,
1059
+ "",
1060
+ "## Validation Issues Found:",
1061
+ report.message,
1062
+ "",
1063
+ "## Fix Instructions",
1064
+ "1. Read each failing check and identify the root cause",
1065
+ "2. Fix the issue in the source code (not by suppressing the check)",
1066
+ "3. Re-run the relevant verification command to confirm the fix",
1067
+ "4. If a fix requires changes to multiple files, ensure cross-file consistency",
1068
+ "",
1069
+ "When ALL issues are resolved and verified, include [TASK_COMPLETE] in your response."
1070
+ ].join("\n")
1071
+ const fixOut = await processTurnLoop({
1072
+ prompt: fixPrompt, mode: "agent", agent: getAgent("coding-agent"),
1073
+ model, providerType, sessionId, configState,
1074
+ baseUrl, apiKeyEnv, signal, output, allowQuestion, toolContext
1075
+ })
1076
+ accumulateUsage(fixOut)
1077
+ iteration++
1078
+ if (/\[TASK_COMPLETE\]/i.test(fixOut.reply || "")) completionMarkerSeen = true
1079
+ finalReply = fixOut.reply || finalReply
1080
+ }
1081
+ } catch (valErr) {
1082
+ gateStatus.completionValidation = { status: "warn", reason: `skipped: ${valErr.message}` }
1083
+ }
1084
+ }
1085
+
1086
+ // ========== H6: USABILITY GATES ==========
1087
+ await setPhase("H6", "gates")
1088
+ currentGate = "gates"
1089
+ await syncState({ lastMessage: "H6: running usability gates" })
1090
+
1091
+ // Gate 偏好提示(首次运行时询问用户)
1092
+ const shouldPromptGates = gatesConfig.prompt_user === "first_run" || gatesConfig.prompt_user === "always"
1093
+ if (shouldPromptGates && allowQuestion) {
1094
+ const hasPrefs = await hasGatePreferences()
1095
+ if (!hasPrefs || gatesConfig.prompt_user === "always") {
1096
+ const gateAssistantResult = await processTurnLoop({
1097
+ prompt: buildGatePromptText(),
1098
+ mode: "assistant", model, providerType, sessionId, configState,
1099
+ baseUrl, apiKeyEnv, agent, signal, allowQuestion: true, toolContext
1100
+ })
1101
+ accumulateUsage(gateAssistantResult)
1102
+ const gatePrefs = parseGateSelection(gateAssistantResult.reply)
1103
+ await saveGatePreferences(gatePrefs)
1104
+ for (const [gate, enabled] of Object.entries(gatePrefs)) {
1105
+ if (configState.config.agent.longagent.usability_gates[gate]) {
1106
+ configState.config.agent.longagent.usability_gates[gate].enabled = enabled
1107
+ }
1108
+ }
1109
+ } else {
1110
+ const savedPrefs = await getGatePreferences()
1111
+ if (savedPrefs) {
1112
+ for (const [gate, enabled] of Object.entries(savedPrefs)) {
1113
+ if (configState.config.agent.longagent.usability_gates[gate]) {
1114
+ configState.config.agent.longagent.usability_gates[gate].enabled = enabled
1115
+ }
1116
+ }
1117
+ }
1118
+ }
1119
+ }
1120
+
1121
+ let gateAttempt = 0
1122
+
1123
+ while (gateAttempt < maxGateAttempts) {
1124
+ gateAttempt++
1125
+ if (stopFlag || signal?.aborted) break
1126
+
1127
+ const gateResult = await runUsabilityGates({
1128
+ sessionId, configState, model, providerType,
1129
+ baseUrl, apiKeyEnv, signal, toolContext,
1130
+ objective: prompt, fileChanges,
1131
+ gatesConfig, allowQuestion
1132
+ })
1133
+ if (gateResult.usage) accumulateUsage(gateResult)
1134
+
1135
+ if (gateResult.allPassed) {
1136
+ gateStatus.usabilityGates = { status: "pass", attempt: gateAttempt }
1137
+ break
1138
+ }
1139
+
1140
+ lastGateFailures = gateResult.failures || []
1141
+ gateStatus.usabilityGates = { status: "fixing", attempt: gateAttempt, failures: summarizeGateFailures(lastGateFailures) }
1142
+ await syncState({ lastMessage: `H6: gate failures (attempt ${gateAttempt}/${maxGateAttempts}), fixing...` })
1143
+
1144
+ // 修复循环:根据 gate 类型选择修复策略 (Phase 8)
1145
+ const strategy = getGateFixStrategy(lastGateFailures)
1146
+
1147
+ // lint 失败时先尝试自动修复
1148
+ if (strategy.autoFix) {
1149
+ try {
1150
+ const { execSync } = await import("node:child_process")
1151
+ execSync(strategy.autoFix, { cwd: process.cwd(), timeout: 30000, stdio: "ignore" })
1152
+ } catch { /* autofix failed, fall through to agent */ }
1153
+ }
1154
+
1155
+ const gateFailureSummary = summarizeGateFailures(lastGateFailures)
1156
+ const fixPrompt = [
1157
+ `## Quality Gate Failures — Attempt ${gateAttempt}/${maxGateAttempts}`,
1158
+ "",
1159
+ `${strategy.prefix || "Fix the following quality gate failures:"}`,
1160
+ "",
1161
+ gateFailureSummary,
1162
+ "",
1163
+ "## Fix Protocol",
1164
+ "1. Read the error output carefully — identify the ROOT CAUSE, not just the symptom",
1165
+ "2. Fix the source code (do NOT disable or skip the gate check)",
1166
+ "3. Re-run the failing command to verify the fix works",
1167
+ "4. If the fix touches shared code, verify no regressions in other modules",
1168
+ "",
1169
+ `Original objective: ${prompt}`
1170
+ ].join("\n")
1171
+ const fixOut = await processTurnLoop({
1172
+ prompt: fixPrompt, mode: "agent", agent: getAgent(strategy.agent || "coding-agent"),
1173
+ model, providerType, sessionId, configState,
1174
+ baseUrl, apiKeyEnv, signal, output, allowQuestion, toolContext
1175
+ })
1176
+ accumulateUsage(fixOut)
1177
+ iteration++
1178
+ }
1179
+
1180
+ if (gateAttempt >= maxGateAttempts && lastGateFailures.length) {
1181
+ gateStatus.usabilityGates = { status: "fail", attempt: gateAttempt, failures: summarizeGateFailures(lastGateFailures) }
1182
+ }
1183
+
1184
+ // ========== H7: GIT MERGE (原子性保护) ==========
1185
+ if (gitActive && gitBaseBranch && gitBranch) {
1186
+ await setPhase("H7", "git_merge")
1187
+ try {
1188
+ if (gitConfig.auto_merge !== false) {
1189
+ await LongAgentManager.withLock(async () => {
1190
+ const doneState = await LongAgentManager.get(sessionId)
1191
+ if (doneState?.status === "failed") return
1192
+
1193
+ // Step 1: 提交 feature branch 上的最终变更(锁内执行,防止并发)
1194
+ const finalCommit = await git.commitAll(`[kkcode-hybrid] session ${sessionId} completed`, cwd)
1195
+ if (!finalCommit.ok && !finalCommit.empty) {
1196
+ gateStatus.gitMerge = { status: "warn", reason: `final commit failed: ${finalCommit.message}` }
1197
+ return
1198
+ }
1199
+
1200
+ // Step 2: 保存 savepoint — 记录 feature branch HEAD 用于回滚
1201
+ const featureHead = await git.getHeadHash(cwd)
1202
+
1203
+ // Step 3: checkout base branch
1204
+ const checkoutResult = await git.checkoutBranch(gitBaseBranch, cwd)
1205
+ if (!checkoutResult.ok) {
1206
+ await git.checkoutBranch(gitBranch, cwd).catch(() => {})
1207
+ gateStatus.gitMerge = { status: "warn", reason: `checkout base failed: ${checkoutResult.message}` }
1208
+ return
1209
+ }
1210
+
1211
+ // Step 4: 保存 base branch HEAD(merge 失败时回滚到此)
1212
+ const baseHead = await git.getHeadHash(cwd)
1213
+
1214
+ // Step 5: 执行 merge
1215
+ const mergeResult = await git.mergeBranch(gitBranch, cwd)
1216
+ if (mergeResult.ok) {
1217
+ // Step 6: post-merge 验证 — 确认 HEAD 包含 feature 分支的变更
1218
+ const mergedHead = await git.getHeadHash(cwd)
1219
+ if (!mergedHead || mergedHead === baseHead) {
1220
+ // merge 声称成功但 HEAD 未变化,回滚
1221
+ if (baseHead) await git.resetTo(baseHead, cwd).catch(() => {})
1222
+ await git.checkoutBranch(gitBranch, cwd).catch(() => {})
1223
+ gateStatus.gitMerge = { status: "warn", reason: "merge reported success but HEAD unchanged" }
1224
+ return
1225
+ }
1226
+ await git.deleteBranch(gitBranch, cwd)
1227
+ gateStatus.gitMerge = { status: "pass", branch: gitBranch, baseBranch: gitBaseBranch }
1228
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_GIT_MERGED, sessionId, payload: { branch: gitBranch, baseBranch: gitBaseBranch } })
1229
+ return
1230
+ }
1231
+
1232
+ // Step 7: merge 失败 — 检查是否为冲突
1233
+ const conflictFiles = await git.getConflictFiles(cwd)
1234
+ if (conflictFiles.length === 0) {
1235
+ // 非冲突类 merge 失败 — 回滚到 base HEAD,回到 feature branch
1236
+ if (baseHead) await git.resetTo(baseHead, cwd).catch(() => {})
1237
+ await git.checkoutBranch(gitBranch, cwd).catch(() => {})
1238
+ gateStatus.gitMerge = { status: "warn", reason: `merge failed: ${mergeResult.message}` }
1239
+ return
1240
+ }
1241
+
1242
+ // Step 8: 冲突自愈 — 原子化处理
1243
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_GIT_CONFLICT_RESOLUTION, sessionId, payload: { files: conflictFiles } })
1244
+ try {
1245
+ const conflictPrompt = buildConflictResolutionPrompt(conflictFiles)
1246
+ const conflictOut = await processTurnLoop({
1247
+ prompt: conflictPrompt, mode: "agent", agent: getAgent("coding-agent"),
1248
+ model, providerType, sessionId, configState,
1249
+ baseUrl, apiKeyEnv, signal, output, allowQuestion: false, toolContext
1250
+ })
1251
+ accumulateUsage(conflictOut)
1252
+
1253
+ // Step 9: 验证冲突标记已全部清除
1254
+ const markersRemain = await git.hasConflictMarkers(cwd)
1255
+ if (markersRemain) {
1256
+ throw new Error("conflict markers still present after resolution")
1257
+ }
1258
+
1259
+ const commitResult = await git.commitAll(`[kkcode-hybrid] resolved merge conflicts`, cwd)
1260
+ if (commitResult.ok) {
1261
+ await git.deleteBranch(gitBranch, cwd)
1262
+ gateStatus.gitMerge = { status: "pass", branch: gitBranch, baseBranch: gitBaseBranch, conflictsResolved: true }
1263
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_GIT_MERGED, sessionId, payload: { branch: gitBranch, baseBranch: gitBaseBranch } })
1264
+ return
1265
+ }
1266
+ throw new Error(`commit after conflict resolution failed: ${commitResult.message}`)
1267
+ } catch (resolveErr) {
1268
+ // Step 10: 冲突解决失败 — 精确回滚
1269
+ await git.mergeAbort(cwd).catch(() => {})
1270
+ if (baseHead) await git.resetTo(baseHead, cwd).catch(() => {})
1271
+ await git.checkoutBranch(gitBranch, cwd).catch(() => {})
1272
+ if (featureHead) await git.resetTo(featureHead, cwd).catch(() => {})
1273
+ gateStatus.gitMerge = { status: "warn", reason: `conflict resolution failed: ${resolveErr.message}` }
1274
+ }
1275
+ }, cwd)
1276
+ }
1277
+ } catch (h7Err) {
1278
+ // 记录错误而非静默吞掉
1279
+ gateStatus.gitMerge = gateStatus.gitMerge || { status: "warn", reason: `H7 error: ${h7Err.message}` }
1280
+ }
1281
+ }
1282
+
1283
+ // #5 保存 project memory
1284
+ if (hybridConfig.project_memory !== false && previewFindings) {
1285
+ try {
1286
+ const newMemory = parseMemoryFromPreview(previewFindings)
1287
+ if (newMemory.techStack.length) {
1288
+ const merged = { ...projectMemory, techStack: [...new Set([...(projectMemory?.techStack || []), ...newMemory.techStack])].slice(0, 20), patterns: [...new Set([...(projectMemory?.patterns || []), ...newMemory.patterns])].slice(0, 20), conventions: projectMemory?.conventions || [] }
1289
+ await saveProjectMemory(cwd, merged)
1290
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_MEMORY_SAVED, sessionId, payload: { techStackCount: merged.techStack.length } })
1291
+ }
1292
+ } catch { /* ignore memory save errors */ }
1293
+ }
1294
+
1295
+ // Phase 10: Checkpoint 清理
1296
+ if (hybridConfig.checkpoint_cleanup !== false) {
1297
+ try {
1298
+ const cleanResult = await cleanupCheckpoints(sessionId, {
1299
+ maxKeep: Number(hybridConfig.checkpoint_max_keep || 10),
1300
+ keepStageCheckpoints: true
1301
+ })
1302
+ if (cleanResult.removed > 0) {
1303
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_CHECKPOINT_CLEANED, sessionId, payload: { removed: cleanResult.removed } })
1304
+ }
1305
+ } catch { /* ignore cleanup errors */ }
1306
+ }
1307
+
1308
+ // ========== 完成 ==========
1309
+ unsubscribeStop()
1310
+ const elapsed = Math.round((Date.now() - startTime) / 1000)
1311
+ const finalStatus = completionMarkerSeen ? "completed" : "done"
1312
+ await LongAgentManager.update(sessionId, { status: finalStatus, lastMessage: "hybrid longagent complete", elapsed })
1313
+ await markSessionStatus(sessionId, finalStatus === "completed" ? "completed" : "active")
1314
+
1315
+ const stats = stageProgressStats(taskProgress)
1316
+
1317
+ // Phase 11: 恢复建议生成
1318
+ let recoverySuggestions = null
1319
+ if (finalStatus !== "completed") {
1320
+ recoverySuggestions = generateRecoverySuggestions({
1321
+ status: finalStatus,
1322
+ taskProgress,
1323
+ gateStatus,
1324
+ phase: currentPhase,
1325
+ recoveryCount,
1326
+ fileChanges
1327
+ })
1328
+ }
1329
+
1330
+ return {
1331
+ sessionId, turnId: `turn_long_${Date.now()}`,
1332
+ reply: finalReply || "hybrid longagent complete",
1333
+ usage: aggregateUsage, toolEvents, iterations: iteration,
1334
+ status: finalStatus, phase: currentPhase,
1335
+ gateStatus, currentGate, lastGateFailures, recoveryCount,
1336
+ progress: lastProgress, elapsed,
1337
+ stageIndex, stageCount: stagePlan?.stages?.length || 0,
1338
+ planFrozen, taskProgress, fileChanges,
1339
+ stageProgress: { done: stats.done, total: stats.total },
1340
+ remainingFilesCount: stats.remainingFilesCount,
1341
+ gitBranch, gitBaseBranch,
1342
+ recoverySuggestions
1343
+ }
1344
+ }