@virtengine/openfleet 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/.env.example +914 -0
  2. package/LICENSE +190 -0
  3. package/README.md +500 -0
  4. package/agent-endpoint.mjs +918 -0
  5. package/agent-hook-bridge.mjs +230 -0
  6. package/agent-hooks.mjs +1188 -0
  7. package/agent-pool.mjs +2403 -0
  8. package/agent-prompts.mjs +689 -0
  9. package/agent-sdk.mjs +141 -0
  10. package/anomaly-detector.mjs +1195 -0
  11. package/autofix.mjs +1294 -0
  12. package/claude-shell.mjs +708 -0
  13. package/cli.mjs +906 -0
  14. package/codex-config.mjs +1274 -0
  15. package/codex-model-profiles.mjs +135 -0
  16. package/codex-shell.mjs +762 -0
  17. package/config-doctor.mjs +613 -0
  18. package/config.mjs +1720 -0
  19. package/conflict-resolver.mjs +248 -0
  20. package/container-runner.mjs +450 -0
  21. package/copilot-shell.mjs +827 -0
  22. package/daemon-restart-policy.mjs +56 -0
  23. package/diff-stats.mjs +282 -0
  24. package/error-detector.mjs +829 -0
  25. package/fetch-runtime.mjs +34 -0
  26. package/fleet-coordinator.mjs +838 -0
  27. package/get-telegram-chat-id.mjs +71 -0
  28. package/git-safety.mjs +170 -0
  29. package/github-reconciler.mjs +403 -0
  30. package/hook-profiles.mjs +651 -0
  31. package/kanban-adapter.mjs +4491 -0
  32. package/lib/logger.mjs +645 -0
  33. package/maintenance.mjs +828 -0
  34. package/merge-strategy.mjs +1171 -0
  35. package/monitor.mjs +12207 -0
  36. package/openfleet.config.example.json +115 -0
  37. package/openfleet.schema.json +465 -0
  38. package/package.json +203 -0
  39. package/postinstall.mjs +187 -0
  40. package/pr-cleanup-daemon.mjs +978 -0
  41. package/preflight.mjs +408 -0
  42. package/prepublish-check.mjs +90 -0
  43. package/presence.mjs +328 -0
  44. package/primary-agent.mjs +282 -0
  45. package/publish.mjs +151 -0
  46. package/repo-root.mjs +29 -0
  47. package/restart-controller.mjs +100 -0
  48. package/review-agent.mjs +557 -0
  49. package/rotate-agent-logs.sh +133 -0
  50. package/sdk-conflict-resolver.mjs +973 -0
  51. package/session-tracker.mjs +880 -0
  52. package/setup.mjs +3937 -0
  53. package/shared-knowledge.mjs +410 -0
  54. package/shared-state-manager.mjs +841 -0
  55. package/shared-workspace-cli.mjs +199 -0
  56. package/shared-workspace-registry.mjs +537 -0
  57. package/shared-workspaces.json +18 -0
  58. package/startup-service.mjs +1070 -0
  59. package/sync-engine.mjs +1063 -0
  60. package/task-archiver.mjs +801 -0
  61. package/task-assessment.mjs +550 -0
  62. package/task-claims.mjs +924 -0
  63. package/task-complexity.mjs +581 -0
  64. package/task-executor.mjs +5111 -0
  65. package/task-store.mjs +753 -0
  66. package/telegram-bot.mjs +9281 -0
  67. package/telegram-sentinel.mjs +2010 -0
  68. package/ui/app.js +867 -0
  69. package/ui/app.legacy.js +1464 -0
  70. package/ui/app.monolith.js +2488 -0
  71. package/ui/components/charts.js +226 -0
  72. package/ui/components/chat-view.js +567 -0
  73. package/ui/components/command-palette.js +587 -0
  74. package/ui/components/diff-viewer.js +190 -0
  75. package/ui/components/forms.js +327 -0
  76. package/ui/components/kanban-board.js +451 -0
  77. package/ui/components/session-list.js +305 -0
  78. package/ui/components/shared.js +473 -0
  79. package/ui/index.html +70 -0
  80. package/ui/modules/api.js +297 -0
  81. package/ui/modules/icons.js +461 -0
  82. package/ui/modules/router.js +81 -0
  83. package/ui/modules/settings-schema.js +261 -0
  84. package/ui/modules/state.js +679 -0
  85. package/ui/modules/telegram.js +331 -0
  86. package/ui/modules/utils.js +270 -0
  87. package/ui/styles/animations.css +140 -0
  88. package/ui/styles/base.css +98 -0
  89. package/ui/styles/components.css +1915 -0
  90. package/ui/styles/kanban.css +286 -0
  91. package/ui/styles/layout.css +809 -0
  92. package/ui/styles/sessions.css +827 -0
  93. package/ui/styles/variables.css +188 -0
  94. package/ui/styles.css +141 -0
  95. package/ui/styles.monolith.css +1046 -0
  96. package/ui/tabs/agents.js +1417 -0
  97. package/ui/tabs/chat.js +74 -0
  98. package/ui/tabs/control.js +887 -0
  99. package/ui/tabs/dashboard.js +515 -0
  100. package/ui/tabs/infra.js +537 -0
  101. package/ui/tabs/logs.js +783 -0
  102. package/ui/tabs/settings.js +1487 -0
  103. package/ui/tabs/tasks.js +1385 -0
  104. package/ui-server.mjs +4073 -0
  105. package/update-check.mjs +465 -0
  106. package/utils.mjs +172 -0
  107. package/ve-kanban.mjs +654 -0
  108. package/ve-kanban.ps1 +1365 -0
  109. package/ve-kanban.sh +18 -0
  110. package/ve-orchestrator.mjs +340 -0
  111. package/ve-orchestrator.ps1 +6546 -0
  112. package/ve-orchestrator.sh +18 -0
  113. package/vibe-kanban-wrapper.mjs +41 -0
  114. package/vk-error-resolver.mjs +470 -0
  115. package/vk-log-stream.mjs +914 -0
  116. package/whatsapp-channel.mjs +520 -0
  117. package/workspace-monitor.mjs +581 -0
  118. package/workspace-reaper.mjs +405 -0
  119. package/workspace-registry.mjs +238 -0
  120. package/worktree-manager.mjs +1266 -0
@@ -0,0 +1,1171 @@
1
+ /**
2
+ * merge-strategy.mjs — Codex-powered merge decision engine.
3
+ *
4
+ * When a task completes (agent goes idle, PR exists or is missing, commits
5
+ * detected upstream), this module hands context to Codex SDK and receives a
6
+ * structured action:
7
+ *
8
+ * { action: "merge_after_ci_pass" }
9
+ * { action: "prompt", message: "Fix the lint error in foo.ts" }
10
+ * { action: "close_pr", reason: "Duplicate of #123" }
11
+ * { action: "re_attempt", reason: "Agent didn't implement the feature" }
12
+ * { action: "manual_review", reason: "Big PR needs human eyes" }
13
+ * { action: "wait", seconds: 300, reason: "CI still running" }
14
+ *
15
+ * Enhanced with thread-aware execution:
16
+ * - "prompt" resumes the original agent thread (full context preserved)
17
+ * - "re_attempt" uses execWithRetry for automatic error recovery
18
+ * - "merge_after_ci_pass" enables gh auto-merge
19
+ * - "close_pr" closes the PR with a comment
20
+ * - Self-contained: can use agent-pool directly without injected execCodex
21
+ *
22
+ * Safety:
23
+ * - 10 minute timeout (configurable via MERGE_STRATEGY_TIMEOUT_MS)
24
+ * - Structured JSON parsing with fallback
25
+ * - Audit logs for every decision
26
+ * - Dedup: won't re-analyze the same attempt within cooldown
27
+ */
28
+
29
+ import { writeFile, mkdir } from "node:fs/promises";
30
+ import { resolve } from "node:path";
31
+ import { fileURLToPath } from "node:url";
32
+ import { execSync } from "node:child_process";
33
+
34
+ import {
35
+ execPooledPrompt,
36
+ launchOrResumeThread,
37
+ execWithRetry,
38
+ getThreadRecord,
39
+ invalidateThread,
40
+ } from "./agent-pool.mjs";
41
+ import { resolvePromptTemplate } from "./agent-prompts.mjs";
42
+
43
+ const __dirname = resolve(fileURLToPath(new URL(".", import.meta.url)));
44
+
45
+ // ── Valid actions ────────────────────────────────────────────────────────────
46
+
47
+ const VALID_ACTIONS = new Set([
48
+ "merge_after_ci_pass",
49
+ "prompt",
50
+ "close_pr",
51
+ "re_attempt",
52
+ "manual_review",
53
+ "wait",
54
+ "noop",
55
+ ]);
56
+
57
+ // ── Dedup / rate limiting ───────────────────────────────────────────────────
58
+
59
+ /** @type {Map<string, number>} attemptId → last analysis timestamp */
60
+ const analysisDedup = new Map();
61
+ const ANALYSIS_COOLDOWN_MS = 10 * 60 * 1000; // 10 min per attempt
62
+
63
+ // ── Types ───────────────────────────────────────────────────────────────────
64
+
65
+ /**
66
+ * @typedef {object} MergeContext
67
+ * @property {string} attemptId - Full attempt UUID
68
+ * @property {string} shortId - Short ID for logging
69
+ * @property {string} status - Attempt status (completed, failed, etc.)
70
+ * @property {string} [agentLastMessage] - Last message from the agent
71
+ * @property {string} [prTitle] - PR title (if created)
72
+ * @property {number} [prNumber] - PR number (if created)
73
+ * @property {string} [prUrl] - PR URL (if created)
74
+ * @property {string} [prState] - PR state (open, closed, merged)
75
+ * @property {string} [branch] - Branch name
76
+ * @property {number} [commitsAhead] - Commits ahead of main
77
+ * @property {number} [commitsBehind] - Commits behind main
78
+ * @property {number} [filesChanged] - Number of files changed
79
+ * @property {string} [diffStat] - Git diff --stat output
80
+ * @property {string[]} [changedFiles] - List of changed file paths
81
+ * @property {string} [taskTitle] - Original task title
82
+ * @property {string} [taskDescription] - Original task description
83
+ * @property {string} [worktreeDir] - Local worktree directory
84
+ * @property {string} [ciStatus] - CI status if known (pending, passing, failing)
85
+ * @property {string} [taskKey] - Task key for thread registry lookup (links to original agent thread)
86
+ * @property {string} [baseBranch] - Base branch for diff comparison (default: "origin/main")
87
+ */
88
+
89
+ /**
90
+ * @typedef {object} MergeDecision
91
+ * @property {string} action - One of VALID_ACTIONS
92
+ * @property {string} [message] - For "prompt" action
93
+ * @property {string} [reason] - Explanation for the decision
94
+ * @property {number} [seconds] - For "wait" action
95
+ * @property {boolean} success - Whether analysis completed
96
+ * @property {string} rawOutput - Raw Codex output for audit
97
+ */
98
+
99
+ /**
100
+ * @typedef {object} ExecutionResult
101
+ * @property {boolean} executed Whether the action was executed
102
+ * @property {string} action The action that was taken
103
+ * @property {boolean} success Whether execution succeeded
104
+ * @property {string} [output] Agent output (for prompt/re_attempt)
105
+ * @property {string} [error] Error message if failed
106
+ * @property {boolean} [resumed] Whether an existing thread was resumed
107
+ * @property {number} [attempts] Number of attempts (for re_attempt)
108
+ */
109
+
110
+ // ── Prompt builder ──────────────────────────────────────────────────────────
111
+
112
+ /**
113
+ * Build the analysis prompt for Codex SDK.
114
+ */
115
+ function buildMergeStrategyPrompt(ctx, promptTemplate = "") {
116
+ const parts = [];
117
+
118
+ parts.push(`# Merge Strategy Decision
119
+
120
+ You are a senior engineering reviewer. An AI agent has completed (or attempted) a task.
121
+ Review the context below and decide the NEXT ACTION.
122
+
123
+ ## Task Context`);
124
+
125
+ if (ctx.taskTitle) parts.push(`**Task:** ${ctx.taskTitle}`);
126
+ if (ctx.taskDescription) {
127
+ parts.push(`**Description:** ${ctx.taskDescription.slice(0, 2000)}`);
128
+ }
129
+ parts.push(`**Status:** ${ctx.status}`);
130
+ if (ctx.branch) parts.push(`**Branch:** ${ctx.branch}`);
131
+
132
+ // Agent's last message — this is the key signal
133
+ if (ctx.agentLastMessage) {
134
+ parts.push(`
135
+ ## Agent's Last Message
136
+ \`\`\`
137
+ ${ctx.agentLastMessage.slice(0, 8000)}
138
+ \`\`\``);
139
+ }
140
+
141
+ // PR details
142
+ if (ctx.prNumber) {
143
+ parts.push(`
144
+ ## Pull Request
145
+ - PR #${ctx.prNumber}: ${ctx.prTitle || "(no title)"}
146
+ - State: ${ctx.prState || "unknown"}
147
+ - URL: ${ctx.prUrl || "N/A"}
148
+ - CI: ${ctx.ciStatus || "unknown"}`);
149
+ } else {
150
+ parts.push(`
151
+ ## Pull Request
152
+ No PR has been created yet.`);
153
+ }
154
+
155
+ // Diff / files
156
+ if (ctx.filesChanged != null || ctx.changedFiles?.length) {
157
+ parts.push(`
158
+ ## Changes
159
+ - Files changed: ${ctx.filesChanged ?? ctx.changedFiles?.length ?? "unknown"}
160
+ - Commits ahead: ${ctx.commitsAhead ?? "unknown"}
161
+ - Commits behind: ${ctx.commitsBehind ?? "unknown"}`);
162
+ }
163
+
164
+ if (ctx.changedFiles?.length) {
165
+ const fileList = ctx.changedFiles.slice(0, 50).join("\n");
166
+ parts.push(`
167
+ ### Changed Files
168
+ \`\`\`
169
+ ${fileList}${ctx.changedFiles.length > 50 ? `\n... and ${ctx.changedFiles.length - 50} more` : ""}
170
+ \`\`\``);
171
+ }
172
+
173
+ if (ctx.diffStat) {
174
+ parts.push(`
175
+ ### Diff Stats
176
+ \`\`\`
177
+ ${ctx.diffStat.slice(0, 3000)}
178
+ \`\`\``);
179
+ }
180
+
181
+ if (ctx.worktreeDir) {
182
+ parts.push(`
183
+ ## Worktree
184
+ Directory: ${ctx.worktreeDir}`);
185
+ }
186
+
187
+ // Decision framework
188
+ parts.push(`
189
+ ## Decision Rules
190
+
191
+ Based on the above context, choose ONE action:
192
+
193
+ 1. **merge_after_ci_pass** — Agent completed the task successfully, PR looks good, merge when CI passes.
194
+ Use when: Agent reports success ("✅ Task Complete"), changes match the task description, no obvious issues.
195
+
196
+ 2. **prompt** — Agent needs to do more work. Provide a specific message telling the agent what to fix.
197
+ Use when: Task partially done, lint/test failures mentioned, missing files, incomplete implementation.
198
+ IMPORTANT: Include SPECIFIC instructions (file names, error messages, what to change).
199
+
200
+ 3. **close_pr** — PR should be closed (bad implementation, wrong approach, duplicate).
201
+ Use when: Agent went off-track, made destructive changes, or the PR is fundamentally broken.
202
+
203
+ 4. **re_attempt** — Start the task over with a fresh agent.
204
+ Use when: Agent crashed without useful work, context window exhausted, or approach was wrong.
205
+
206
+ 5. **manual_review** — Escalate to human reviewer.
207
+ Use when: Large/risky changes, security-sensitive code, or you're unsure about the approach.
208
+
209
+ 6. **wait** — CI is still running, wait before deciding.
210
+ Use when: CI status is "pending" and the changes look reasonable.
211
+
212
+ 7. **noop** — No action needed (informational only, or task was already handled).
213
+
214
+ ## Response Format
215
+
216
+ Respond with ONLY a JSON object (no markdown, no explanation outside the JSON):
217
+
218
+ \`\`\`json
219
+ {
220
+ "action": "merge_after_ci_pass",
221
+ "reason": "Agent completed all acceptance criteria. 3 files changed, tests added."
222
+ }
223
+ \`\`\`
224
+
225
+ Or for prompt:
226
+
227
+ \`\`\`json
228
+ {
229
+ "action": "prompt",
230
+ "message": "The ESLint check failed on src/handler.ts:42. Please fix the unused variable warning and push again.",
231
+ "reason": "Agent's last message mentions lint errors but didn't fix them."
232
+ }
233
+ \`\`\`
234
+
235
+ Or for wait:
236
+
237
+ \`\`\`json
238
+ {
239
+ "action": "wait",
240
+ "seconds": 300,
241
+ "reason": "CI is still running. Wait 5 minutes before re-checking."
242
+ }
243
+ \`\`\`
244
+
245
+ RESPOND WITH ONLY THE JSON OBJECT.`);
246
+
247
+ const fallback = parts.join("\n");
248
+ const taskContextBlock = [
249
+ ctx.taskTitle ? `**Task:** ${ctx.taskTitle}` : "",
250
+ ctx.taskDescription
251
+ ? `**Description:** ${ctx.taskDescription.slice(0, 2000)}`
252
+ : "",
253
+ `**Status:** ${ctx.status}`,
254
+ ctx.branch ? `**Branch:** ${ctx.branch}` : "",
255
+ ]
256
+ .filter(Boolean)
257
+ .join("\n");
258
+ return resolvePromptTemplate(
259
+ promptTemplate,
260
+ {
261
+ TASK_CONTEXT_BLOCK: taskContextBlock,
262
+ AGENT_LAST_MESSAGE_BLOCK: ctx.agentLastMessage
263
+ ? `## Agent's Last Message\n\`\`\`\n${ctx.agentLastMessage.slice(0, 8000)}\n\`\`\``
264
+ : "",
265
+ PULL_REQUEST_BLOCK: ctx.prNumber
266
+ ? `## Pull Request\n- PR #${ctx.prNumber}: ${ctx.prTitle || "(no title)"}\n- State: ${ctx.prState || "unknown"}\n- URL: ${ctx.prUrl || "N/A"}\n- CI: ${ctx.ciStatus || "unknown"}`
267
+ : "## Pull Request\nNo PR has been created yet.",
268
+ CHANGES_BLOCK:
269
+ ctx.filesChanged != null || ctx.changedFiles?.length
270
+ ? `## Changes\n- Files changed: ${ctx.filesChanged ?? ctx.changedFiles?.length ?? "unknown"}\n- Commits ahead: ${ctx.commitsAhead ?? "unknown"}\n- Commits behind: ${ctx.commitsBehind ?? "unknown"}`
271
+ : "",
272
+ CHANGED_FILES_BLOCK: ctx.changedFiles?.length
273
+ ? `### Changed Files\n\`\`\`\n${ctx.changedFiles.slice(0, 50).join("\n")}${ctx.changedFiles.length > 50 ? `\n... and ${ctx.changedFiles.length - 50} more` : ""}\n\`\`\``
274
+ : "",
275
+ DIFF_STATS_BLOCK: ctx.diffStat
276
+ ? `### Diff Stats\n\`\`\`\n${ctx.diffStat.slice(0, 3000)}\n\`\`\``
277
+ : "",
278
+ WORKTREE_BLOCK: ctx.worktreeDir ? `## Worktree\nDirectory: ${ctx.worktreeDir}` : "",
279
+ },
280
+ fallback,
281
+ );
282
+ }
283
+
284
+ // ── JSON extraction ─────────────────────────────────────────────────────────
285
+
286
+ /**
287
+ * Extract a JSON action from Codex output, which may contain markdown
288
+ * fences or surrounding text.
289
+ */
290
+ function extractActionJson(raw) {
291
+ if (!raw || typeof raw !== "string") return null;
292
+
293
+ // Try direct parse
294
+ try {
295
+ const parsed = JSON.parse(raw.trim());
296
+ if (parsed && typeof parsed.action === "string") return parsed;
297
+ } catch {
298
+ /* not pure JSON */
299
+ }
300
+
301
+ // Try extracting from markdown fences
302
+ const fenceMatch = raw.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
303
+ if (fenceMatch) {
304
+ try {
305
+ const parsed = JSON.parse(fenceMatch[1].trim());
306
+ if (parsed && typeof parsed.action === "string") return parsed;
307
+ } catch {
308
+ /* bad JSON in fence */
309
+ }
310
+ }
311
+
312
+ // Try finding {..."action":...} anywhere
313
+ const braceMatch = raw.match(/\{[\s\S]*?"action"\s*:\s*"[^"]+?"[\s\S]*?\}/);
314
+ if (braceMatch) {
315
+ try {
316
+ const parsed = JSON.parse(braceMatch[0]);
317
+ if (parsed && typeof parsed.action === "string") return parsed;
318
+ } catch {
319
+ /* partial match */
320
+ }
321
+ }
322
+
323
+ return null;
324
+ }
325
+
326
+ // ── Git helpers ─────────────────────────────────────────────────────────────
327
+
328
+ /**
329
+ * Read the last agent message from the worktree's git log or status file.
330
+ */
331
+ function readLastAgentMessage(worktreeDir) {
332
+ if (!worktreeDir) return null;
333
+ try {
334
+ // Get the last commit message (agent typically commits with a summary)
335
+ const msg = execSync("git log -1 --pretty=format:%B", {
336
+ cwd: worktreeDir,
337
+ encoding: "utf8",
338
+ timeout: 5000,
339
+ }).trim();
340
+ return msg || null;
341
+ } catch {
342
+ return null;
343
+ }
344
+ }
345
+
346
+ /**
347
+ * Get diff stats for the branch vs its upstream/base.
348
+ * @param {string} worktreeDir
349
+ * @param {string} [baseBranch] - upstream branch to diff against, defaults to origin/main
350
+ */
351
+ function getDiffDetails(worktreeDir, baseBranch) {
352
+ if (!worktreeDir)
353
+ return { diffStat: null, changedFiles: [], filesChanged: 0 };
354
+ const base = baseBranch || "origin/main";
355
+ try {
356
+ const stat = execSync(`git diff --stat ${base}...HEAD`, {
357
+ cwd: worktreeDir,
358
+ encoding: "utf8",
359
+ timeout: 10000,
360
+ }).trim();
361
+
362
+ const files = execSync(`git diff --name-only ${base}...HEAD`, {
363
+ cwd: worktreeDir,
364
+ encoding: "utf8",
365
+ timeout: 10000,
366
+ })
367
+ .trim()
368
+ .split(/\r?\n/)
369
+ .filter(Boolean);
370
+
371
+ return { diffStat: stat, changedFiles: files, filesChanged: files.length };
372
+ } catch {
373
+ return { diffStat: null, changedFiles: [], filesChanged: 0 };
374
+ }
375
+ }
376
+
377
+ // ── Main analysis function ──────────────────────────────────────────────────
378
+
379
+ /**
380
+ * Analyze a completed task and return a merge strategy decision.
381
+ *
382
+ * Uses the Codex SDK (persistent shell) via execCodexPrompt to review
383
+ * the agent's work and decide what to do next.
384
+ *
385
+ * @param {MergeContext} ctx - Context about the completed task
386
+ * @param {object} opts
387
+ * @param {function} [opts.execCodex] - execCodexPrompt function from codex-shell (legacy; optional if agent-pool available)
388
+ * @param {number} [opts.timeoutMs=600000] - Analysis timeout (default: 10 min)
389
+ * @param {string} opts.logDir - Directory for audit logs
390
+ * @param {function} [opts.onTelegram] - Telegram notification callback
391
+ * @param {boolean} [opts.useAgentPool=true] - When true and no execCodex, use agent-pool's execPooledPrompt
392
+ * @param {object} [opts.promptTemplates] - Optional prompt template overrides
393
+ * @returns {Promise<MergeDecision>}
394
+ */
395
+ export async function analyzeMergeStrategy(ctx, opts = {}) {
396
+ const {
397
+ execCodex,
398
+ timeoutMs = 10 * 60 * 1000,
399
+ logDir,
400
+ onTelegram,
401
+ useAgentPool = true,
402
+ promptTemplates = {},
403
+ } = opts;
404
+
405
+ const tag = `merge-strategy(${ctx.shortId})`;
406
+
407
+ // ── Dedup check ────────────────────────────────────────────
408
+ const lastAnalysis = analysisDedup.get(ctx.attemptId);
409
+ if (lastAnalysis && Date.now() - lastAnalysis < ANALYSIS_COOLDOWN_MS) {
410
+ const waitSec = Math.round(
411
+ (ANALYSIS_COOLDOWN_MS - (Date.now() - lastAnalysis)) / 1000,
412
+ );
413
+ console.log(`[${tag}] skipping — analyzed ${waitSec}s ago (cooldown)`);
414
+ return {
415
+ action: "noop",
416
+ reason: `Already analyzed recently (${waitSec}s ago)`,
417
+ success: true,
418
+ rawOutput: "",
419
+ };
420
+ }
421
+ analysisDedup.set(ctx.attemptId, Date.now());
422
+
423
+ // ── Enrich context with git data if worktree available ─────
424
+ if (ctx.worktreeDir) {
425
+ if (!ctx.agentLastMessage) {
426
+ ctx.agentLastMessage = readLastAgentMessage(ctx.worktreeDir);
427
+ }
428
+ if (!ctx.diffStat || !ctx.changedFiles?.length) {
429
+ const diff = getDiffDetails(ctx.worktreeDir, ctx.baseBranch);
430
+ ctx.diffStat = ctx.diffStat || diff.diffStat;
431
+ ctx.changedFiles = ctx.changedFiles?.length
432
+ ? ctx.changedFiles
433
+ : diff.changedFiles;
434
+ ctx.filesChanged = ctx.filesChanged ?? diff.filesChanged;
435
+ }
436
+ }
437
+
438
+ // ── Build prompt ───────────────────────────────────────────
439
+ const prompt = buildMergeStrategyPrompt(ctx, promptTemplates.mergeStrategy);
440
+
441
+ console.log(
442
+ `[${tag}] starting Codex merge analysis (timeout: ${timeoutMs / 1000}s)...`,
443
+ );
444
+
445
+ if (onTelegram) {
446
+ onTelegram(
447
+ `🔍 Merge strategy analysis started for ${ctx.shortId}` +
448
+ (ctx.taskTitle ? ` — "${ctx.taskTitle}"` : "") +
449
+ (ctx.prNumber ? ` (PR #${ctx.prNumber})` : ""),
450
+ );
451
+ }
452
+
453
+ // ── Run Codex ──────────────────────────────────────────────
454
+ const startMs = Date.now();
455
+ let rawOutput = "";
456
+ let codexError = null;
457
+
458
+ try {
459
+ let result;
460
+ if (execCodex) {
461
+ // Legacy path: injected function (backward compat)
462
+ result = await execCodex(prompt, { timeoutMs });
463
+ } else if (useAgentPool) {
464
+ // New path: use agent-pool's execPooledPrompt (self-contained)
465
+ result = await execPooledPrompt(prompt, { timeoutMs });
466
+ } else {
467
+ throw new Error(
468
+ "No execution backend: provide opts.execCodex or enable useAgentPool",
469
+ );
470
+ }
471
+ rawOutput = result?.finalResponse || "";
472
+ } catch (err) {
473
+ codexError = err?.message || String(err);
474
+ console.warn(`[${tag}] Codex analysis failed: ${codexError}`);
475
+ }
476
+
477
+ const elapsed = Date.now() - startMs;
478
+
479
+ // ── Parse decision ─────────────────────────────────────────
480
+ let decision = extractActionJson(rawOutput);
481
+
482
+ if (!decision) {
483
+ // Codex didn't return valid JSON — build a fallback
484
+ console.warn(
485
+ `[${tag}] Codex returned non-JSON output (${rawOutput.length} chars)`,
486
+ );
487
+ decision = {
488
+ action: "manual_review",
489
+ reason: codexError
490
+ ? `Codex error: ${codexError}`
491
+ : `Could not parse Codex response (${rawOutput.slice(0, 200)})`,
492
+ };
493
+ }
494
+
495
+ // Validate action
496
+ if (!VALID_ACTIONS.has(decision.action)) {
497
+ console.warn(
498
+ `[${tag}] invalid action "${decision.action}" — defaulting to manual_review`,
499
+ );
500
+ decision.action = "manual_review";
501
+ decision.reason = `Invalid action "${decision.action}" — ${decision.reason || "unknown"}`;
502
+ }
503
+
504
+ // ── Audit log ──────────────────────────────────────────────
505
+ if (logDir) {
506
+ try {
507
+ await mkdir(resolve(logDir), { recursive: true });
508
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
509
+ const auditPath = resolve(
510
+ logDir,
511
+ `merge-strategy-${ctx.shortId}-${stamp}.log`,
512
+ );
513
+ await writeFile(
514
+ auditPath,
515
+ [
516
+ `# Merge Strategy Analysis`,
517
+ `# Attempt: ${ctx.attemptId}`,
518
+ `# Task: ${ctx.taskTitle || "unknown"}`,
519
+ `# Status: ${ctx.status}`,
520
+ `# Elapsed: ${elapsed}ms`,
521
+ `# Timestamp: ${new Date().toISOString()}`,
522
+ "",
523
+ "## Prompt:",
524
+ prompt,
525
+ "",
526
+ "## Raw Codex Output:",
527
+ rawOutput || "(empty)",
528
+ codexError ? `\n## Error: ${codexError}` : "",
529
+ "",
530
+ "## Parsed Decision:",
531
+ JSON.stringify(decision, null, 2),
532
+ ].join("\n"),
533
+ "utf8",
534
+ );
535
+ } catch {
536
+ /* audit write failed — non-critical */
537
+ }
538
+ }
539
+
540
+ // ── Notify ─────────────────────────────────────────────────
541
+ const actionEmoji = {
542
+ merge_after_ci_pass: "✅",
543
+ prompt: "💬",
544
+ close_pr: "🚫",
545
+ re_attempt: "🔄",
546
+ manual_review: "👀",
547
+ wait: "⏳",
548
+ noop: "➖",
549
+ };
550
+
551
+ const emoji = actionEmoji[decision.action] || "❓";
552
+ console.log(
553
+ `[${tag}] decision: ${emoji} ${decision.action}` +
554
+ (decision.reason ? ` — ${decision.reason.slice(0, 120)}` : "") +
555
+ ` (${elapsed}ms)`,
556
+ );
557
+
558
+ if (onTelegram) {
559
+ const lines = [
560
+ `${emoji} Merge Strategy: **${decision.action}**`,
561
+ `Task: ${ctx.taskTitle || ctx.shortId}`,
562
+ ];
563
+ if (decision.reason) lines.push(`Reason: ${decision.reason.slice(0, 300)}`);
564
+ if (decision.message)
565
+ lines.push(`Message: ${decision.message.slice(0, 300)}`);
566
+ if (ctx.prNumber) lines.push(`PR: #${ctx.prNumber}`);
567
+ lines.push(`Analysis: ${Math.round(elapsed / 1000)}s`);
568
+ onTelegram(lines.join("\n"));
569
+ }
570
+
571
+ return {
572
+ ...decision,
573
+ success: !codexError,
574
+ rawOutput,
575
+ };
576
+ }
577
+
578
+ /**
579
+ * Reset the dedup cache (useful when clearing state).
580
+ */
581
+ export function resetMergeStrategyDedup() {
582
+ analysisDedup.clear();
583
+ }
584
+
585
+ // ── Decision execution ──────────────────────────────────────────────────────
586
+
587
+ /**
588
+ * Execute a merge strategy decision by acting on the chosen action.
589
+ *
590
+ * Key behaviors:
591
+ * - "prompt": Resumes the ORIGINAL agent thread (via taskKey) with the fix instruction,
592
+ * so the agent has full context from the initial work. Falls back to fresh thread
593
+ * if no existing thread found.
594
+ * - "re_attempt": Uses execWithRetry to launch a fresh attempt with error recovery.
595
+ * The original thread is invalidated first.
596
+ * - "merge_after_ci_pass": Enables auto-merge on the PR via `gh pr merge --auto`.
597
+ * - "close_pr": Closes the PR via `gh pr close`.
598
+ * - "wait": Returns with the wait duration (caller handles scheduling).
599
+ * - "manual_review": Sends notification (caller handles telegram).
600
+ * - "noop": Does nothing.
601
+ *
602
+ * @param {MergeDecision} decision The decision from analyzeMergeStrategy
603
+ * @param {MergeContext} ctx The context used for the analysis
604
+ * @param {object} opts
605
+ * @param {string} [opts.logDir] Audit log directory
606
+ * @param {function} [opts.onTelegram] Telegram notification callback
607
+ * @param {number} [opts.timeoutMs] Timeout for agent operations (default: 15 min)
608
+ * @param {number} [opts.maxRetries] Max retries for re_attempt (default: 2)
609
+ * @param {object} [opts.promptTemplates] Optional prompt template overrides
610
+ * @returns {Promise<ExecutionResult>}
611
+ */
612
+ export async function executeDecision(decision, ctx, opts = {}) {
613
+ const {
614
+ logDir,
615
+ onTelegram,
616
+ timeoutMs = 15 * 60 * 1000,
617
+ maxRetries = 2,
618
+ promptTemplates = {},
619
+ } = opts;
620
+
621
+ const tag = `merge-exec(${ctx.shortId})`;
622
+ const taskKey = ctx.taskKey || ctx.attemptId;
623
+ const cwd = ctx.worktreeDir || undefined;
624
+
625
+ try {
626
+ switch (decision.action) {
627
+ case "prompt":
628
+ return await executePromptAction(decision, ctx, {
629
+ tag,
630
+ taskKey,
631
+ cwd,
632
+ timeoutMs,
633
+ logDir,
634
+ onTelegram,
635
+ promptTemplate: promptTemplates.mergeStrategyFix,
636
+ });
637
+
638
+ case "re_attempt":
639
+ return await executeReAttemptAction(decision, ctx, {
640
+ tag,
641
+ taskKey,
642
+ cwd,
643
+ timeoutMs,
644
+ maxRetries,
645
+ logDir,
646
+ onTelegram,
647
+ promptTemplate: promptTemplates.mergeStrategyReAttempt,
648
+ });
649
+
650
+ case "merge_after_ci_pass":
651
+ return await executeMergeAction(decision, ctx, { tag, onTelegram });
652
+
653
+ case "close_pr":
654
+ return await executeCloseAction(decision, ctx, { tag, onTelegram });
655
+
656
+ case "wait":
657
+ return {
658
+ executed: true,
659
+ action: "wait",
660
+ success: true,
661
+ waitSeconds: decision.seconds || 300,
662
+ };
663
+
664
+ case "manual_review":
665
+ if (onTelegram) {
666
+ onTelegram(
667
+ `👀 Manual review needed for ${ctx.taskTitle || ctx.shortId}: ${decision.reason || "no reason"}`,
668
+ );
669
+ }
670
+ return { executed: true, action: "manual_review", success: true };
671
+
672
+ case "noop":
673
+ return { executed: true, action: "noop", success: true };
674
+
675
+ default:
676
+ return {
677
+ executed: false,
678
+ action: decision.action,
679
+ success: false,
680
+ error: `Unknown action: ${decision.action}`,
681
+ };
682
+ }
683
+ } catch (err) {
684
+ console.error(
685
+ `[${tag}] executeDecision threw unexpectedly: ${err.message}`,
686
+ );
687
+ return {
688
+ executed: false,
689
+ action: decision.action,
690
+ success: false,
691
+ error: err.message,
692
+ };
693
+ }
694
+ }
695
+
696
+ // ── Prompt (resume) action ──────────────────────────────────────────────────
697
+
698
+ /**
699
+ * Execute a "prompt" action: resume the original agent thread with the fix instruction.
700
+ *
701
+ * Flow:
702
+ * 1. Look up the original thread via taskKey in thread registry
703
+ * 2. If found and alive → resume that thread with the fix message (full context preserved)
704
+ * 3. If not found → launch a fresh thread with context-carrying preamble
705
+ * 4. Return the agent's response
706
+ */
707
+ async function executePromptAction(decision, ctx, execOpts) {
708
+ const { tag, taskKey, cwd, timeoutMs, logDir, onTelegram, promptTemplate } =
709
+ execOpts;
710
+
711
+ const fixMessage =
712
+ decision.message ||
713
+ decision.reason ||
714
+ "Please review and fix the remaining issues.";
715
+
716
+ // Check if original thread exists
717
+ const existingThread = getThreadRecord(taskKey);
718
+ const hasLiveThread = !!(
719
+ existingThread &&
720
+ existingThread.alive &&
721
+ existingThread.threadId
722
+ );
723
+
724
+ if (hasLiveThread) {
725
+ console.log(
726
+ `[${tag}] resuming original agent thread (${existingThread.sdk}, turn ${existingThread.turnCount + 1}) with fix: "${fixMessage.slice(0, 100)}..."`,
727
+ );
728
+ } else {
729
+ console.log(
730
+ `[${tag}] no existing thread for task "${taskKey}" — launching fresh with context`,
731
+ );
732
+ }
733
+
734
+ if (onTelegram) {
735
+ onTelegram(
736
+ `💬 ${hasLiveThread ? "Resuming" : "Starting"} agent for ${ctx.taskTitle || ctx.shortId}: ${fixMessage.slice(0, 200)}`,
737
+ );
738
+ }
739
+
740
+ // Build a rich prompt for the fix action
741
+ const fixPrompt = buildFixPrompt(
742
+ fixMessage,
743
+ ctx,
744
+ hasLiveThread,
745
+ promptTemplate,
746
+ );
747
+
748
+ try {
749
+ const result = await launchOrResumeThread(fixPrompt, cwd, timeoutMs, {
750
+ taskKey,
751
+ });
752
+
753
+ // Audit log
754
+ await auditDecisionExecution(logDir, ctx, decision, result);
755
+
756
+ if (result.success) {
757
+ console.log(
758
+ `[${tag}] ✅ agent ${result.resumed ? "resumed" : "launched"} successfully for fix`,
759
+ );
760
+ if (onTelegram) {
761
+ onTelegram(
762
+ `✅ Agent ${result.resumed ? "resumed" : "completed"} fix for ${ctx.taskTitle || ctx.shortId}`,
763
+ );
764
+ }
765
+ } else {
766
+ console.warn(`[${tag}] ❌ agent fix failed: ${result.error}`);
767
+ if (onTelegram) {
768
+ onTelegram(
769
+ `❌ Agent fix failed for ${ctx.taskTitle || ctx.shortId}: ${result.error?.slice(0, 200)}`,
770
+ );
771
+ }
772
+ }
773
+
774
+ return {
775
+ executed: true,
776
+ action: "prompt",
777
+ success: result.success,
778
+ output: result.output,
779
+ error: result.error,
780
+ resumed: result.resumed || false,
781
+ };
782
+ } catch (err) {
783
+ console.error(`[${tag}] executePromptAction threw: ${err.message}`);
784
+ return {
785
+ executed: true,
786
+ action: "prompt",
787
+ success: false,
788
+ error: err.message,
789
+ resumed: false,
790
+ };
791
+ }
792
+ }
793
+
794
+ /**
795
+ * Build a rich prompt for the agent to fix issues.
796
+ * If the thread is being resumed, the prompt is shorter (agent has context).
797
+ * If starting fresh, includes full task context.
798
+ */
799
+ function buildFixPrompt(fixMessage, ctx, isResume, promptTemplate = "") {
800
+ const parts = [];
801
+
802
+ if (isResume) {
803
+ // Resuming — agent already knows the task
804
+ parts.push(`# Fix Required\n`);
805
+ parts.push(`Your previous work on this task needs some fixes:\n`);
806
+ parts.push(fixMessage);
807
+ if (ctx.ciStatus === "failing") {
808
+ parts.push(
809
+ `\n\n**CI is currently failing.** Please fix CI issues before pushing.`,
810
+ );
811
+ }
812
+ parts.push(`\n\nAfter fixing, commit and push the changes.`);
813
+ } else {
814
+ // Fresh start — include full context
815
+ parts.push(`# Fix Required — Task Context\n`);
816
+ if (ctx.taskTitle) parts.push(`**Task:** ${ctx.taskTitle}`);
817
+ if (ctx.taskDescription)
818
+ parts.push(`**Description:** ${ctx.taskDescription.slice(0, 2000)}`);
819
+ if (ctx.branch) parts.push(`**Branch:** ${ctx.branch}`);
820
+ if (ctx.prNumber) parts.push(`**PR:** #${ctx.prNumber}`);
821
+ parts.push(`\n## What Needs Fixing\n`);
822
+ parts.push(fixMessage);
823
+ if (ctx.changedFiles?.length) {
824
+ parts.push(
825
+ `\n## Files Already Changed\n\`\`\`\n${ctx.changedFiles.slice(0, 30).join("\n")}\n\`\`\``,
826
+ );
827
+ }
828
+ if (ctx.ciStatus === "failing") {
829
+ parts.push(`\n**CI is currently failing.** Please fix CI issues.`);
830
+ }
831
+ parts.push(`\n\nFix the issues, then commit and push.`);
832
+ }
833
+
834
+ const fallback = parts.join("\n");
835
+ return resolvePromptTemplate(
836
+ promptTemplate,
837
+ {
838
+ FIX_MESSAGE: fixMessage,
839
+ TASK_CONTEXT_BLOCK: [
840
+ ctx.taskTitle ? `Task: ${ctx.taskTitle}` : "",
841
+ ctx.taskDescription
842
+ ? `Description: ${ctx.taskDescription.slice(0, 2000)}`
843
+ : "",
844
+ ctx.branch ? `Branch: ${ctx.branch}` : "",
845
+ ctx.prNumber ? `PR: #${ctx.prNumber}` : "",
846
+ ]
847
+ .filter(Boolean)
848
+ .join("\n"),
849
+ CI_STATUS_LINE:
850
+ ctx.ciStatus === "failing"
851
+ ? "CI is currently failing. Fix CI issues before pushing."
852
+ : "",
853
+ },
854
+ fallback,
855
+ );
856
+ }
857
+
858
+ // ── Re-attempt action ───────────────────────────────────────────────────────
859
+
860
+ /**
861
+ * Execute a "re_attempt" action: invalidate the old thread and start fresh with error recovery.
862
+ */
863
+ async function executeReAttemptAction(decision, ctx, execOpts) {
864
+ const {
865
+ tag,
866
+ taskKey,
867
+ cwd,
868
+ timeoutMs,
869
+ maxRetries,
870
+ logDir,
871
+ onTelegram,
872
+ promptTemplate,
873
+ } = execOpts;
874
+
875
+ const reason = decision.reason || "Previous attempt failed";
876
+
877
+ console.log(
878
+ `[${tag}] re-attempting task "${ctx.taskTitle || taskKey}" (max ${maxRetries} retries). Reason: ${reason}`,
879
+ );
880
+
881
+ // Invalidate the old thread so we start completely fresh
882
+ invalidateThread(taskKey);
883
+
884
+ if (onTelegram) {
885
+ onTelegram(
886
+ `🔄 Re-attempting task "${ctx.taskTitle || ctx.shortId}": ${reason.slice(0, 200)}`,
887
+ );
888
+ }
889
+
890
+ // Build the re-attempt prompt with full context
891
+ const reAttemptPrompt = buildReAttemptPrompt(ctx, reason, promptTemplate);
892
+
893
+ try {
894
+ const result = await execWithRetry(reAttemptPrompt, {
895
+ taskKey: `${taskKey}-reattempt`, // New key so it doesn't conflict with old thread
896
+ cwd,
897
+ timeoutMs,
898
+ maxRetries,
899
+ shouldRetry: (res) => !res.success, // Retry on any failure
900
+ buildRetryPrompt: (lastResult, attempt) =>
901
+ `# Retry ${attempt} — Previous Error\n\n\`\`\`\n${lastResult?.error || lastResult?.output?.slice(0, 500) || "unknown"}\n\`\`\`\n\nPlease fix the error and complete the task.\n\nOriginal task: ${ctx.taskTitle || "unknown"}\nBranch: ${ctx.branch || "unknown"}`,
902
+ });
903
+
904
+ await auditDecisionExecution(logDir, ctx, decision, result);
905
+
906
+ if (result.success) {
907
+ console.log(
908
+ `[${tag}] ✅ re-attempt succeeded after ${result.attempts} attempt(s)`,
909
+ );
910
+ if (onTelegram) {
911
+ onTelegram(
912
+ `✅ Re-attempt succeeded for "${ctx.taskTitle || ctx.shortId}" (${result.attempts} attempt(s))`,
913
+ );
914
+ }
915
+ } else {
916
+ console.warn(
917
+ `[${tag}] ❌ re-attempt failed after ${result.attempts} attempt(s): ${result.error}`,
918
+ );
919
+ if (onTelegram) {
920
+ onTelegram(
921
+ `❌ Re-attempt failed for "${ctx.taskTitle || ctx.shortId}" after ${result.attempts} attempt(s): ${result.error?.slice(0, 200)}`,
922
+ );
923
+ }
924
+ }
925
+
926
+ return {
927
+ executed: true,
928
+ action: "re_attempt",
929
+ success: result.success,
930
+ output: result.output,
931
+ error: result.error,
932
+ attempts: result.attempts,
933
+ resumed: false,
934
+ };
935
+ } catch (err) {
936
+ console.error(`[${tag}] executeReAttemptAction threw: ${err.message}`);
937
+ return {
938
+ executed: true,
939
+ action: "re_attempt",
940
+ success: false,
941
+ error: err.message,
942
+ attempts: 0,
943
+ resumed: false,
944
+ };
945
+ }
946
+ }
947
+
948
+ /**
949
+ * Build a full-context prompt for re-attempting a task from scratch.
950
+ */
951
+ function buildReAttemptPrompt(ctx, reason, promptTemplate = "") {
952
+ const parts = [];
953
+ parts.push(`# Task Re-Attempt\n`);
954
+ parts.push(
955
+ `A previous agent attempt at this task failed. Start fresh and complete the task.\n`,
956
+ );
957
+ parts.push(`**Failure reason:** ${reason}\n`);
958
+ if (ctx.taskTitle) parts.push(`**Task:** ${ctx.taskTitle}`);
959
+ if (ctx.taskDescription)
960
+ parts.push(`**Description:** ${ctx.taskDescription.slice(0, 3000)}`);
961
+ if (ctx.branch) parts.push(`**Branch:** ${ctx.branch}`);
962
+ if (ctx.prNumber)
963
+ parts.push(`**Existing PR:** #${ctx.prNumber} (may need amendment)`);
964
+ parts.push(`\nPlease implement the task fully, run tests, commit, and push.`);
965
+ const fallback = parts.join("\n");
966
+ return resolvePromptTemplate(
967
+ promptTemplate,
968
+ {
969
+ FAILURE_REASON: reason,
970
+ TASK_CONTEXT_BLOCK: [
971
+ ctx.taskTitle ? `Task: ${ctx.taskTitle}` : "",
972
+ ctx.taskDescription
973
+ ? `Description: ${ctx.taskDescription.slice(0, 3000)}`
974
+ : "",
975
+ ctx.branch ? `Branch: ${ctx.branch}` : "",
976
+ ctx.prNumber ? `Existing PR: #${ctx.prNumber}` : "",
977
+ ]
978
+ .filter(Boolean)
979
+ .join("\n"),
980
+ },
981
+ fallback,
982
+ );
983
+ }
984
+
985
+ // ── Merge action ────────────────────────────────────────────────────────────
986
+
987
+ /**
988
+ * Enable auto-merge for the PR via `gh pr merge --auto --squash`.
989
+ */
990
+ async function executeMergeAction(decision, ctx, execOpts) {
991
+ const { tag, onTelegram } = execOpts;
992
+
993
+ if (!ctx.prNumber) {
994
+ console.warn(`[${tag}] merge_after_ci_pass but no PR number`);
995
+ return {
996
+ executed: false,
997
+ action: "merge_after_ci_pass",
998
+ success: false,
999
+ error: "No PR number",
1000
+ };
1001
+ }
1002
+
1003
+ console.log(`[${tag}] enabling auto-merge for PR #${ctx.prNumber}`);
1004
+
1005
+ try {
1006
+ const result = execSync(`gh pr merge ${ctx.prNumber} --auto --squash`, {
1007
+ encoding: "utf8",
1008
+ timeout: 30000,
1009
+ stdio: ["ignore", "pipe", "pipe"],
1010
+ });
1011
+
1012
+ if (onTelegram) {
1013
+ onTelegram(
1014
+ `✅ Auto-merge enabled for PR #${ctx.prNumber} "${ctx.prTitle || ctx.taskTitle || ""}"`,
1015
+ );
1016
+ }
1017
+
1018
+ return {
1019
+ executed: true,
1020
+ action: "merge_after_ci_pass",
1021
+ success: true,
1022
+ output: result,
1023
+ };
1024
+ } catch (err) {
1025
+ // Auto-merge might already be enabled, or repo doesn't support it
1026
+ console.warn(`[${tag}] gh pr merge --auto failed: ${err.message}`);
1027
+
1028
+ if (onTelegram) {
1029
+ onTelegram(
1030
+ `⚠️ Auto-merge failed for PR #${ctx.prNumber}: ${err.message?.slice(0, 200)}. Will retry on next cycle.`,
1031
+ );
1032
+ }
1033
+
1034
+ return {
1035
+ executed: true,
1036
+ action: "merge_after_ci_pass",
1037
+ success: false,
1038
+ error: err.message,
1039
+ };
1040
+ }
1041
+ }
1042
+
1043
+ // ── Close PR action ─────────────────────────────────────────────────────────
1044
+
1045
+ /**
1046
+ * Close the PR via `gh pr close` with an explanatory comment.
1047
+ */
1048
+ async function executeCloseAction(decision, ctx, execOpts) {
1049
+ const { tag, onTelegram } = execOpts;
1050
+
1051
+ if (!ctx.prNumber) {
1052
+ return {
1053
+ executed: false,
1054
+ action: "close_pr",
1055
+ success: false,
1056
+ error: "No PR number",
1057
+ };
1058
+ }
1059
+
1060
+ console.log(
1061
+ `[${tag}] closing PR #${ctx.prNumber}: ${decision.reason || "no reason"}`,
1062
+ );
1063
+
1064
+ try {
1065
+ const comment = decision.reason
1066
+ ? `Closing: ${decision.reason}`
1067
+ : "Closing PR based on merge strategy analysis.";
1068
+
1069
+ execSync(
1070
+ `gh pr close ${ctx.prNumber} --comment "${comment.replace(/"/g, '\\"')}"`,
1071
+ {
1072
+ encoding: "utf8",
1073
+ timeout: 30000,
1074
+ stdio: ["ignore", "pipe", "pipe"],
1075
+ },
1076
+ );
1077
+
1078
+ if (onTelegram) {
1079
+ onTelegram(
1080
+ `🚫 Closed PR #${ctx.prNumber}: ${decision.reason || "strategy decision"}`,
1081
+ );
1082
+ }
1083
+
1084
+ return { executed: true, action: "close_pr", success: true };
1085
+ } catch (err) {
1086
+ console.warn(`[${tag}] close_pr failed: ${err.message}`);
1087
+ return {
1088
+ executed: true,
1089
+ action: "close_pr",
1090
+ success: false,
1091
+ error: err.message,
1092
+ };
1093
+ }
1094
+ }
1095
+
1096
+ // ── Audit helper ────────────────────────────────────────────────────────────
1097
+
1098
+ /**
1099
+ * Write an audit log for decision execution.
1100
+ */
1101
+ async function auditDecisionExecution(logDir, ctx, decision, result) {
1102
+ if (!logDir) return;
1103
+ try {
1104
+ await mkdir(resolve(logDir), { recursive: true });
1105
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
1106
+ const auditPath = resolve(logDir, `merge-exec-${ctx.shortId}-${stamp}.log`);
1107
+ await writeFile(
1108
+ auditPath,
1109
+ [
1110
+ `# Merge Decision Execution`,
1111
+ `# Attempt: ${ctx.attemptId}`,
1112
+ `# Action: ${decision.action}`,
1113
+ `# Task: ${ctx.taskTitle || "unknown"}`,
1114
+ `# Timestamp: ${new Date().toISOString()}`,
1115
+ "",
1116
+ "## Decision:",
1117
+ JSON.stringify(decision, null, 2),
1118
+ "",
1119
+ "## Execution Result:",
1120
+ JSON.stringify(
1121
+ {
1122
+ success: result?.success,
1123
+ resumed: result?.resumed,
1124
+ attempts: result?.attempts,
1125
+ error: result?.error,
1126
+ outputLength: result?.output?.length || 0,
1127
+ },
1128
+ null,
1129
+ 2,
1130
+ ),
1131
+ "",
1132
+ result?.output
1133
+ ? `## Agent Output:\n${result.output.slice(0, 5000)}`
1134
+ : "",
1135
+ ].join("\n"),
1136
+ "utf8",
1137
+ );
1138
+ } catch {
1139
+ /* audit best-effort */
1140
+ }
1141
+ }
1142
+
1143
+ // ── Convenience pipeline ────────────────────────────────────────────────────
1144
+
1145
+ /**
1146
+ * One-shot convenience: analyze + execute in a single call.
1147
+ * Useful for callers who want the full pipeline without manual orchestration.
1148
+ *
1149
+ * @param {MergeContext} ctx Task context
1150
+ * @param {object} opts Options passed to both analyze and execute
1151
+ * @returns {Promise<{ decision: MergeDecision, execution: ExecutionResult }>}
1152
+ */
1153
+ export async function analyzeAndExecute(ctx, opts = {}) {
1154
+ const decision = await analyzeMergeStrategy(ctx, opts);
1155
+
1156
+ // Skip execution for noop
1157
+ if (decision.action === "noop") {
1158
+ return {
1159
+ decision,
1160
+ execution: { executed: false, action: "noop", success: true },
1161
+ };
1162
+ }
1163
+
1164
+ const execution = await executeDecision(decision, ctx, opts);
1165
+
1166
+ return { decision, execution };
1167
+ }
1168
+
1169
+ // ── Exports ─────────────────────────────────────────────────────────────────
1170
+
1171
+ export { VALID_ACTIONS, extractActionJson, buildMergeStrategyPrompt };