@kkelly-offical/kkcode 0.1.3 → 0.1.6

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 (58) hide show
  1. package/README.md +110 -172
  2. package/package.json +46 -46
  3. package/src/agent/agent.mjs +41 -0
  4. package/src/agent/prompt/frontend-designer.txt +58 -0
  5. package/src/agent/prompt/longagent-blueprint-agent.txt +83 -0
  6. package/src/agent/prompt/longagent-coding-agent.txt +37 -0
  7. package/src/agent/prompt/longagent-debugging-agent.txt +46 -0
  8. package/src/agent/prompt/longagent-preview-agent.txt +63 -0
  9. package/src/config/defaults.mjs +260 -195
  10. package/src/config/schema.mjs +71 -6
  11. package/src/core/constants.mjs +91 -46
  12. package/src/index.mjs +1 -1
  13. package/src/knowledge/frontend-aesthetics.txt +39 -0
  14. package/src/knowledge/loader.mjs +2 -1
  15. package/src/knowledge/tailwind.txt +12 -3
  16. package/src/mcp/client-http.mjs +141 -157
  17. package/src/mcp/client-sse.mjs +288 -286
  18. package/src/mcp/client-stdio.mjs +533 -451
  19. package/src/mcp/constants.mjs +2 -0
  20. package/src/mcp/registry.mjs +479 -394
  21. package/src/mcp/stdio-framing.mjs +133 -127
  22. package/src/mcp/tool-result.mjs +24 -0
  23. package/src/observability/index.mjs +42 -0
  24. package/src/observability/metrics.mjs +137 -0
  25. package/src/observability/tracer.mjs +137 -0
  26. package/src/orchestration/background-manager.mjs +372 -358
  27. package/src/orchestration/background-worker.mjs +305 -245
  28. package/src/orchestration/longagent-manager.mjs +171 -116
  29. package/src/orchestration/stage-scheduler.mjs +728 -489
  30. package/src/permission/exec-policy.mjs +9 -11
  31. package/src/provider/anthropic.mjs +1 -0
  32. package/src/provider/openai.mjs +340 -339
  33. package/src/provider/retry-policy.mjs +68 -68
  34. package/src/provider/router.mjs +241 -228
  35. package/src/provider/sse.mjs +104 -91
  36. package/src/repl.mjs +1 -1
  37. package/src/session/checkpoint.mjs +66 -3
  38. package/src/session/engine.mjs +227 -225
  39. package/src/session/longagent-4stage.mjs +460 -0
  40. package/src/session/longagent-hybrid.mjs +1081 -0
  41. package/src/session/longagent-plan.mjs +365 -329
  42. package/src/session/longagent-project-memory.mjs +53 -0
  43. package/src/session/longagent-scaffold.mjs +291 -100
  44. package/src/session/longagent-task-bus.mjs +54 -0
  45. package/src/session/longagent-utils.mjs +472 -0
  46. package/src/session/longagent.mjs +884 -1462
  47. package/src/session/project-context.mjs +30 -0
  48. package/src/session/store.mjs +510 -503
  49. package/src/session/task-validator.mjs +4 -3
  50. package/src/skill/builtin/design.mjs +76 -0
  51. package/src/skill/builtin/frontend.mjs +8 -0
  52. package/src/skill/registry.mjs +390 -336
  53. package/src/storage/ghost-commit-store.mjs +18 -8
  54. package/src/tool/executor.mjs +11 -0
  55. package/src/tool/git-auto.mjs +0 -19
  56. package/src/tool/registry.mjs +71 -37
  57. package/src/ui/activity-renderer.mjs +664 -410
  58. package/src/util/git.mjs +23 -0
@@ -0,0 +1,1081 @@
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
+
461
+ let codingRollbackCount = 0
462
+ const maxCodingRollbacks = Number(hybridConfig.max_coding_rollbacks || 2)
463
+ const maxDebugIterations = Number(hybridConfig.debugging_max_iterations || 20)
464
+ let rerunCoding = true
465
+
466
+ while (rerunCoding && codingRollbackCount <= maxCodingRollbacks) {
467
+ rerunCoding = false
468
+
469
+ // --- H4: CODING (并行 stage 执行) ---
470
+ await setPhase("H4", "coding")
471
+ currentGate = "coding"
472
+ stageIndex = 0
473
+ const codingPhaseStart = Date.now()
474
+
475
+ while (stageIndex < stagePlan.stages.length) {
476
+ const state = await LongAgentManager.get(sessionId)
477
+ if (state?.stopRequested || signal?.aborted) break
478
+
479
+ // Phase 2: 阶段超时检测
480
+ if (Date.now() - codingPhaseStart > codingPhaseTimeoutMs) {
481
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_PHASE_TIMEOUT, sessionId, payload: { phase: "H4", elapsed: Date.now() - codingPhaseStart } })
482
+ if (degradationChain.canDegrade()) {
483
+ const degCtx = { model, taskProgress, configState, shouldStop: false }
484
+ const deg = degradationChain.apply(degCtx)
485
+ if (degCtx.model !== model) model = degCtx.model
486
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_DEGRADATION_APPLIED, sessionId, payload: { strategy: deg.strategy, phase: "H4" } })
487
+ if (deg.applied && deg.strategy === "graceful_stop") break
488
+ } else {
489
+ break
490
+ }
491
+ }
492
+
493
+ iteration++
494
+ const stage = stagePlan.stages[stageIndex]
495
+ currentGate = `stage:${stage.stageId}`
496
+ await syncState({ stageStatus: "running", lastMessage: `H4: running ${stage.stageId} (${stageIndex + 1}/${stagePlan.stages.length})` })
497
+
498
+ const seeded = Object.fromEntries(
499
+ stage.tasks.map(t => [t.taskId, taskProgress[t.taskId]]).filter(([, v]) => Boolean(v))
500
+ )
501
+
502
+ const stageResult = await runStageBarrier({
503
+ stage, sessionId, config: configState.config, model, providerType,
504
+ seedTaskProgress: seeded, objective: prompt,
505
+ stageIndex, stageCount: stagePlan.stages.length, priorContext,
506
+ stuckTracker,
507
+ onTaskComplete: async (taskData) => {
508
+ await saveTaskCheckpoint(sessionId, taskData.stageId, taskData.taskId, taskData)
509
+ },
510
+ taskBus
511
+ })
512
+
513
+ // 合并结果
514
+ for (const [taskId, progress] of Object.entries(stageResult.taskProgress || {})) {
515
+ taskProgress[taskId] = { ...taskProgress[taskId], ...progress }
516
+ if (String(progress.lastReply || "").toLowerCase().includes("[task_complete]")) completionMarkerSeen = true
517
+ // #4 TaskBus: 解析 task 输出中的广播消息
518
+ if (taskBus && progress.lastReply) taskBus.parseTaskOutput(taskId, progress.lastReply)
519
+ // #3 动态重规划: 检测 [REPLAN:...] 标记
520
+ const replan = parseReplanMarker(progress.lastReply)
521
+ if (replan?.stages) {
522
+ const { plan, errors } = validateAndNormalizeStagePlan(replan, { objective: prompt, defaults: planDefaults })
523
+ if (!errors.length) {
524
+ stagePlan = plan
525
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_REPLAN, sessionId, payload: { newStageCount: plan.stages.length } })
526
+ }
527
+ }
528
+ }
529
+ if (stageResult.completionMarkerSeen) completionMarkerSeen = true
530
+ if (stageResult.fileChanges?.length) {
531
+ fileChanges = mergeCappedFileChanges(fileChanges, stageResult.fileChanges, fileChangesLimit)
532
+ }
533
+
534
+ gateStatus[stage.stageId] = {
535
+ status: stageResult.allSuccess ? "pass" : "fail",
536
+ successCount: stageResult.successCount, failCount: stageResult.failCount
537
+ }
538
+
539
+ // 知识传递
540
+ const taskSummaries = Object.values(stageResult.taskProgress || {})
541
+ .filter(t => t.lastReply)
542
+ .map(t => `[${t.taskId}] ${t.status}: ${t.lastReply.slice(0, 300)}`)
543
+ if (taskSummaries.length) {
544
+ priorContext += `\n### Stage ${stageIndex + 1}: ${stage.name || stage.stageId} (${stageResult.allSuccess ? "PASS" : "FAIL"})\n${taskSummaries.join("\n")}\n`
545
+ }
546
+ // #4 TaskBus 注入到 priorContext
547
+ if (taskBus) {
548
+ const busCtx = taskBus.toContextString()
549
+ if (busCtx) priorContext += `\n${busCtx}\n`
550
+ }
551
+ // #13 上下文压缩
552
+ const pressureLimit = Number(hybridConfig.context_pressure_limit || 8000)
553
+ if (priorContext.length > pressureLimit) {
554
+ priorContext = await compressContext(priorContext, pressureLimit, { model, providerType, sessionId, configState, baseUrl, apiKeyEnv, signal, toolContext })
555
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_CONTEXT_COMPRESSED, sessionId, payload: { newLength: priorContext.length } })
556
+ }
557
+
558
+ lastProgress = {
559
+ percentage: Math.round(((stageIndex + (stageResult.allSuccess ? 1 : 0)) / Math.max(1, stagePlan.stages.length)) * 100),
560
+ currentStep: stageIndex + (stageResult.allSuccess ? 1 : 0),
561
+ totalSteps: stagePlan.stages.length
562
+ }
563
+
564
+ // Git: 每 stage 自动 commit
565
+ if (gitActive && stageResult.allSuccess && gitConfig.auto_commit_stages !== false) {
566
+ const msg = `[kkcode-hybrid] stage ${stage.stageId} completed (${stageIndex + 1}/${stagePlan.stages.length})`
567
+ await git.commitAll(msg, cwd)
568
+ }
569
+
570
+ // #10 增量门控:每个 stage 完成后运行轻量检查
571
+ if (hybridConfig.incremental_gates !== false && stageResult.allSuccess && stageIndex < stagePlan.stages.length - 1) {
572
+ const stageFiles = (stageResult.fileChanges || []).map(f => f.path).filter(Boolean)
573
+ if (stageFiles.length > 0) {
574
+ const miniGate = await runUsabilityGates({
575
+ sessionId, configState, model, providerType, baseUrl, apiKeyEnv, signal, toolContext,
576
+ objective: `Verify stage ${stage.stageId}: ${stage.name || ""}`, fileChanges: stageResult.fileChanges || [],
577
+ gatesConfig: { ...gatesConfig, lint: true, typecheck: true, test: false, security: false, build: false }, allowQuestion: false
578
+ })
579
+ if (miniGate.usage) accumulateUsage(miniGate)
580
+ gateStatus[`gate_${stage.stageId}`] = { status: miniGate.allPassed ? "pass" : "warn" }
581
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_INCREMENTAL_GATE, sessionId, payload: { stageId: stage.stageId, passed: miniGate.allPassed } })
582
+ // #18: Feed gate results into priorContext so subsequent stages see lint/typecheck feedback
583
+ if (!miniGate.allPassed && miniGate.failures?.length) {
584
+ const gateFeedback = miniGate.failures.slice(0, 3).map(f => `${f.gate}: ${(f.reason || "").slice(0, 150)}`).join("; ")
585
+ priorContext += `\n### Incremental Gate Warning (${stage.stageId})\n${gateFeedback}\n`
586
+ }
587
+ }
588
+ }
589
+
590
+ // #14 预算感知:检查 token 消耗是否超限
591
+ // #21: 增加基于历史平均值的预算预测
592
+ if (hybridConfig.budget_awareness !== false) {
593
+ const totalTokens = aggregateUsage.input + aggregateUsage.output
594
+ const budgetLimit = Number(longagentConfig.token_budget || 2000000)
595
+
596
+ // #21: Predict remaining budget based on average per-stage cost
597
+ const completedStages = stageIndex + (stageResult.allSuccess ? 1 : 0)
598
+ const remainingStages = stagePlan.stages.length - completedStages
599
+ if (completedStages > 0 && remainingStages > 0) {
600
+ const avgPerStage = totalTokens / completedStages
601
+ const predicted = totalTokens + avgPerStage * remainingStages
602
+ if (predicted > budgetLimit && totalTokens <= budgetLimit * 0.9) {
603
+ 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 } })
604
+ await syncState({ lastMessage: `H4: budget forecast — predicted ${Math.round(predicted / 1000)}k tokens (limit ${Math.round(budgetLimit / 1000)}k)` })
605
+ }
606
+ }
607
+
608
+ if (totalTokens > budgetLimit * 0.9) {
609
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_BUDGET_WARNING, sessionId, payload: { totalTokens, budgetLimit, percentage: Math.round(totalTokens / budgetLimit * 100) } })
610
+ await syncState({ lastMessage: `H4: budget warning — ${Math.round(totalTokens / budgetLimit * 100)}% used` })
611
+ }
612
+ if (totalTokens > budgetLimit) {
613
+ // Phase 6: 尝试降级而非直接 break
614
+ if (degradationChain.canDegrade()) {
615
+ const degCtx2 = { model, taskProgress, configState, shouldStop: false }
616
+ const deg = degradationChain.apply(degCtx2)
617
+ if (degCtx2.model !== model) model = degCtx2.model
618
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_DEGRADATION_APPLIED, sessionId, payload: { strategy: deg.strategy, phase: "H4", reason: "budget_exceeded" } })
619
+ if (deg.applied && deg.strategy === "graceful_stop") {
620
+ await syncState({ status: "budget_exceeded", lastMessage: `H4: budget exceeded, graceful stop` })
621
+ break
622
+ }
623
+ } else {
624
+ await syncState({ status: "budget_exceeded", lastMessage: `H4: budget exceeded (${totalTokens}/${budgetLimit})` })
625
+ break
626
+ }
627
+ }
628
+ }
629
+
630
+ if (!stageResult.allSuccess) {
631
+ recoveryCount++
632
+ const backoffMs = Math.min(1000 * 2 ** (recoveryCount - 1), 30000)
633
+ await new Promise(r => setTimeout(r, backoffMs))
634
+ const maxStageRecoveries = Number(longagentConfig.max_stage_recoveries ?? 3)
635
+ if (recoveryCount >= maxStageRecoveries) {
636
+ // Phase 6: 尝试降级而非直接 abort
637
+ if (degradationChain.canDegrade()) {
638
+ const degCtx3 = { model, taskProgress, configState, shouldStop: false }
639
+ const deg = degradationChain.apply(degCtx3)
640
+ if (degCtx3.model !== model) model = degCtx3.model
641
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_DEGRADATION_APPLIED, sessionId, payload: { strategy: deg.strategy, phase: "H4", reason: "max_recoveries" } })
642
+ if (deg.applied && deg.strategy === "graceful_stop") {
643
+ await syncState({ status: "error", lastMessage: `stage ${stage.stageId} aborted after degradation` })
644
+ break
645
+ }
646
+ // 降级成功但非 graceful_stop,重置 recoveryCount 继续
647
+ recoveryCount = 0
648
+ } else {
649
+ await syncState({ status: "error", lastMessage: `stage ${stage.stageId} aborted after ${recoveryCount} recoveries` })
650
+ break
651
+ }
652
+ }
653
+ // Phase 1: 根据错误类别决定是否重试
654
+ for (const [taskId, tp] of Object.entries(taskProgress)) {
655
+ if (tp.status === "error") {
656
+ const category = classifyError(tp.lastError)
657
+ if (category === ERROR_CATEGORIES.PERMANENT || category === ERROR_CATEGORIES.UNKNOWN) {
658
+ taskProgress[taskId] = { ...tp, status: "error", skipReason: `${category} error` }
659
+ } else {
660
+ taskProgress[taskId] = { ...tp, status: "retrying", attempt: 0 }
661
+ }
662
+ }
663
+ }
664
+ continue
665
+ }
666
+
667
+ stageIndex++
668
+ await saveCheckpoint(sessionId, { name: `hybrid_stage_${stage.stageId}`, iteration, currentPhase, stageIndex, stagePlan, taskProgress, planFrozen, lastProgress })
669
+ }
670
+
671
+ // #11 Cross-review:H4 完成后、H5 之前,让独立 agent 审查代码
672
+ if (hybridConfig.cross_review !== false && fileChanges.length > 0) {
673
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_CROSS_REVIEW, sessionId, payload: { fileCount: fileChanges.length } })
674
+ const reviewFiles = fileChanges.slice(0, 20).map(f => f.path).join(", ")
675
+ const reviewOut = await processTurnLoop({
676
+ prompt: [
677
+ "You are the CROSS-REVIEW agent. Multiple parallel sub-agents just completed their coding tasks independently.",
678
+ "Your job: verify that their outputs are compatible, correct, and integrate properly.",
679
+ "",
680
+ "## Files to review:",
681
+ reviewFiles,
682
+ "",
683
+ "## Review Checklist",
684
+ "1. IMPORT RESOLUTION: Do all cross-file imports resolve? Are exported symbols correct?",
685
+ "2. INTERFACE COMPATIBILITY: Do function signatures match what callers expect?",
686
+ "3. ERROR HANDLING: Are errors properly caught, propagated, or thrown? No silent failures?",
687
+ "4. RESOURCE CLEANUP: Are timers cleared, listeners removed, handles closed in all code paths?",
688
+ "5. EDGE CASES: Null/undefined checks, empty arrays, concurrent access guards?",
689
+ "6. CONSISTENCY: Same naming conventions, error patterns, async style across files?",
690
+ "",
691
+ `## Original Objective: ${prompt}`,
692
+ "",
693
+ "## Output Format",
694
+ "For each issue found, output: [FAILED_TASK: taskId] with a description of the problem.",
695
+ "If no issues found, state that the cross-review passed.",
696
+ "Focus on REAL bugs that would cause runtime failures — not style preferences."
697
+ ].join("\n"),
698
+ mode: "agent", agent: getAgent("debugging-agent"),
699
+ model, providerType, sessionId, configState, baseUrl, apiKeyEnv, signal, output, allowQuestion: false, toolContext
700
+ })
701
+ accumulateUsage(reviewOut)
702
+ // 将审查发现注入 priorContext
703
+ if (reviewOut.reply) priorContext += `\n### Cross-Review Findings\n${reviewOut.reply.slice(0, 1500)}\n`
704
+ }
705
+
706
+ // --- H5: DEBUGGING (回滚检测) ---
707
+ await setPhase("H5", "debugging")
708
+ currentGate = "debugging"
709
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_DEBUGGING_START, sessionId, payload: { codingRollbackCount } })
710
+ await syncState({ lastMessage: "H5: debugging agent verifying implementation" })
711
+
712
+ const debugModel = getModelForStage("debugging")
713
+ const debugPrompt = buildStageWrapper(LONGAGENT_4STAGE_STAGES.DEBUGGING, {
714
+ preview: previewFindings.slice(0, 2000),
715
+ blueprint: architectureText.slice(0, 3000),
716
+ coding: priorContext.slice(0, 4000)
717
+ }, prompt)
718
+
719
+ let debugIter = 0
720
+ let debugDone = false
721
+ const semanticTracker = createSemanticErrorTracker(3)
722
+ const debugPhaseStart = Date.now()
723
+
724
+ while (!debugDone && debugIter < maxDebugIterations) {
725
+ debugIter++
726
+ iteration++
727
+ const state = await LongAgentManager.get(sessionId)
728
+ if (state?.stopRequested || signal?.aborted) break
729
+
730
+ // Phase 2: debugging 阶段超时检测
731
+ if (Date.now() - debugPhaseStart > debuggingPhaseTimeoutMs) {
732
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_PHASE_TIMEOUT, sessionId, payload: { phase: "H5", elapsed: Date.now() - debugPhaseStart } })
733
+ if (degradationChain.canDegrade()) {
734
+ const degCtx4 = { model, taskProgress, configState, shouldStop: false }
735
+ const deg = degradationChain.apply(degCtx4)
736
+ if (degCtx4.model !== model) model = degCtx4.model
737
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_DEGRADATION_APPLIED, sessionId, payload: { strategy: deg.strategy, phase: "H5" } })
738
+ if (deg.applied && deg.strategy === "graceful_stop") break
739
+ } else {
740
+ break
741
+ }
742
+ }
743
+
744
+ const debugOut = await processTurnLoop({
745
+ prompt: debugPrompt, mode: "agent", agent: getAgent("debugging-agent"),
746
+ model: debugModel.model, providerType: debugModel.providerType,
747
+ sessionId, configState, baseUrl, apiKeyEnv, signal, output, allowQuestion, toolContext
748
+ })
749
+ accumulateUsage(debugOut)
750
+ finalReply = debugOut.reply || ""
751
+
752
+ // 防卡死检测
753
+ if (debugOut.toolEvents?.length) {
754
+ const stuckResult = stuckTracker.track(debugOut.toolEvents)
755
+ if (stuckResult.isStuck) {
756
+ stuckTracker.resetReadOnlyCount()
757
+ await EventBus.emit({
758
+ type: EVENT_TYPES.LONGAGENT_ALERT, sessionId,
759
+ payload: { kind: "stuck_warning", stage: "H5:debugging", reason: stuckResult.reason, debugIter }
760
+ })
761
+ await syncState({ lastMessage: `H5: stuck detected (${stuckResult.reason}), iter ${debugIter}` })
762
+ }
763
+ }
764
+
765
+ // Phase 5: 语义级错误检测
766
+ const semResult = semanticTracker.track(finalReply)
767
+ if (semResult.isRepeated) {
768
+ await EventBus.emit({
769
+ type: EVENT_TYPES.LONGAGENT_SEMANTIC_ERROR_REPEATED, sessionId,
770
+ payload: { error: semResult.error, count: semResult.count, debugIter }
771
+ })
772
+ // 注入更详细的错误分析提示,避免无限循环
773
+ await syncState({ lastMessage: `H5: repeated error detected (${semResult.count}x): ${(semResult.error || "").slice(0, 80)}` })
774
+ }
775
+
776
+ if (detectStageComplete(finalReply, LONGAGENT_4STAGE_STAGES.DEBUGGING)) {
777
+ debugDone = true
778
+ gateStatus.debugging = { status: "pass", iterations: debugIter }
779
+ }
780
+
781
+ if (detectReturnToCoding(finalReply)) {
782
+ codingRollbackCount++
783
+ rerunCoding = true
784
+ // #1 细粒度回滚:优先只重置被标记的失败 task
785
+ const failedIds = extractFailedTaskIds(finalReply)
786
+ if (failedIds.length > 0) {
787
+ for (const fid of failedIds) {
788
+ if (taskProgress[fid]) taskProgress[fid] = { ...taskProgress[fid], status: "retrying", attempt: 0 }
789
+ }
790
+ } else {
791
+ // 回退:重置所有 error 状态的 task
792
+ for (const [taskId, tp] of Object.entries(taskProgress)) {
793
+ if (tp.status === "error") taskProgress[taskId] = { ...tp, status: "retrying", attempt: 0 }
794
+ }
795
+ }
796
+ gateStatus.debugging = { status: "rollback", iterations: debugIter, rollbackCount: codingRollbackCount, failedTaskIds: failedIds }
797
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_RETURN_TO_CODING, sessionId, payload: { rollbackCount: codingRollbackCount, failedTaskIds: failedIds } })
798
+ break
799
+ }
800
+
801
+ if (/\[TASK_COMPLETE\]/i.test(finalReply)) { completionMarkerSeen = true; debugDone = true }
802
+ await syncState({ lastMessage: `H5: debugging iteration ${debugIter}/${maxDebugIterations}` })
803
+ }
804
+
805
+ if (!debugDone && !rerunCoding) {
806
+ gateStatus.debugging = { status: "timeout", iterations: debugIter }
807
+ }
808
+
809
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_DEBUGGING_COMPLETE, sessionId, payload: { debugIter, rollback: rerunCoding } })
810
+ await syncState({ lastMessage: rerunCoding ? `H5: rollback to coding (attempt ${codingRollbackCount})` : `H5: debugging complete` })
811
+ } // end while(rerunCoding)
812
+
813
+ // ========== H5.5: COMPLETION VALIDATION ==========
814
+ if (hybridConfig.completion_validation !== false) {
815
+ await setPhase("H5.5", "completion_validation")
816
+ await syncState({ lastMessage: "H5.5: validating completion" })
817
+
818
+ const cwd = process.cwd()
819
+ try {
820
+ const validator = await createValidator({ cwd, configState })
821
+ const report = await validator.validate({ todoState: toolContext?._todoState, level: "standard" })
822
+ gateStatus.completionValidation = {
823
+ status: report.verdict === "BLOCK" ? "fail" : "pass",
824
+ verdict: report.verdict,
825
+ failedChecks: report.results?.filter(r => !r.passed).length || 0
826
+ }
827
+
828
+ if (report.verdict === "BLOCK" && !completionMarkerSeen) {
829
+ const fixPrompt = [
830
+ "## Completion Validation Failed — Fix Required",
831
+ "",
832
+ `Original objective: ${prompt}`,
833
+ "",
834
+ "## Validation Issues Found:",
835
+ report.message,
836
+ "",
837
+ "## Fix Instructions",
838
+ "1. Read each failing check and identify the root cause",
839
+ "2. Fix the issue in the source code (not by suppressing the check)",
840
+ "3. Re-run the relevant verification command to confirm the fix",
841
+ "4. If a fix requires changes to multiple files, ensure cross-file consistency",
842
+ "",
843
+ "When ALL issues are resolved and verified, include [TASK_COMPLETE] in your response."
844
+ ].join("\n")
845
+ const fixOut = await processTurnLoop({
846
+ prompt: fixPrompt, mode: "agent", agent: getAgent("coding-agent"),
847
+ model, providerType, sessionId, configState,
848
+ baseUrl, apiKeyEnv, signal, output, allowQuestion, toolContext
849
+ })
850
+ accumulateUsage(fixOut)
851
+ iteration++
852
+ if (/\[TASK_COMPLETE\]/i.test(fixOut.reply || "")) completionMarkerSeen = true
853
+ finalReply = fixOut.reply || finalReply
854
+ }
855
+ } catch (valErr) {
856
+ gateStatus.completionValidation = { status: "warn", reason: `skipped: ${valErr.message}` }
857
+ }
858
+ }
859
+
860
+ // ========== H6: USABILITY GATES ==========
861
+ await setPhase("H6", "gates")
862
+ currentGate = "gates"
863
+ await syncState({ lastMessage: "H6: running usability gates" })
864
+
865
+ // Gate 偏好提示(首次运行时询问用户)
866
+ const shouldPromptGates = gatesConfig.prompt_user === "first_run" || gatesConfig.prompt_user === "always"
867
+ if (shouldPromptGates && allowQuestion) {
868
+ const hasPrefs = await hasGatePreferences()
869
+ if (!hasPrefs || gatesConfig.prompt_user === "always") {
870
+ const gateAskResult = await processTurnLoop({
871
+ prompt: buildGatePromptText(),
872
+ mode: "ask", model, providerType, sessionId, configState,
873
+ baseUrl, apiKeyEnv, agent, signal, allowQuestion: true, toolContext
874
+ })
875
+ accumulateUsage(gateAskResult)
876
+ const gatePrefs = parseGateSelection(gateAskResult.reply)
877
+ await saveGatePreferences(gatePrefs)
878
+ for (const [gate, enabled] of Object.entries(gatePrefs)) {
879
+ if (configState.config.agent.longagent.usability_gates[gate]) {
880
+ configState.config.agent.longagent.usability_gates[gate].enabled = enabled
881
+ }
882
+ }
883
+ } else {
884
+ const savedPrefs = await getGatePreferences()
885
+ if (savedPrefs) {
886
+ for (const [gate, enabled] of Object.entries(savedPrefs)) {
887
+ if (configState.config.agent.longagent.usability_gates[gate]) {
888
+ configState.config.agent.longagent.usability_gates[gate].enabled = enabled
889
+ }
890
+ }
891
+ }
892
+ }
893
+ }
894
+
895
+ let gateAttempt = 0
896
+
897
+ while (gateAttempt < maxGateAttempts) {
898
+ gateAttempt++
899
+ const state = await LongAgentManager.get(sessionId)
900
+ if (state?.stopRequested || signal?.aborted) break
901
+
902
+ const gateResult = await runUsabilityGates({
903
+ sessionId, configState, model, providerType,
904
+ baseUrl, apiKeyEnv, signal, toolContext,
905
+ objective: prompt, fileChanges,
906
+ gatesConfig, allowQuestion
907
+ })
908
+ if (gateResult.usage) accumulateUsage(gateResult)
909
+
910
+ if (gateResult.allPassed) {
911
+ gateStatus.usabilityGates = { status: "pass", attempt: gateAttempt }
912
+ break
913
+ }
914
+
915
+ lastGateFailures = gateResult.failures || []
916
+ gateStatus.usabilityGates = { status: "fixing", attempt: gateAttempt, failures: summarizeGateFailures(lastGateFailures) }
917
+ await syncState({ lastMessage: `H6: gate failures (attempt ${gateAttempt}/${maxGateAttempts}), fixing...` })
918
+
919
+ // 修复循环:根据 gate 类型选择修复策略 (Phase 8)
920
+ const strategy = getGateFixStrategy(lastGateFailures)
921
+
922
+ // lint 失败时先尝试自动修复
923
+ if (strategy.autoFix) {
924
+ try {
925
+ const { execSync } = await import("node:child_process")
926
+ execSync(strategy.autoFix, { cwd: process.cwd(), timeout: 30000, stdio: "ignore" })
927
+ } catch { /* autofix failed, fall through to agent */ }
928
+ }
929
+
930
+ const gateFailureSummary = summarizeGateFailures(lastGateFailures)
931
+ const fixPrompt = [
932
+ `## Quality Gate Failures — Attempt ${gateAttempt}/${maxGateAttempts}`,
933
+ "",
934
+ `${strategy.prefix || "Fix the following quality gate failures:"}`,
935
+ "",
936
+ gateFailureSummary,
937
+ "",
938
+ "## Fix Protocol",
939
+ "1. Read the error output carefully — identify the ROOT CAUSE, not just the symptom",
940
+ "2. Fix the source code (do NOT disable or skip the gate check)",
941
+ "3. Re-run the failing command to verify the fix works",
942
+ "4. If the fix touches shared code, verify no regressions in other modules",
943
+ "",
944
+ `Original objective: ${prompt}`
945
+ ].join("\n")
946
+ const fixOut = await processTurnLoop({
947
+ prompt: fixPrompt, mode: "agent", agent: getAgent(strategy.agent || "coding-agent"),
948
+ model, providerType, sessionId, configState,
949
+ baseUrl, apiKeyEnv, signal, output, allowQuestion, toolContext
950
+ })
951
+ accumulateUsage(fixOut)
952
+ iteration++
953
+ }
954
+
955
+ if (gateAttempt >= maxGateAttempts && lastGateFailures.length) {
956
+ gateStatus.usabilityGates = { status: "fail", attempt: gateAttempt, failures: summarizeGateFailures(lastGateFailures) }
957
+ }
958
+
959
+ // ========== H7: GIT MERGE ==========
960
+ if (gitActive && gitBaseBranch && gitBranch) {
961
+ await setPhase("H7", "git_merge")
962
+ try {
963
+ await git.commitAll(`[kkcode-hybrid] session ${sessionId} completed`, cwd)
964
+ if (gitConfig.auto_merge !== false) {
965
+ await git.checkoutBranch(gitBaseBranch, cwd)
966
+ await git.mergeBranch(gitBranch, cwd)
967
+ await git.deleteBranch(gitBranch, cwd)
968
+ gateStatus.gitMerge = { status: "pass", branch: gitBranch, baseBranch: gitBaseBranch }
969
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_GIT_MERGED, sessionId, payload: { branch: gitBranch, baseBranch: gitBaseBranch } })
970
+ }
971
+ } catch (err) {
972
+ // Phase 9: 自愈式 Git 操作
973
+ if (git.isConflictError(err)) {
974
+ try {
975
+ const conflictFiles = await git.getConflictFiles(cwd)
976
+ if (conflictFiles.length > 0) {
977
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_GIT_CONFLICT_RESOLUTION, sessionId, payload: { files: conflictFiles } })
978
+ const conflictPrompt = [
979
+ "## Git Merge Conflict Resolution",
980
+ "",
981
+ "The following files have merge conflicts that must be resolved:",
982
+ ...conflictFiles.map(f => `- ${f}`),
983
+ "",
984
+ "## Resolution Protocol",
985
+ "1. Read each conflicted file and locate ALL conflict markers (<<<<<<< ======= >>>>>>>)",
986
+ "2. For each conflict block:",
987
+ " - Understand what BOTH sides intended (ours = feature branch, theirs = base branch)",
988
+ " - Keep the feature branch changes (our work) unless they break base branch functionality",
989
+ " - If both sides modified the same logic, merge them intelligently (not just pick one)",
990
+ " - Remove ALL conflict markers — no <<<<<<< or ======= or >>>>>>> should remain",
991
+ "3. After resolving, run syntax check on each file (node --check / python -m py_compile)",
992
+ "4. Verify imports still resolve correctly across resolved files"
993
+ ].join("\n")
994
+ const conflictOut = await processTurnLoop({
995
+ prompt: conflictPrompt, mode: "agent", agent: getAgent("coding-agent"),
996
+ model, providerType, sessionId, configState,
997
+ baseUrl, apiKeyEnv, signal, output, allowQuestion: false, toolContext
998
+ })
999
+ accumulateUsage(conflictOut)
1000
+ const commitResult = await git.commitAll(`[kkcode-hybrid] resolved merge conflicts`, cwd)
1001
+ if (commitResult.ok) {
1002
+ gateStatus.gitMerge = { status: "pass", branch: gitBranch, baseBranch: gitBaseBranch, conflictsResolved: true }
1003
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_GIT_MERGED, sessionId, payload: { branch: gitBranch, baseBranch: gitBaseBranch } })
1004
+ } else {
1005
+ await git.mergeAbort(cwd)
1006
+ gateStatus.gitMerge = { status: "warn", reason: "conflict resolution failed, staying on feature branch" }
1007
+ }
1008
+ } else {
1009
+ gateStatus.gitMerge = { status: "warn", reason: err.message }
1010
+ }
1011
+ } catch (resolveErr) {
1012
+ await git.mergeAbort(cwd).catch(() => {})
1013
+ gateStatus.gitMerge = { status: "warn", reason: `conflict resolution error: ${resolveErr.message}` }
1014
+ }
1015
+ } else {
1016
+ gateStatus.gitMerge = { status: "warn", reason: err.message }
1017
+ }
1018
+ }
1019
+ }
1020
+
1021
+ // #5 保存 project memory
1022
+ if (hybridConfig.project_memory !== false && previewFindings) {
1023
+ try {
1024
+ const newMemory = parseMemoryFromPreview(previewFindings)
1025
+ if (newMemory.techStack.length) {
1026
+ 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 || [] }
1027
+ await saveProjectMemory(cwd, merged)
1028
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_HYBRID_MEMORY_SAVED, sessionId, payload: { techStackCount: merged.techStack.length } })
1029
+ }
1030
+ } catch { /* ignore memory save errors */ }
1031
+ }
1032
+
1033
+ // Phase 10: Checkpoint 清理
1034
+ if (hybridConfig.checkpoint_cleanup !== false) {
1035
+ try {
1036
+ const cleanResult = await cleanupCheckpoints(sessionId, {
1037
+ maxKeep: Number(hybridConfig.checkpoint_max_keep || 10),
1038
+ keepStageCheckpoints: true
1039
+ })
1040
+ if (cleanResult.removed > 0) {
1041
+ await EventBus.emit({ type: EVENT_TYPES.LONGAGENT_CHECKPOINT_CLEANED, sessionId, payload: { removed: cleanResult.removed } })
1042
+ }
1043
+ } catch { /* ignore cleanup errors */ }
1044
+ }
1045
+
1046
+ // ========== 完成 ==========
1047
+ const elapsed = Math.round((Date.now() - startTime) / 1000)
1048
+ const finalStatus = completionMarkerSeen ? "completed" : "done"
1049
+ await LongAgentManager.update(sessionId, { status: finalStatus, lastMessage: "hybrid longagent complete", elapsed })
1050
+ await markSessionStatus(sessionId, finalStatus === "completed" ? "completed" : "active")
1051
+
1052
+ const stats = stageProgressStats(taskProgress)
1053
+
1054
+ // Phase 11: 恢复建议生成
1055
+ let recoverySuggestions = null
1056
+ if (finalStatus !== "completed") {
1057
+ recoverySuggestions = generateRecoverySuggestions({
1058
+ status: finalStatus,
1059
+ taskProgress,
1060
+ gateStatus,
1061
+ phase: currentPhase,
1062
+ recoveryCount,
1063
+ fileChanges
1064
+ })
1065
+ }
1066
+
1067
+ return {
1068
+ sessionId, turnId: `turn_long_${Date.now()}`,
1069
+ reply: finalReply || "hybrid longagent complete",
1070
+ usage: aggregateUsage, toolEvents, iterations: iteration,
1071
+ status: finalStatus, phase: currentPhase,
1072
+ gateStatus, currentGate, lastGateFailures, recoveryCount,
1073
+ progress: lastProgress, elapsed,
1074
+ stageIndex, stageCount: stagePlan?.stages?.length || 0,
1075
+ planFrozen, taskProgress, fileChanges,
1076
+ stageProgress: { done: stats.done, total: stats.total },
1077
+ remainingFilesCount: stats.remainingFilesCount,
1078
+ gitBranch, gitBaseBranch,
1079
+ recoverySuggestions
1080
+ }
1081
+ }