@litmers/cursorflow-orchestrator 0.1.20 → 0.1.28

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 (224) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/commands/cursorflow-clean.md +19 -0
  3. package/commands/cursorflow-runs.md +59 -0
  4. package/commands/cursorflow-stop.md +55 -0
  5. package/dist/cli/clean.js +171 -0
  6. package/dist/cli/clean.js.map +1 -1
  7. package/dist/cli/index.js +7 -0
  8. package/dist/cli/index.js.map +1 -1
  9. package/dist/cli/init.js +1 -1
  10. package/dist/cli/init.js.map +1 -1
  11. package/dist/cli/logs.js +83 -42
  12. package/dist/cli/logs.js.map +1 -1
  13. package/dist/cli/monitor.d.ts +7 -0
  14. package/dist/cli/monitor.js +1007 -189
  15. package/dist/cli/monitor.js.map +1 -1
  16. package/dist/cli/prepare.js +87 -3
  17. package/dist/cli/prepare.js.map +1 -1
  18. package/dist/cli/resume.js +188 -236
  19. package/dist/cli/resume.js.map +1 -1
  20. package/dist/cli/run.js +125 -3
  21. package/dist/cli/run.js.map +1 -1
  22. package/dist/cli/runs.d.ts +5 -0
  23. package/dist/cli/runs.js +214 -0
  24. package/dist/cli/runs.js.map +1 -0
  25. package/dist/cli/setup-commands.js +0 -0
  26. package/dist/cli/signal.js +1 -1
  27. package/dist/cli/signal.js.map +1 -1
  28. package/dist/cli/stop.d.ts +5 -0
  29. package/dist/cli/stop.js +215 -0
  30. package/dist/cli/stop.js.map +1 -0
  31. package/dist/cli/tasks.d.ts +10 -0
  32. package/dist/cli/tasks.js +165 -0
  33. package/dist/cli/tasks.js.map +1 -0
  34. package/dist/core/auto-recovery.d.ts +212 -0
  35. package/dist/core/auto-recovery.js +737 -0
  36. package/dist/core/auto-recovery.js.map +1 -0
  37. package/dist/core/failure-policy.d.ts +156 -0
  38. package/dist/core/failure-policy.js +488 -0
  39. package/dist/core/failure-policy.js.map +1 -0
  40. package/dist/core/orchestrator.d.ts +15 -2
  41. package/dist/core/orchestrator.js +397 -15
  42. package/dist/core/orchestrator.js.map +1 -1
  43. package/dist/core/reviewer.d.ts +2 -0
  44. package/dist/core/reviewer.js +2 -0
  45. package/dist/core/reviewer.js.map +1 -1
  46. package/dist/core/runner.d.ts +33 -10
  47. package/dist/core/runner.js +321 -146
  48. package/dist/core/runner.js.map +1 -1
  49. package/dist/services/logging/buffer.d.ts +67 -0
  50. package/dist/services/logging/buffer.js +309 -0
  51. package/dist/services/logging/buffer.js.map +1 -0
  52. package/dist/services/logging/console.d.ts +89 -0
  53. package/dist/services/logging/console.js +169 -0
  54. package/dist/services/logging/console.js.map +1 -0
  55. package/dist/services/logging/file-writer.d.ts +71 -0
  56. package/dist/services/logging/file-writer.js +516 -0
  57. package/dist/services/logging/file-writer.js.map +1 -0
  58. package/dist/services/logging/formatter.d.ts +39 -0
  59. package/dist/services/logging/formatter.js +227 -0
  60. package/dist/services/logging/formatter.js.map +1 -0
  61. package/dist/services/logging/index.d.ts +11 -0
  62. package/dist/services/logging/index.js +30 -0
  63. package/dist/services/logging/index.js.map +1 -0
  64. package/dist/services/logging/parser.d.ts +31 -0
  65. package/dist/services/logging/parser.js +222 -0
  66. package/dist/services/logging/parser.js.map +1 -0
  67. package/dist/services/process/index.d.ts +59 -0
  68. package/dist/services/process/index.js +257 -0
  69. package/dist/services/process/index.js.map +1 -0
  70. package/dist/types/agent.d.ts +20 -0
  71. package/dist/types/agent.js +6 -0
  72. package/dist/types/agent.js.map +1 -0
  73. package/dist/types/config.d.ts +65 -0
  74. package/dist/types/config.js +6 -0
  75. package/dist/types/config.js.map +1 -0
  76. package/dist/types/events.d.ts +125 -0
  77. package/dist/types/events.js +6 -0
  78. package/dist/types/events.js.map +1 -0
  79. package/dist/types/index.d.ts +12 -0
  80. package/dist/types/index.js +37 -0
  81. package/dist/types/index.js.map +1 -0
  82. package/dist/types/lane.d.ts +43 -0
  83. package/dist/types/lane.js +6 -0
  84. package/dist/types/lane.js.map +1 -0
  85. package/dist/types/logging.d.ts +71 -0
  86. package/dist/types/logging.js +16 -0
  87. package/dist/types/logging.js.map +1 -0
  88. package/dist/types/review.d.ts +17 -0
  89. package/dist/types/review.js +6 -0
  90. package/dist/types/review.js.map +1 -0
  91. package/dist/types/run.d.ts +32 -0
  92. package/dist/types/run.js +6 -0
  93. package/dist/types/run.js.map +1 -0
  94. package/dist/types/task.d.ts +71 -0
  95. package/dist/types/task.js +6 -0
  96. package/dist/types/task.js.map +1 -0
  97. package/dist/ui/components.d.ts +134 -0
  98. package/dist/ui/components.js +389 -0
  99. package/dist/ui/components.js.map +1 -0
  100. package/dist/ui/log-viewer.d.ts +49 -0
  101. package/dist/ui/log-viewer.js +449 -0
  102. package/dist/ui/log-viewer.js.map +1 -0
  103. package/dist/utils/checkpoint.d.ts +87 -0
  104. package/dist/utils/checkpoint.js +317 -0
  105. package/dist/utils/checkpoint.js.map +1 -0
  106. package/dist/utils/config.d.ts +4 -0
  107. package/dist/utils/config.js +11 -2
  108. package/dist/utils/config.js.map +1 -1
  109. package/dist/utils/cursor-agent.js.map +1 -1
  110. package/dist/utils/dependency.d.ts +74 -0
  111. package/dist/utils/dependency.js +420 -0
  112. package/dist/utils/dependency.js.map +1 -0
  113. package/dist/utils/doctor.js +10 -5
  114. package/dist/utils/doctor.js.map +1 -1
  115. package/dist/utils/enhanced-logger.d.ts +10 -33
  116. package/dist/utils/enhanced-logger.js +94 -9
  117. package/dist/utils/enhanced-logger.js.map +1 -1
  118. package/dist/utils/git.d.ts +121 -0
  119. package/dist/utils/git.js +322 -2
  120. package/dist/utils/git.js.map +1 -1
  121. package/dist/utils/health.d.ts +91 -0
  122. package/dist/utils/health.js +556 -0
  123. package/dist/utils/health.js.map +1 -0
  124. package/dist/utils/lock.d.ts +95 -0
  125. package/dist/utils/lock.js +332 -0
  126. package/dist/utils/lock.js.map +1 -0
  127. package/dist/utils/log-buffer.d.ts +17 -0
  128. package/dist/utils/log-buffer.js +14 -0
  129. package/dist/utils/log-buffer.js.map +1 -0
  130. package/dist/utils/log-constants.d.ts +23 -0
  131. package/dist/utils/log-constants.js +28 -0
  132. package/dist/utils/log-constants.js.map +1 -0
  133. package/dist/utils/log-formatter.d.ts +9 -0
  134. package/dist/utils/log-formatter.js +113 -70
  135. package/dist/utils/log-formatter.js.map +1 -1
  136. package/dist/utils/log-service.d.ts +19 -0
  137. package/dist/utils/log-service.js +47 -0
  138. package/dist/utils/log-service.js.map +1 -0
  139. package/dist/utils/logger.d.ts +46 -27
  140. package/dist/utils/logger.js +82 -60
  141. package/dist/utils/logger.js.map +1 -1
  142. package/dist/utils/process-manager.d.ts +21 -0
  143. package/dist/utils/process-manager.js +138 -0
  144. package/dist/utils/process-manager.js.map +1 -0
  145. package/dist/utils/retry.d.ts +121 -0
  146. package/dist/utils/retry.js +374 -0
  147. package/dist/utils/retry.js.map +1 -0
  148. package/dist/utils/run-service.d.ts +88 -0
  149. package/dist/utils/run-service.js +412 -0
  150. package/dist/utils/run-service.js.map +1 -0
  151. package/dist/utils/state.d.ts +58 -2
  152. package/dist/utils/state.js +306 -3
  153. package/dist/utils/state.js.map +1 -1
  154. package/dist/utils/task-service.d.ts +82 -0
  155. package/dist/utils/task-service.js +348 -0
  156. package/dist/utils/task-service.js.map +1 -0
  157. package/dist/utils/types.d.ts +2 -272
  158. package/dist/utils/types.js +16 -0
  159. package/dist/utils/types.js.map +1 -1
  160. package/package.json +38 -23
  161. package/scripts/ai-security-check.js +0 -1
  162. package/scripts/local-security-gate.sh +0 -0
  163. package/scripts/monitor-lanes.sh +94 -0
  164. package/scripts/patches/test-cursor-agent.js +0 -1
  165. package/scripts/release.sh +0 -0
  166. package/scripts/setup-security.sh +0 -0
  167. package/scripts/stream-logs.sh +72 -0
  168. package/scripts/verify-and-fix.sh +0 -0
  169. package/src/cli/clean.ts +180 -0
  170. package/src/cli/index.ts +7 -0
  171. package/src/cli/init.ts +1 -1
  172. package/src/cli/logs.ts +79 -42
  173. package/src/cli/monitor.ts +1815 -899
  174. package/src/cli/prepare.ts +97 -3
  175. package/src/cli/resume.ts +220 -277
  176. package/src/cli/run.ts +154 -3
  177. package/src/cli/runs.ts +212 -0
  178. package/src/cli/setup-commands.ts +0 -0
  179. package/src/cli/signal.ts +1 -1
  180. package/src/cli/stop.ts +209 -0
  181. package/src/cli/tasks.ts +154 -0
  182. package/src/core/auto-recovery.ts +909 -0
  183. package/src/core/failure-policy.ts +592 -0
  184. package/src/core/orchestrator.ts +1136 -675
  185. package/src/core/reviewer.ts +4 -0
  186. package/src/core/runner.ts +1443 -1217
  187. package/src/services/logging/buffer.ts +326 -0
  188. package/src/services/logging/console.ts +193 -0
  189. package/src/services/logging/file-writer.ts +526 -0
  190. package/src/services/logging/formatter.ts +268 -0
  191. package/src/services/logging/index.ts +16 -0
  192. package/src/services/logging/parser.ts +232 -0
  193. package/src/services/process/index.ts +261 -0
  194. package/src/types/agent.ts +24 -0
  195. package/src/types/config.ts +79 -0
  196. package/src/types/events.ts +156 -0
  197. package/src/types/index.ts +29 -0
  198. package/src/types/lane.ts +56 -0
  199. package/src/types/logging.ts +96 -0
  200. package/src/types/review.ts +20 -0
  201. package/src/types/run.ts +37 -0
  202. package/src/types/task.ts +79 -0
  203. package/src/ui/components.ts +430 -0
  204. package/src/ui/log-viewer.ts +485 -0
  205. package/src/utils/checkpoint.ts +374 -0
  206. package/src/utils/config.ts +11 -2
  207. package/src/utils/cursor-agent.ts +1 -1
  208. package/src/utils/dependency.ts +482 -0
  209. package/src/utils/doctor.ts +11 -5
  210. package/src/utils/enhanced-logger.ts +108 -49
  211. package/src/utils/git.ts +871 -499
  212. package/src/utils/health.ts +596 -0
  213. package/src/utils/lock.ts +346 -0
  214. package/src/utils/log-buffer.ts +28 -0
  215. package/src/utils/log-constants.ts +26 -0
  216. package/src/utils/log-formatter.ts +120 -37
  217. package/src/utils/log-service.ts +49 -0
  218. package/src/utils/logger.ts +100 -51
  219. package/src/utils/process-manager.ts +100 -0
  220. package/src/utils/retry.ts +413 -0
  221. package/src/utils/run-service.ts +433 -0
  222. package/src/utils/state.ts +369 -3
  223. package/src/utils/task-service.ts +370 -0
  224. package/src/utils/types.ts +2 -315
@@ -1,1217 +1,1443 @@
1
- /**
2
- * Core Runner - Execute tasks sequentially in a lane
3
- *
4
- * Adapted from sequential-agent-runner.js
5
- */
6
-
7
- import * as fs from 'fs';
8
- import * as path from 'path';
9
- import { execSync, spawn, spawnSync } from 'child_process';
10
-
11
- import * as git from '../utils/git';
12
- import * as logger from '../utils/logger';
13
- import { ensureCursorAgent, checkCursorAuth, printAuthHelp } from '../utils/cursor-agent';
14
- import { saveState, appendLog, createConversationEntry } from '../utils/state';
15
- import { events } from '../utils/events';
16
- import { loadConfig } from '../utils/config';
17
- import { registerWebhooks } from '../utils/webhook';
18
- import { runReviewLoop } from './reviewer';
19
- import { safeJoin } from '../utils/path';
20
- import {
21
- RunnerConfig,
22
- Task,
23
- TaskExecutionResult,
24
- AgentSendResult,
25
- DependencyPolicy,
26
- DependencyRequestPlan,
27
- LaneState
28
- } from '../utils/types';
29
-
30
- /**
31
- * Execute cursor-agent command with timeout and better error handling
32
- */
33
- export function cursorAgentCreateChat(): string {
34
- try {
35
- const res = spawnSync('cursor-agent', ['create-chat'], {
36
- encoding: 'utf8',
37
- stdio: 'pipe',
38
- timeout: 30000, // 30 second timeout
39
- });
40
-
41
- if (res.error || res.status !== 0) {
42
- throw res.error || new Error(res.stderr || 'Failed to create chat');
43
- }
44
-
45
- const out = res.stdout;
46
- const lines = out.split('\n').filter(Boolean);
47
- const chatId = lines[lines.length - 1] || null;
48
-
49
- if (!chatId) {
50
- throw new Error('Failed to get chat ID from cursor-agent');
51
- }
52
-
53
- logger.info(`Created chat session: ${chatId}`);
54
- return chatId;
55
- } catch (error: any) {
56
- // Check for common errors
57
- if (error.message.includes('ENOENT')) {
58
- throw new Error('cursor-agent CLI not found. Install with: npm install -g @cursor/agent');
59
- }
60
-
61
- if (error.message.includes('ETIMEDOUT') || error.killed) {
62
- throw new Error('cursor-agent timed out. Check your internet connection and Cursor authentication.');
63
- }
64
-
65
- if (error.stderr) {
66
- const stderr = error.stderr.toString();
67
-
68
- // Check for authentication errors
69
- if (stderr.includes('not authenticated') ||
70
- stderr.includes('login') ||
71
- stderr.includes('auth')) {
72
- throw new Error(
73
- 'Cursor authentication failed. Please:\n' +
74
- ' 1. Open Cursor IDE\n' +
75
- ' 2. Sign in to your account\n' +
76
- ' 3. Verify you can use AI features\n' +
77
- ' 4. Try running cursorflow again\n\n' +
78
- `Original error: ${stderr.trim()}`
79
- );
80
- }
81
-
82
- // Check for API key errors
83
- if (stderr.includes('api key') || stderr.includes('API_KEY')) {
84
- throw new Error(
85
- 'Cursor API key error. Please check your Cursor account and subscription.\n' +
86
- `Error: ${stderr.trim()}`
87
- );
88
- }
89
-
90
- throw new Error(`cursor-agent error: ${stderr.trim()}`);
91
- }
92
-
93
- throw new Error(`Failed to create chat: ${error.message}`);
94
- }
95
- }
96
-
97
- function parseJsonFromStdout(stdout: string): any {
98
- const text = String(stdout || '').trim();
99
- if (!text) return null;
100
- const lines = text.split('\n').filter(Boolean);
101
-
102
- for (let i = lines.length - 1; i >= 0; i--) {
103
- const line = lines[i]?.trim();
104
- if (line?.startsWith('{') && line?.endsWith('}')) {
105
- try {
106
- return JSON.parse(line);
107
- } catch {
108
- continue;
109
- }
110
- }
111
- }
112
- return null;
113
- }
114
-
115
- /** Default timeout: 10 minutes */
116
- const DEFAULT_TIMEOUT_MS = 600000;
117
-
118
- /** Heartbeat interval: 30 seconds */
119
- const HEARTBEAT_INTERVAL_MS = 30000;
120
-
121
- /**
122
- * Validate task configuration
123
- * @throws Error if validation fails
124
- */
125
- export function validateTaskConfig(config: RunnerConfig): void {
126
- if (!config.tasks || !Array.isArray(config.tasks)) {
127
- throw new Error('Invalid config: "tasks" must be an array');
128
- }
129
-
130
- if (config.tasks.length === 0) {
131
- throw new Error('Invalid config: "tasks" array is empty');
132
- }
133
-
134
- for (let i = 0; i < config.tasks.length; i++) {
135
- const task = config.tasks[i];
136
- const taskNum = i + 1;
137
-
138
- if (!task) {
139
- throw new Error(`Invalid config: Task ${taskNum} is null or undefined`);
140
- }
141
-
142
- if (!task.name || typeof task.name !== 'string') {
143
- throw new Error(
144
- `Invalid config: Task ${taskNum} missing required "name" field.\n` +
145
- ` Found: ${JSON.stringify(task, null, 2).substring(0, 200)}...\n` +
146
- ` Expected: { "name": "task-name", "prompt": "..." }`
147
- );
148
- }
149
-
150
- if (!task.prompt || typeof task.prompt !== 'string') {
151
- throw new Error(
152
- `Invalid config: Task "${task.name}" (${taskNum}) missing required "prompt" field`
153
- );
154
- }
155
-
156
- // Validate task name format (no spaces, special chars that could break branch names)
157
- if (!/^[a-zA-Z0-9_-]+$/.test(task.name)) {
158
- throw new Error(
159
- `Invalid config: Task name "${task.name}" contains invalid characters.\n` +
160
- ` Task names must only contain: letters, numbers, underscore (_), hyphen (-)`
161
- );
162
- }
163
- }
164
-
165
- // Validate timeout if provided
166
- if (config.timeout !== undefined) {
167
- if (typeof config.timeout !== 'number' || config.timeout <= 0) {
168
- throw new Error(
169
- `Invalid config: "timeout" must be a positive number (milliseconds).\n` +
170
- ` Found: ${config.timeout}`
171
- );
172
- }
173
- }
174
- }
175
-
176
- /**
177
- * Execute cursor-agent command with streaming and better error handling
178
- */
179
- export async function cursorAgentSend({ workspaceDir, chatId, prompt, model, signalDir, timeout, enableIntervention, outputFormat }: {
180
- workspaceDir: string;
181
- chatId: string;
182
- prompt: string;
183
- model?: string;
184
- signalDir?: string;
185
- timeout?: number;
186
- /** Enable stdin piping for intervention feature (may cause buffering issues on some systems) */
187
- enableIntervention?: boolean;
188
- /** Output format for cursor-agent (default: 'stream-json') */
189
- outputFormat?: 'stream-json' | 'json' | 'plain';
190
- }): Promise<AgentSendResult> {
191
- // Use stream-json format for structured output with tool calls and results
192
- const format = outputFormat || 'stream-json';
193
- const args = [
194
- '--print',
195
- '--force',
196
- '--approve-mcps',
197
- '--output-format', format,
198
- '--workspace', workspaceDir,
199
- ...(model ? ['--model', model] : []),
200
- '--resume', chatId,
201
- prompt,
202
- ];
203
-
204
- const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
205
- logger.info(`Executing cursor-agent... (timeout: ${Math.round(timeoutMs / 1000)}s)`);
206
-
207
- // Determine stdio mode based on intervention setting
208
- // When intervention is enabled, we pipe stdin for message injection
209
- // When disabled (default), we ignore stdin to avoid buffering issues
210
- const stdinMode = enableIntervention ? 'pipe' : 'ignore';
211
-
212
- if (enableIntervention) {
213
- logger.info('Intervention mode enabled (stdin piped)');
214
- }
215
-
216
- return new Promise((resolve) => {
217
- // Build environment, preserving user's NODE_OPTIONS but disabling problematic flags
218
- const childEnv = { ...process.env };
219
-
220
- // Only filter out specific problematic NODE_OPTIONS, don't clear entirely
221
- if (childEnv.NODE_OPTIONS) {
222
- // Remove flags that might interfere with cursor-agent
223
- const filtered = childEnv.NODE_OPTIONS
224
- .split(' ')
225
- .filter(opt => !opt.includes('--inspect') && !opt.includes('--debug'))
226
- .join(' ');
227
- childEnv.NODE_OPTIONS = filtered;
228
- }
229
-
230
- // Disable Python buffering in case cursor-agent uses Python
231
- childEnv.PYTHONUNBUFFERED = '1';
232
-
233
- const child = spawn('cursor-agent', args, {
234
- stdio: [stdinMode, 'pipe', 'pipe'],
235
- env: childEnv,
236
- });
237
-
238
- logger.info(`Executing cursor-agent... (timeout: ${Math.round(timeoutMs / 1000)}s)`);
239
-
240
- // Save PID to state if possible (avoid TOCTOU by reading directly)
241
- if (child.pid && signalDir) {
242
- try {
243
- const statePath = safeJoin(signalDir, 'state.json');
244
- // Read directly without existence check to avoid race condition
245
- const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
246
- state.pid = child.pid;
247
- fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
248
- } catch {
249
- // Best effort - file may not exist yet
250
- }
251
- }
252
-
253
- let fullStdout = '';
254
- let fullStderr = '';
255
- let timeoutHandle: NodeJS.Timeout;
256
-
257
- // Heartbeat logging to show progress
258
- let lastHeartbeat = Date.now();
259
- let bytesReceived = 0;
260
- const heartbeatInterval = setInterval(() => {
261
- const elapsed = Math.round((Date.now() - lastHeartbeat) / 1000);
262
- const totalElapsed = Math.round((Date.now() - startTime) / 1000);
263
- logger.info(`⏱ Heartbeat: ${totalElapsed}s elapsed, ${bytesReceived} bytes received`);
264
- }, HEARTBEAT_INTERVAL_MS);
265
- const startTime = Date.now();
266
-
267
- // Watch for "intervention.txt" or "timeout.txt" signal files
268
- const interventionPath = signalDir ? path.join(signalDir, 'intervention.txt') : null;
269
- const timeoutPath = signalDir ? path.join(signalDir, 'timeout.txt') : null;
270
- let signalWatcher: fs.FSWatcher | null = null;
271
-
272
- if (signalDir && fs.existsSync(signalDir)) {
273
- signalWatcher = fs.watch(signalDir, (event, filename) => {
274
- // Handle intervention
275
- if (filename === 'intervention.txt' && interventionPath && fs.existsSync(interventionPath)) {
276
- try {
277
- const message = fs.readFileSync(interventionPath, 'utf8').trim();
278
- if (message) {
279
- if (enableIntervention && child.stdin) {
280
- logger.info(`Injecting intervention: ${message}`);
281
- child.stdin.write(message + '\n');
282
- } else {
283
- logger.warn(`Intervention requested but stdin not available: ${message}`);
284
- logger.warn('To enable intervention, set enableIntervention: true in config');
285
- }
286
- fs.unlinkSync(interventionPath); // Clear it
287
- }
288
- } catch (e) {
289
- logger.warn('Failed to read intervention file');
290
- }
291
- }
292
-
293
- // Handle dynamic timeout update
294
- if (filename === 'timeout.txt' && timeoutPath && fs.existsSync(timeoutPath)) {
295
- try {
296
- const newTimeoutStr = fs.readFileSync(timeoutPath, 'utf8').trim();
297
- const newTimeoutMs = parseInt(newTimeoutStr);
298
-
299
- if (!isNaN(newTimeoutMs) && newTimeoutMs > 0) {
300
- logger.info(`⏱ Dynamic timeout update: ${Math.round(newTimeoutMs / 1000)}s`);
301
-
302
- // Clear old timeout
303
- if (timeoutHandle) clearTimeout(timeoutHandle);
304
-
305
- // Set new timeout based on total elapsed time
306
- const elapsed = Date.now() - startTime;
307
- const remaining = Math.max(1000, newTimeoutMs - elapsed);
308
-
309
- timeoutHandle = setTimeout(() => {
310
- clearInterval(heartbeatInterval);
311
- child.kill();
312
- const totalSec = Math.round(newTimeoutMs / 1000);
313
- resolve({
314
- ok: false,
315
- exitCode: -1,
316
- error: `cursor-agent timed out after updated limit of ${totalSec} seconds.`,
317
- });
318
- }, remaining);
319
-
320
- fs.unlinkSync(timeoutPath); // Clear it
321
- }
322
- } catch (e) {
323
- logger.warn('Failed to read timeout update file');
324
- }
325
- }
326
- });
327
- }
328
-
329
- if (child.stdout) {
330
- child.stdout.on('data', (data) => {
331
- const str = data.toString();
332
- fullStdout += str;
333
- bytesReceived += data.length;
334
- // Also pipe to our own stdout so it goes to terminal.log
335
- process.stdout.write(data);
336
- });
337
- }
338
-
339
- if (child.stderr) {
340
- child.stderr.on('data', (data) => {
341
- fullStderr += data.toString();
342
- // Pipe to our own stderr so it goes to terminal.log
343
- process.stderr.write(data);
344
- });
345
- }
346
-
347
- timeoutHandle = setTimeout(() => {
348
- clearInterval(heartbeatInterval);
349
- child.kill();
350
- const timeoutSec = Math.round(timeoutMs / 1000);
351
- resolve({
352
- ok: false,
353
- exitCode: -1,
354
- error: `cursor-agent timed out after ${timeoutSec} seconds. The LLM request may be taking too long or there may be network issues.`,
355
- });
356
- }, timeoutMs);
357
-
358
- child.on('close', (code) => {
359
- clearTimeout(timeoutHandle);
360
- clearInterval(heartbeatInterval);
361
- if (signalWatcher) signalWatcher.close();
362
-
363
- const json = parseJsonFromStdout(fullStdout);
364
-
365
- if (code !== 0 || !json || json.type !== 'result') {
366
- let errorMsg = fullStderr.trim() || fullStdout.trim() || `exit=${code}`;
367
-
368
- // Check for common errors
369
- if (errorMsg.includes('not authenticated') || errorMsg.includes('login') || errorMsg.includes('auth')) {
370
- errorMsg = 'Authentication error. Please sign in to Cursor IDE.';
371
- } else if (errorMsg.includes('rate limit') || errorMsg.includes('quota')) {
372
- errorMsg = 'API rate limit or quota exceeded.';
373
- } else if (errorMsg.includes('model')) {
374
- errorMsg = `Model error (requested: ${model || 'default'}). Check your subscription.`;
375
- }
376
-
377
- resolve({
378
- ok: false,
379
- exitCode: code ?? -1,
380
- error: errorMsg,
381
- });
382
- } else {
383
- resolve({
384
- ok: !json.is_error,
385
- exitCode: code ?? 0,
386
- sessionId: json.session_id || chatId,
387
- resultText: json.result || '',
388
- });
389
- }
390
- });
391
-
392
- child.on('error', (err) => {
393
- clearTimeout(timeoutHandle);
394
- clearInterval(heartbeatInterval);
395
- resolve({
396
- ok: false,
397
- exitCode: -1,
398
- error: `Failed to start cursor-agent: ${err.message}`,
399
- });
400
- });
401
- });
402
- }
403
-
404
- /**
405
- * Extract dependency change request from agent response
406
- */
407
- export function extractDependencyRequest(text: string): { required: boolean; plan?: DependencyRequestPlan; raw: string } {
408
- const t = String(text || '');
409
- const marker = 'DEPENDENCY_CHANGE_REQUIRED';
410
-
411
- if (!t.includes(marker)) {
412
- return { required: false, raw: t };
413
- }
414
-
415
- const after = t.split(marker).slice(1).join(marker);
416
- const match = after.match(/\{[\s\S]*?\}/);
417
-
418
- if (match) {
419
- try {
420
- return {
421
- required: true,
422
- plan: JSON.parse(match[0]!) as DependencyRequestPlan,
423
- raw: t,
424
- };
425
- } catch {
426
- return { required: true, raw: t };
427
- }
428
- }
429
-
430
- return { required: true, raw: t };
431
- }
432
-
433
- /**
434
- * Wrap prompt with dependency policy
435
- */
436
- export function wrapPromptForDependencyPolicy(prompt: string, policy: DependencyPolicy, options: { noGit?: boolean } = {}): string {
437
- const { noGit = false } = options;
438
-
439
- if (policy.allowDependencyChange && !policy.lockfileReadOnly && !noGit) {
440
- return prompt;
441
- }
442
-
443
- let rules = '# Dependency Policy (MUST FOLLOW)\n\nYou are running in a restricted lane.\n\n';
444
-
445
- rules += `- allowDependencyChange: ${policy.allowDependencyChange}\n`;
446
- rules += `- lockfileReadOnly: ${policy.lockfileReadOnly}\n`;
447
-
448
- if (noGit) {
449
- rules += '- NO_GIT_MODE: Git is disabled. DO NOT run any git commands (commit, push, etc.). Just edit files.\n';
450
- }
451
-
452
- rules += '\nRules:\n';
453
- rules += '- BEFORE making any code changes, decide whether dependency changes are required.\n';
454
- rules += '- If dependency changes are required, DO NOT change any files. Instead reply with:\n\n';
455
- rules += 'DEPENDENCY_CHANGE_REQUIRED\n';
456
- rules += '```json\n{ "reason": "...", "changes": [...], "commands": ["pnpm add ..."], "notes": "..." }\n```\n\n';
457
- rules += 'Then STOP.\n';
458
- rules += '- If dependency changes are NOT required, proceed normally.\n';
459
-
460
- return `${rules}\n---\n\n${prompt}`;
461
- }
462
-
463
- /**
464
- * Apply file permissions based on dependency policy
465
- */
466
- export function applyDependencyFilePermissions(worktreeDir: string, policy: DependencyPolicy): void {
467
- const targets: string[] = [];
468
-
469
- if (!policy.allowDependencyChange) {
470
- targets.push('package.json');
471
- }
472
-
473
- if (policy.lockfileReadOnly) {
474
- targets.push('pnpm-lock.yaml', 'package-lock.json', 'yarn.lock');
475
- }
476
-
477
- for (const file of targets) {
478
- const filePath = safeJoin(worktreeDir, file);
479
- if (!fs.existsSync(filePath)) continue;
480
-
481
- try {
482
- const stats = fs.statSync(filePath);
483
- const mode = stats.mode & 0o777;
484
- fs.chmodSync(filePath, mode & ~0o222); // Remove write bits
485
- } catch {
486
- // Best effort
487
- }
488
- }
489
- }
490
-
491
- /**
492
- * Wait for task-level dependencies to be completed by other lanes
493
- */
494
- export async function waitForTaskDependencies(deps: string[], runDir: string): Promise<void> {
495
- if (!deps || deps.length === 0) return;
496
-
497
- const lanesRoot = path.dirname(runDir);
498
- const pendingDeps = new Set(deps);
499
-
500
- logger.info(`Waiting for task dependencies: ${deps.join(', ')}`);
501
-
502
- while (pendingDeps.size > 0) {
503
- for (const dep of pendingDeps) {
504
- const [laneName, taskName] = dep.split(':');
505
- if (!laneName || !taskName) {
506
- logger.warn(`Invalid dependency format: ${dep}. Expected "lane:task"`);
507
- pendingDeps.delete(dep);
508
- continue;
509
- }
510
-
511
- const depStatePath = safeJoin(lanesRoot, laneName, 'state.json');
512
- if (fs.existsSync(depStatePath)) {
513
- try {
514
- const state = JSON.parse(fs.readFileSync(depStatePath, 'utf8')) as LaneState;
515
- if (state.completedTasks && state.completedTasks.includes(taskName)) {
516
- logger.info(`✓ Dependency met: ${dep}`);
517
- pendingDeps.delete(dep);
518
- } else if (state.status === 'failed') {
519
- throw new Error(`Dependency failed: ${dep} (Lane ${laneName} failed)`);
520
- }
521
- } catch (e: any) {
522
- if (e.message.includes('Dependency failed')) throw e;
523
- // Ignore parse errors, file might be being written
524
- }
525
- }
526
- }
527
-
528
- if (pendingDeps.size > 0) {
529
- await new Promise(resolve => setTimeout(resolve, 5000)); // Poll every 5 seconds
530
- }
531
- }
532
- }
533
-
534
- /**
535
- * Merge branches from dependency lanes
536
- */
537
- export async function mergeDependencyBranches(deps: string[], runDir: string, worktreeDir: string): Promise<void> {
538
- if (!deps || deps.length === 0) return;
539
-
540
- const lanesRoot = path.dirname(runDir);
541
- const lanesToMerge = new Set(deps.map(d => d.split(':')[0]!));
542
-
543
- for (const laneName of lanesToMerge) {
544
- const depStatePath = safeJoin(lanesRoot, laneName, 'state.json');
545
- if (!fs.existsSync(depStatePath)) continue;
546
-
547
- try {
548
- const state = JSON.parse(fs.readFileSync(depStatePath, 'utf8')) as LaneState;
549
- if (state.pipelineBranch) {
550
- logger.info(`Merging branch from ${laneName}: ${state.pipelineBranch}`);
551
-
552
- // Ensure we have the latest
553
- git.runGit(['fetch', 'origin', state.pipelineBranch], { cwd: worktreeDir, silent: true });
554
-
555
- git.merge(state.pipelineBranch, {
556
- cwd: worktreeDir,
557
- noFf: true,
558
- message: `chore: merge task dependency from ${laneName}`
559
- });
560
- }
561
- } catch (e) {
562
- logger.error(`Failed to merge branch from ${laneName}: ${e}`);
563
- }
564
- }
565
- }
566
-
567
- /**
568
- * Run a single task
569
- */
570
- export async function runTask({
571
- task,
572
- config,
573
- index,
574
- worktreeDir,
575
- pipelineBranch,
576
- taskBranch,
577
- chatId,
578
- runDir,
579
- noGit = false,
580
- }: {
581
- task: Task;
582
- config: RunnerConfig;
583
- index: number;
584
- worktreeDir: string;
585
- pipelineBranch: string;
586
- taskBranch: string;
587
- chatId: string;
588
- runDir: string;
589
- noGit?: boolean;
590
- }): Promise<TaskExecutionResult> {
591
- const model = task.model || config.model || 'sonnet-4.5';
592
- const timeout = task.timeout || config.timeout;
593
- const convoPath = safeJoin(runDir, 'conversation.jsonl');
594
-
595
- logger.section(`[${index + 1}/${config.tasks.length}] ${task.name}`);
596
- logger.info(`Model: ${model}`);
597
- if (noGit) {
598
- logger.info('🚫 noGit mode: skipping branch operations');
599
- } else {
600
- logger.info(`Branch: ${taskBranch}`);
601
- }
602
-
603
- events.emit('task.started', {
604
- taskName: task.name,
605
- taskBranch,
606
- index,
607
- });
608
-
609
- // Checkout task branch (skip in noGit mode)
610
- if (!noGit) {
611
- git.runGit(['checkout', '-B', taskBranch], { cwd: worktreeDir });
612
- }
613
-
614
- // Apply dependency permissions
615
- applyDependencyFilePermissions(worktreeDir, config.dependencyPolicy);
616
-
617
- // Run prompt
618
- const prompt1 = wrapPromptForDependencyPolicy(task.prompt, config.dependencyPolicy, { noGit });
619
-
620
- appendLog(convoPath, createConversationEntry('user', prompt1, {
621
- task: task.name,
622
- model,
623
- }));
624
-
625
- logger.info('Sending prompt to agent...');
626
- const startTime = Date.now();
627
- events.emit('agent.prompt_sent', {
628
- taskName: task.name,
629
- model,
630
- promptLength: prompt1.length,
631
- });
632
-
633
- const r1 = await cursorAgentSend({
634
- workspaceDir: worktreeDir,
635
- chatId,
636
- prompt: prompt1,
637
- model,
638
- signalDir: runDir,
639
- timeout,
640
- enableIntervention: config.enableIntervention,
641
- outputFormat: config.agentOutputFormat,
642
- });
643
-
644
- const duration = Date.now() - startTime;
645
- events.emit('agent.response_received', {
646
- taskName: task.name,
647
- ok: r1.ok,
648
- duration,
649
- responseLength: r1.resultText?.length || 0,
650
- error: r1.error,
651
- });
652
-
653
- appendLog(convoPath, createConversationEntry('assistant', r1.resultText || r1.error || 'No response', {
654
- task: task.name,
655
- model,
656
- }));
657
-
658
- if (!r1.ok) {
659
- events.emit('task.failed', {
660
- taskName: task.name,
661
- taskBranch,
662
- error: r1.error,
663
- });
664
- return {
665
- taskName: task.name,
666
- taskBranch,
667
- status: 'ERROR',
668
- error: r1.error,
669
- };
670
- }
671
-
672
- // Check for dependency request
673
- const depReq = extractDependencyRequest(r1.resultText || '');
674
- if (depReq.required && !config.dependencyPolicy.allowDependencyChange) {
675
- return {
676
- taskName: task.name,
677
- taskBranch,
678
- status: 'BLOCKED_DEPENDENCY',
679
- dependencyRequest: depReq.plan || null,
680
- };
681
- }
682
-
683
- // Push task branch (skip in noGit mode)
684
- if (!noGit) {
685
- git.push(taskBranch, { cwd: worktreeDir, setUpstream: true });
686
- }
687
-
688
- // Automatic Review
689
- const reviewEnabled = config.reviewAllTasks || task.acceptanceCriteria?.length || config.enableReview;
690
-
691
- if (reviewEnabled) {
692
- logger.section(`🔍 Reviewing Task: ${task.name}`);
693
- const reviewResult = await runReviewLoop({
694
- taskResult: {
695
- taskName: task.name,
696
- taskBranch: taskBranch,
697
- acceptanceCriteria: task.acceptanceCriteria,
698
- },
699
- worktreeDir,
700
- runDir,
701
- config,
702
- workChatId: chatId,
703
- model, // Use the same model as requested
704
- cursorAgentSend,
705
- cursorAgentCreateChat,
706
- });
707
-
708
- if (!reviewResult.approved) {
709
- logger.error(`❌ Task review failed after ${reviewResult.iterations} iterations`);
710
- return {
711
- taskName: task.name,
712
- taskBranch,
713
- status: 'ERROR',
714
- error: reviewResult.error || 'Task failed to pass review criteria',
715
- };
716
- }
717
- }
718
-
719
- events.emit('task.completed', {
720
- taskName: task.name,
721
- taskBranch,
722
- status: 'FINISHED',
723
- });
724
-
725
- return {
726
- taskName: task.name,
727
- taskBranch,
728
- status: 'FINISHED',
729
- };
730
- }
731
-
732
- /**
733
- * Run all tasks in sequence
734
- */
735
- export async function runTasks(tasksFile: string, config: RunnerConfig, runDir: string, options: { startIndex?: number; noGit?: boolean } = {}): Promise<TaskExecutionResult[]> {
736
- const startIndex = options.startIndex || 0;
737
- const noGit = options.noGit || config.noGit || false;
738
-
739
- if (noGit) {
740
- logger.info('🚫 Running in noGit mode - Git operations will be skipped');
741
- }
742
-
743
- // Validate configuration before starting
744
- logger.info('Validating task configuration...');
745
- try {
746
- validateTaskConfig(config);
747
- logger.success(' Configuration valid');
748
- } catch (validationError: any) {
749
- logger.error('❌ Configuration validation failed');
750
- logger.error(` ${validationError.message}`);
751
- throw validationError;
752
- }
753
-
754
- // Ensure cursor-agent is installed
755
- ensureCursorAgent();
756
-
757
- // Check authentication before starting
758
- logger.info('Checking Cursor authentication...');
759
- const authStatus = checkCursorAuth();
760
-
761
- if (!authStatus.authenticated) {
762
- logger.error('❌ Cursor authentication failed');
763
- logger.error(` ${authStatus.message}`);
764
-
765
- if (authStatus.details) {
766
- logger.error(` Details: ${authStatus.details}`);
767
- }
768
-
769
- if (authStatus.help) {
770
- logger.error(` ${authStatus.help}`);
771
- }
772
-
773
- console.log('');
774
- printAuthHelp();
775
-
776
- throw new Error('Cursor authentication required. Please authenticate and try again.');
777
- }
778
-
779
- logger.success('✓ Cursor authentication OK');
780
-
781
- // In noGit mode, we don't need repoRoot - use current directory
782
- const repoRoot = noGit ? process.cwd() : git.getRepoRoot();
783
-
784
- // Load existing state if resuming
785
- const statePath = safeJoin(runDir, 'state.json');
786
- let state: LaneState | null = null;
787
-
788
- if (fs.existsSync(statePath)) {
789
- try {
790
- state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
791
- } catch (e) {
792
- logger.warn(`Failed to load existing state from ${statePath}: ${e}`);
793
- }
794
- }
795
-
796
- const randomSuffix = Math.random().toString(36).substring(2, 7);
797
- const pipelineBranch = state?.pipelineBranch || config.pipelineBranch || `${config.branchPrefix || 'cursorflow/'}${Date.now().toString(36)}-${randomSuffix}`;
798
-
799
- // In noGit mode, use a simple local directory instead of worktree
800
- // Flatten the path by replacing slashes with hyphens to avoid race conditions in parent directory creation
801
- const worktreeDir = state?.worktreeDir || config.worktreeDir || (noGit
802
- ? safeJoin(repoRoot, config.worktreeRoot || '_cursorflow/workdir', pipelineBranch.replace(/\//g, '-'))
803
- : safeJoin(repoRoot, config.worktreeRoot || '_cursorflow/worktrees', pipelineBranch.replace(/\//g, '-')));
804
-
805
- if (startIndex === 0) {
806
- logger.section('🚀 Starting Pipeline');
807
- } else {
808
- logger.section(`🔁 Resuming Pipeline from task ${startIndex + 1}`);
809
- }
810
-
811
- logger.info(`Pipeline Branch: ${pipelineBranch}`);
812
- logger.info(`Worktree: ${worktreeDir}`);
813
- logger.info(`Tasks: ${config.tasks.length}`);
814
-
815
- // Create worktree only if starting fresh and worktree doesn't exist
816
- if (!fs.existsSync(worktreeDir)) {
817
- if (noGit) {
818
- // In noGit mode, just create the directory
819
- logger.info(`Creating work directory: ${worktreeDir}`);
820
- fs.mkdirSync(worktreeDir, { recursive: true });
821
- } else {
822
- // Use a simple retry mechanism for Git worktree creation to handle potential race conditions
823
- let retries = 3;
824
- let lastError: Error | null = null;
825
-
826
- while (retries > 0) {
827
- try {
828
- // Ensure parent directory exists before calling git worktree
829
- const worktreeParent = path.dirname(worktreeDir);
830
- if (!fs.existsSync(worktreeParent)) {
831
- fs.mkdirSync(worktreeParent, { recursive: true });
832
- }
833
-
834
- git.createWorktree(worktreeDir, pipelineBranch, {
835
- baseBranch: config.baseBranch || 'main',
836
- cwd: repoRoot,
837
- });
838
- break; // Success
839
- } catch (e: any) {
840
- lastError = e;
841
- retries--;
842
- if (retries > 0) {
843
- const delay = Math.floor(Math.random() * 1000) + 500;
844
- logger.warn(`Worktree creation failed, retrying in ${delay}ms... (${retries} retries left)`);
845
- await new Promise(resolve => setTimeout(resolve, delay));
846
- }
847
- }
848
- }
849
-
850
- if (retries === 0 && lastError) {
851
- throw new Error(`Failed to create Git worktree after retries: ${lastError.message}`);
852
- }
853
- }
854
- } else if (!noGit) {
855
- // If it exists but we are in Git mode, ensure it's actually a worktree and on the right branch
856
- logger.info(`Reusing existing worktree: ${worktreeDir}`);
857
- try {
858
- git.runGit(['checkout', pipelineBranch], { cwd: worktreeDir });
859
- } catch (e) {
860
- // If checkout fails, maybe the worktree is in a weird state.
861
- // For now, just log it. In a more robust impl, we might want to repair it.
862
- logger.warn(`Failed to checkout branch ${pipelineBranch} in existing worktree: ${e}`);
863
- }
864
- }
865
-
866
- // Create chat
867
- logger.info('Creating chat session...');
868
- const chatId = cursorAgentCreateChat();
869
-
870
- // Initialize state if not loaded
871
- if (!state) {
872
- state = {
873
- status: 'running',
874
- pipelineBranch,
875
- worktreeDir,
876
- totalTasks: config.tasks.length,
877
- currentTaskIndex: 0,
878
- label: pipelineBranch,
879
- startTime: Date.now(),
880
- endTime: null,
881
- error: null,
882
- dependencyRequest: null,
883
- tasksFile, // Store tasks file for resume
884
- dependsOn: config.dependsOn || [],
885
- completedTasks: [],
886
- };
887
- } else {
888
- state.status = 'running';
889
- state.error = null;
890
- state.dependencyRequest = null;
891
- state.pipelineBranch = pipelineBranch;
892
- state.worktreeDir = worktreeDir;
893
- state.label = state.label || pipelineBranch;
894
- state.dependsOn = config.dependsOn || [];
895
- state.completedTasks = state.completedTasks || [];
896
- }
897
-
898
- saveState(statePath, state);
899
-
900
- // Merge dependencies if any (skip in noGit mode)
901
- if (!noGit && startIndex === 0 && config.dependsOn && config.dependsOn.length > 0) {
902
- logger.section('🔗 Merging Dependencies');
903
-
904
- // The runDir for the lane is passed in. Dependencies are in ../<depName> relative to this runDir
905
- const lanesRoot = path.dirname(runDir);
906
-
907
- for (const depName of config.dependsOn) {
908
- const depRunDir = path.join(lanesRoot, depName); // nosemgrep
909
- const depStatePath = path.join(depRunDir, 'state.json'); // nosemgrep
910
-
911
- if (!fs.existsSync(depStatePath)) {
912
- logger.warn(`Dependency state not found for ${depName} at ${depStatePath}`);
913
- continue;
914
- }
915
-
916
- try {
917
- const depState = JSON.parse(fs.readFileSync(depStatePath, 'utf8')) as LaneState;
918
- if (depState.status !== 'completed') {
919
- logger.warn(`Dependency ${depName} is in status ${depState.status}, merge might be incomplete`);
920
- }
921
-
922
- if (depState.pipelineBranch) {
923
- logger.info(`Merging dependency branch: ${depState.pipelineBranch} (${depName})`);
924
-
925
- // Fetch first to ensure we have the branch
926
- git.runGit(['fetch', 'origin', depState.pipelineBranch], { cwd: worktreeDir, silent: true });
927
-
928
- // Merge
929
- git.merge(depState.pipelineBranch, {
930
- cwd: worktreeDir,
931
- noFf: true,
932
- message: `chore: merge dependency ${depName} (${depState.pipelineBranch})`
933
- });
934
-
935
- // Log changed files
936
- const stats = git.getLastOperationStats(worktreeDir);
937
- if (stats) {
938
- logger.info('Changed files:\n' + stats);
939
- }
940
- }
941
- } catch (e) {
942
- logger.error(`Failed to merge dependency ${depName}: ${e}`);
943
- }
944
- }
945
-
946
- // Push the merged state
947
- git.push(pipelineBranch, { cwd: worktreeDir });
948
- } else if (noGit && startIndex === 0 && config.dependsOn && config.dependsOn.length > 0) {
949
- logger.info('⚠️ Dependencies specified but Git is disabled - copying files instead of merging');
950
-
951
- // The runDir for the lane is passed in. Dependencies are in ../<depName> relative to this runDir
952
- const lanesRoot = path.dirname(runDir);
953
-
954
- for (const depName of config.dependsOn) {
955
- const depRunDir = safeJoin(lanesRoot, depName);
956
- const depStatePath = safeJoin(depRunDir, 'state.json');
957
-
958
- if (!fs.existsSync(depStatePath)) {
959
- continue;
960
- }
961
-
962
- try {
963
- const depState = JSON.parse(fs.readFileSync(depStatePath, 'utf8')) as LaneState;
964
- if (depState.worktreeDir && fs.existsSync(depState.worktreeDir)) {
965
- logger.info(`Copying files from dependency ${depName}: ${depState.worktreeDir} → ${worktreeDir}`);
966
-
967
- // Use a simple recursive copy (excluding Git and internal dirs)
968
- const copyFiles = (src: string, dest: string) => {
969
- if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
970
- const entries = fs.readdirSync(src, { withFileTypes: true });
971
-
972
- for (const entry of entries) {
973
- if (entry.name === '.git' || entry.name === '_cursorflow' || entry.name === 'node_modules') continue;
974
-
975
- const srcPath = safeJoin(src, entry.name);
976
- const destPath = safeJoin(dest, entry.name);
977
-
978
- if (entry.isDirectory()) {
979
- copyFiles(srcPath, destPath);
980
- } else {
981
- fs.copyFileSync(srcPath, destPath);
982
- }
983
- }
984
- };
985
-
986
- copyFiles(depState.worktreeDir, worktreeDir);
987
- }
988
- } catch (e) {
989
- logger.error(`Failed to copy dependency ${depName}: ${e}`);
990
- }
991
- }
992
- }
993
-
994
- // Run tasks
995
- const results: TaskExecutionResult[] = [];
996
-
997
- for (let i = startIndex; i < config.tasks.length; i++) {
998
- const task = config.tasks[i]!;
999
- const taskBranch = `${pipelineBranch}--${String(i + 1).padStart(2, '0')}-${task.name}`;
1000
-
1001
- // Handle task-level dependencies
1002
- if (task.dependsOn && task.dependsOn.length > 0) {
1003
- state.status = 'waiting';
1004
- state.waitingFor = task.dependsOn;
1005
- saveState(statePath, state);
1006
-
1007
- try {
1008
- await waitForTaskDependencies(task.dependsOn, runDir);
1009
-
1010
- if (!noGit) {
1011
- await mergeDependencyBranches(task.dependsOn, runDir, worktreeDir);
1012
- }
1013
-
1014
- state.status = 'running';
1015
- state.waitingFor = [];
1016
- saveState(statePath, state);
1017
- } catch (e: any) {
1018
- state.status = 'failed';
1019
- state.waitingFor = [];
1020
- state.error = e.message;
1021
- saveState(statePath, state);
1022
- logger.error(`Task dependency wait/merge failed: ${e.message}`);
1023
- process.exit(1);
1024
- }
1025
- }
1026
-
1027
- const result = await runTask({
1028
- task,
1029
- config,
1030
- index: i,
1031
- worktreeDir,
1032
- pipelineBranch,
1033
- taskBranch,
1034
- chatId,
1035
- runDir,
1036
- noGit,
1037
- });
1038
-
1039
- results.push(result);
1040
-
1041
- // Update state
1042
- state.currentTaskIndex = i + 1;
1043
- state.completedTasks = state.completedTasks || [];
1044
- if (!state.completedTasks.includes(task.name)) {
1045
- state.completedTasks.push(task.name);
1046
- }
1047
- saveState(statePath, state);
1048
-
1049
- // Handle blocked or error
1050
- if (result.status === 'BLOCKED_DEPENDENCY') {
1051
- state.status = 'failed';
1052
- state.dependencyRequest = result.dependencyRequest || null;
1053
- saveState(statePath, state);
1054
-
1055
- if (result.dependencyRequest) {
1056
- events.emit('lane.dependency_requested', {
1057
- laneName: state.label,
1058
- dependencyRequest: result.dependencyRequest,
1059
- });
1060
- }
1061
-
1062
- logger.warn('Task blocked on dependency change');
1063
- process.exit(2);
1064
- }
1065
-
1066
- if (result.status !== 'FINISHED') {
1067
- state.status = 'failed';
1068
- state.error = result.error || 'Unknown error';
1069
- saveState(statePath, state);
1070
- logger.error(`Task failed: ${result.error}`);
1071
- process.exit(1);
1072
- }
1073
-
1074
- // Merge into pipeline (skip in noGit mode)
1075
- if (!noGit) {
1076
- logger.info(`Merging ${taskBranch} → ${pipelineBranch}`);
1077
- git.merge(taskBranch, { cwd: worktreeDir, noFf: true });
1078
-
1079
- // Log changed files
1080
- const stats = git.getLastOperationStats(worktreeDir);
1081
- if (stats) {
1082
- logger.info('Changed files:\n' + stats);
1083
- }
1084
-
1085
- git.push(pipelineBranch, { cwd: worktreeDir });
1086
- } else {
1087
- logger.info(`✓ Task ${task.name} completed (noGit mode - no branch operations)`);
1088
- }
1089
- }
1090
-
1091
- // Complete
1092
- state.status = 'completed';
1093
- state.endTime = Date.now();
1094
- saveState(statePath, state);
1095
-
1096
- // Log final file summary
1097
- if (noGit) {
1098
- const getFileSummary = (dir: string): { files: number; dirs: number } => {
1099
- let stats = { files: 0, dirs: 0 };
1100
- if (!fs.existsSync(dir)) return stats;
1101
-
1102
- const entries = fs.readdirSync(dir, { withFileTypes: true });
1103
- for (const entry of entries) {
1104
- if (entry.name === '.git' || entry.name === '_cursorflow' || entry.name === 'node_modules') continue;
1105
-
1106
- if (entry.isDirectory()) {
1107
- stats.dirs++;
1108
- const sub = getFileSummary(safeJoin(dir, entry.name));
1109
- stats.files += sub.files;
1110
- stats.dirs += sub.dirs;
1111
- } else {
1112
- stats.files++;
1113
- }
1114
- }
1115
- return stats;
1116
- };
1117
-
1118
- const summary = getFileSummary(worktreeDir);
1119
- logger.info(`Final Workspace Summary (noGit): ${summary.files} files, ${summary.dirs} directories created/modified`);
1120
- } else {
1121
- try {
1122
- const stats = git.runGit(['diff', '--stat', config.baseBranch || 'main', pipelineBranch], { cwd: repoRoot, silent: true });
1123
- if (stats) {
1124
- logger.info('Final Workspace Summary (Git):\n' + stats);
1125
- }
1126
- } catch (e) {
1127
- // Ignore
1128
- }
1129
- }
1130
-
1131
- logger.success('All tasks completed!');
1132
- return results;
1133
- }
1134
-
1135
- /**
1136
- * CLI entry point
1137
- */
1138
- if (require.main === module) {
1139
- const args = process.argv.slice(2);
1140
-
1141
- if (args.length < 1) {
1142
- console.error('Usage: node runner.js <tasks-file> --run-dir <dir> --executor <executor>');
1143
- process.exit(1);
1144
- }
1145
-
1146
- const tasksFile = args[0]!;
1147
- const runDirIdx = args.indexOf('--run-dir');
1148
- const startIdxIdx = args.indexOf('--start-index');
1149
- const pipelineBranchIdx = args.indexOf('--pipeline-branch');
1150
- const worktreeDirIdx = args.indexOf('--worktree-dir');
1151
- const noGit = args.includes('--no-git');
1152
-
1153
- const runDir = runDirIdx >= 0 ? args[runDirIdx + 1]! : '.';
1154
- const startIndex = startIdxIdx >= 0 ? parseInt(args[startIdxIdx + 1] || '0') : 0;
1155
- const forcedPipelineBranch = pipelineBranchIdx >= 0 ? args[pipelineBranchIdx + 1] : null;
1156
- const forcedWorktreeDir = worktreeDirIdx >= 0 ? args[worktreeDirIdx + 1] : null;
1157
-
1158
- // Extract runId from runDir (format: .../runs/run-123/lanes/lane-name)
1159
- const parts = runDir.split(path.sep);
1160
- const runsIdx = parts.lastIndexOf('runs');
1161
- const runId = runsIdx >= 0 && parts[runsIdx + 1] ? parts[runsIdx + 1]! : `run-${Date.now()}`;
1162
-
1163
- events.setRunId(runId);
1164
-
1165
- // Load global config for defaults and webhooks
1166
- let globalConfig;
1167
- try {
1168
- globalConfig = loadConfig();
1169
- if (globalConfig.webhooks) {
1170
- registerWebhooks(globalConfig.webhooks);
1171
- }
1172
- } catch (e) {
1173
- // Non-blocking
1174
- }
1175
-
1176
- if (!fs.existsSync(tasksFile)) {
1177
- console.error(`Tasks file not found: ${tasksFile}`);
1178
- process.exit(1);
1179
- }
1180
-
1181
- // Load tasks configuration
1182
- let config: RunnerConfig;
1183
- try {
1184
- config = JSON.parse(fs.readFileSync(tasksFile, 'utf8')) as RunnerConfig;
1185
- if (forcedPipelineBranch) {
1186
- config.pipelineBranch = forcedPipelineBranch;
1187
- }
1188
- if (forcedWorktreeDir) {
1189
- config.worktreeDir = forcedWorktreeDir;
1190
- }
1191
- } catch (error: any) {
1192
- console.error(`Failed to load tasks file: ${error.message}`);
1193
- process.exit(1);
1194
- }
1195
-
1196
- // Add defaults from global config or hardcoded
1197
- config.dependencyPolicy = config.dependencyPolicy || {
1198
- allowDependencyChange: globalConfig?.allowDependencyChange ?? false,
1199
- lockfileReadOnly: globalConfig?.lockfileReadOnly ?? true,
1200
- };
1201
-
1202
- // Add agent output format default
1203
- config.agentOutputFormat = config.agentOutputFormat || globalConfig?.agentOutputFormat || 'stream-json';
1204
-
1205
- // Run tasks
1206
- runTasks(tasksFile, config, runDir, { startIndex, noGit })
1207
- .then(() => {
1208
- process.exit(0);
1209
- })
1210
- .catch(error => {
1211
- console.error(`Runner failed: ${error.message}`);
1212
- if (process.env['DEBUG']) {
1213
- console.error(error.stack);
1214
- }
1215
- process.exit(1);
1216
- });
1217
- }
1
+ /**
2
+ * Core Runner - Execute tasks sequentially in a lane
3
+ *
4
+ * Features:
5
+ * - Enhanced retry with circuit breaker
6
+ * - Checkpoint system for recovery
7
+ * - State validation and repair
8
+ * - Improved dependency management
9
+ */
10
+
11
+ import * as fs from 'fs';
12
+ import * as path from 'path';
13
+ import { spawn, spawnSync } from 'child_process';
14
+
15
+ import * as git from '../utils/git';
16
+ import * as logger from '../utils/logger';
17
+ import { ensureCursorAgent, checkCursorAuth, printAuthHelp } from '../utils/cursor-agent';
18
+ import { saveState, appendLog, createConversationEntry, loadState, validateLaneState, repairLaneState, stateNeedsRecovery } from '../utils/state';
19
+ import { events } from '../utils/events';
20
+ import { loadConfig } from '../utils/config';
21
+ import { registerWebhooks } from '../utils/webhook';
22
+ import { runReviewLoop } from './reviewer';
23
+ import { safeJoin } from '../utils/path';
24
+ import { analyzeFailure, RecoveryAction, logFailure, withRetry } from './failure-policy';
25
+ import { createCheckpoint, getLatestCheckpoint, restoreFromCheckpoint } from '../utils/checkpoint';
26
+ import { waitForTaskDependencies as waitForDeps, DependencyWaitOptions } from '../utils/dependency';
27
+ import { preflightCheck, printPreflightReport } from '../utils/health';
28
+ import {
29
+ RunnerConfig,
30
+ Task,
31
+ TaskExecutionResult,
32
+ AgentSendResult,
33
+ DependencyPolicy,
34
+ DependencyRequestPlan,
35
+ LaneState
36
+ } from '../types';
37
+
38
+ /**
39
+ * Execute cursor-agent command with timeout and better error handling
40
+ */
41
+ export function cursorAgentCreateChat(): string {
42
+ try {
43
+ const res = spawnSync('cursor-agent', ['create-chat'], {
44
+ encoding: 'utf8',
45
+ stdio: 'pipe',
46
+ timeout: 30000, // 30 second timeout
47
+ });
48
+
49
+ if (res.error || res.status !== 0) {
50
+ throw res.error || new Error(res.stderr || 'Failed to create chat');
51
+ }
52
+
53
+ const out = res.stdout;
54
+ const lines = out.split('\n').filter(Boolean);
55
+ const chatId = lines[lines.length - 1] || null;
56
+
57
+ if (!chatId) {
58
+ throw new Error('Failed to get chat ID from cursor-agent');
59
+ }
60
+
61
+ logger.info(`Created chat session: ${chatId}`);
62
+ return chatId;
63
+ } catch (error: any) {
64
+ // Check for common errors
65
+ if (error.message.includes('ENOENT')) {
66
+ throw new Error('cursor-agent CLI not found. Install with: npm install -g @cursor/agent');
67
+ }
68
+
69
+ if (error.message.includes('ETIMEDOUT') || error.killed) {
70
+ throw new Error('cursor-agent timed out. Check your internet connection and Cursor authentication.');
71
+ }
72
+
73
+ if (error.stderr) {
74
+ const stderr = error.stderr.toString();
75
+
76
+ // Check for authentication errors
77
+ if (stderr.includes('not authenticated') ||
78
+ stderr.includes('login') ||
79
+ stderr.includes('auth')) {
80
+ throw new Error(
81
+ 'Cursor authentication failed. Please:\n' +
82
+ ' 1. Open Cursor IDE\n' +
83
+ ' 2. Sign in to your account\n' +
84
+ ' 3. Verify you can use AI features\n' +
85
+ ' 4. Try running cursorflow again\n\n' +
86
+ `Original error: ${stderr.trim()}`
87
+ );
88
+ }
89
+
90
+ // Check for API key errors
91
+ if (stderr.includes('api key') || stderr.includes('API_KEY')) {
92
+ throw new Error(
93
+ 'Cursor API key error. Please check your Cursor account and subscription.\n' +
94
+ `Error: ${stderr.trim()}`
95
+ );
96
+ }
97
+
98
+ throw new Error(`cursor-agent error: ${stderr.trim()}`);
99
+ }
100
+
101
+ throw new Error(`Failed to create chat: ${error.message}`);
102
+ }
103
+ }
104
+
105
+ function parseJsonFromStdout(stdout: string): any {
106
+ const text = String(stdout || '').trim();
107
+ if (!text) return null;
108
+ const lines = text.split('\n').filter(Boolean);
109
+
110
+ for (let i = lines.length - 1; i >= 0; i--) {
111
+ const line = lines[i]?.trim();
112
+ if (line?.startsWith('{') && line?.endsWith('}')) {
113
+ try {
114
+ return JSON.parse(line);
115
+ } catch {
116
+ continue;
117
+ }
118
+ }
119
+ }
120
+ return null;
121
+ }
122
+
123
+ /** Default timeout: 10 minutes */
124
+ const DEFAULT_TIMEOUT_MS = 600000;
125
+
126
+ /** Heartbeat interval: 30 seconds */
127
+ const HEARTBEAT_INTERVAL_MS = 30000;
128
+
129
+ /**
130
+ * Validate task configuration
131
+ * @throws Error if validation fails
132
+ */
133
+ export function validateTaskConfig(config: RunnerConfig): void {
134
+ if (!config.tasks || !Array.isArray(config.tasks)) {
135
+ throw new Error('Invalid config: "tasks" must be an array');
136
+ }
137
+
138
+ if (config.tasks.length === 0) {
139
+ throw new Error('Invalid config: "tasks" array is empty');
140
+ }
141
+
142
+ for (let i = 0; i < config.tasks.length; i++) {
143
+ const task = config.tasks[i];
144
+ const taskNum = i + 1;
145
+
146
+ if (!task) {
147
+ throw new Error(`Invalid config: Task ${taskNum} is null or undefined`);
148
+ }
149
+
150
+ if (!task.name || typeof task.name !== 'string') {
151
+ throw new Error(
152
+ `Invalid config: Task ${taskNum} missing required "name" field.\n` +
153
+ ` Found: ${JSON.stringify(task, null, 2).substring(0, 200)}...\n` +
154
+ ` Expected: { "name": "task-name", "prompt": "..." }`
155
+ );
156
+ }
157
+
158
+ if (!task.prompt || typeof task.prompt !== 'string') {
159
+ throw new Error(
160
+ `Invalid config: Task "${task.name}" (${taskNum}) missing required "prompt" field`
161
+ );
162
+ }
163
+
164
+ // Validate task name format (no spaces, special chars that could break branch names)
165
+ if (!/^[a-zA-Z0-9_-]+$/.test(task.name)) {
166
+ throw new Error(
167
+ `Invalid config: Task name "${task.name}" contains invalid characters.\n` +
168
+ ` Task names must only contain: letters, numbers, underscore (_), hyphen (-)`
169
+ );
170
+ }
171
+ }
172
+
173
+ // Validate timeout if provided
174
+ if (config.timeout !== undefined) {
175
+ if (typeof config.timeout !== 'number' || config.timeout <= 0) {
176
+ throw new Error(
177
+ `Invalid config: "timeout" must be a positive number (milliseconds).\n` +
178
+ ` Found: ${config.timeout}`
179
+ );
180
+ }
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Internal: Execute cursor-agent command with streaming
186
+ */
187
+ async function cursorAgentSendRaw({ workspaceDir, chatId, prompt, model, signalDir, timeout, enableIntervention, outputFormat, taskName }: {
188
+ workspaceDir: string;
189
+ chatId: string;
190
+ prompt: string;
191
+ model?: string;
192
+ signalDir?: string;
193
+ timeout?: number;
194
+ enableIntervention?: boolean;
195
+ outputFormat?: 'stream-json' | 'json' | 'plain';
196
+ taskName?: string;
197
+ }): Promise<AgentSendResult> {
198
+ // Use stream-json format for structured output with tool calls and results
199
+ const format = outputFormat || 'stream-json';
200
+ const args = [
201
+ '--print',
202
+ '--force',
203
+ '--approve-mcps',
204
+ '--output-format', format,
205
+ '--workspace', workspaceDir,
206
+ ...(model ? ['--model', model] : []),
207
+ '--resume', chatId,
208
+ prompt,
209
+ ];
210
+
211
+ const timeoutMs = timeout || DEFAULT_TIMEOUT_MS;
212
+
213
+ // Determine stdio mode based on intervention setting
214
+ const stdinMode = enableIntervention ? 'pipe' : 'ignore';
215
+
216
+ return new Promise((resolve) => {
217
+ // Build environment, preserving user's NODE_OPTIONS but disabling problematic flags
218
+ const childEnv = { ...process.env };
219
+
220
+ if (childEnv.NODE_OPTIONS) {
221
+ const filtered = childEnv.NODE_OPTIONS
222
+ .split(' ')
223
+ .filter(opt => !opt.includes('--inspect') && !opt.includes('--debug'))
224
+ .join(' ');
225
+ childEnv.NODE_OPTIONS = filtered;
226
+ }
227
+
228
+ childEnv.PYTHONUNBUFFERED = '1';
229
+
230
+ const child = spawn('cursor-agent', args, {
231
+ stdio: [stdinMode, 'pipe', 'pipe'],
232
+ env: childEnv,
233
+ });
234
+
235
+ // Save PID to state if possible
236
+ if (child.pid && signalDir) {
237
+ try {
238
+ const statePath = safeJoin(signalDir, 'state.json');
239
+ const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
240
+ state.pid = child.pid;
241
+ fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
242
+ } catch {
243
+ // Best effort
244
+ }
245
+ }
246
+
247
+ let fullStdout = '';
248
+ let fullStderr = '';
249
+ let timeoutHandle: NodeJS.Timeout;
250
+
251
+ // Heartbeat logging
252
+ let lastHeartbeat = Date.now();
253
+ let bytesReceived = 0;
254
+ const startTime = Date.now();
255
+ const heartbeatInterval = setInterval(() => {
256
+ const totalElapsed = Math.round((Date.now() - startTime) / 1000);
257
+ // Output without timestamp - orchestrator will add it
258
+ console.log(`⏱ Heartbeat: ${totalElapsed}s elapsed, ${bytesReceived} bytes received`);
259
+ }, HEARTBEAT_INTERVAL_MS);
260
+
261
+ // Signal watchers (intervention, timeout)
262
+ const interventionPath = signalDir ? path.join(signalDir, 'intervention.txt') : null;
263
+ const timeoutPath = signalDir ? path.join(signalDir, 'timeout.txt') : null;
264
+ let signalWatcher: fs.FSWatcher | null = null;
265
+
266
+ if (signalDir && fs.existsSync(signalDir)) {
267
+ signalWatcher = fs.watch(signalDir, (event, filename) => {
268
+ if (filename === 'intervention.txt' && interventionPath && fs.existsSync(interventionPath)) {
269
+ try {
270
+ const message = fs.readFileSync(interventionPath, 'utf8').trim();
271
+ if (message) {
272
+ if (enableIntervention && child.stdin) {
273
+ logger.info(`Injecting intervention: ${message}`);
274
+ child.stdin.write(message + '\n');
275
+
276
+ // Log to conversation history for visibility in monitor/logs
277
+ if (signalDir) {
278
+ const convoPath = path.join(signalDir, 'conversation.jsonl');
279
+ appendLog(convoPath, createConversationEntry('intervention', `[HUMAN INTERVENTION]: ${message}`, {
280
+ task: taskName || 'AGENT_TURN',
281
+ model: 'manual'
282
+ }));
283
+ }
284
+ } else {
285
+ logger.warn(`Intervention requested but stdin not available: ${message}`);
286
+ }
287
+ fs.unlinkSync(interventionPath);
288
+ }
289
+ } catch {}
290
+ }
291
+
292
+ if (filename === 'timeout.txt' && timeoutPath && fs.existsSync(timeoutPath)) {
293
+ try {
294
+ const newTimeoutStr = fs.readFileSync(timeoutPath, 'utf8').trim();
295
+ const newTimeoutMs = parseInt(newTimeoutStr);
296
+ if (!isNaN(newTimeoutMs) && newTimeoutMs > 0) {
297
+ logger.info(`⏱ Dynamic timeout update: ${Math.round(newTimeoutMs / 1000)}s`);
298
+ if (timeoutHandle) clearTimeout(timeoutHandle);
299
+ const elapsed = Date.now() - startTime;
300
+ const remaining = Math.max(1000, newTimeoutMs - elapsed);
301
+ timeoutHandle = setTimeout(() => {
302
+ clearInterval(heartbeatInterval);
303
+ child.kill();
304
+ resolve({ ok: false, exitCode: -1, error: `cursor-agent timed out after updated limit.` });
305
+ }, remaining);
306
+ fs.unlinkSync(timeoutPath);
307
+ }
308
+ } catch {}
309
+ }
310
+ });
311
+ }
312
+
313
+ if (child.stdout) {
314
+ child.stdout.on('data', (data) => {
315
+ fullStdout += data.toString();
316
+ bytesReceived += data.length;
317
+ process.stdout.write(data);
318
+ });
319
+ }
320
+
321
+ if (child.stderr) {
322
+ child.stderr.on('data', (data) => {
323
+ fullStderr += data.toString();
324
+ process.stderr.write(data);
325
+ });
326
+ }
327
+
328
+ timeoutHandle = setTimeout(() => {
329
+ clearInterval(heartbeatInterval);
330
+ child.kill();
331
+ resolve({
332
+ ok: false,
333
+ exitCode: -1,
334
+ error: `cursor-agent timed out after ${Math.round(timeoutMs / 1000)} seconds.`,
335
+ });
336
+ }, timeoutMs);
337
+
338
+ child.on('close', (code) => {
339
+ clearTimeout(timeoutHandle);
340
+ clearInterval(heartbeatInterval);
341
+ if (signalWatcher) signalWatcher.close();
342
+
343
+ const json = parseJsonFromStdout(fullStdout);
344
+
345
+ if (code !== 0 || !json || json.type !== 'result') {
346
+ let errorMsg = fullStderr.trim() || fullStdout.trim() || `exit=${code}`;
347
+ resolve({ ok: false, exitCode: code ?? -1, error: errorMsg });
348
+ } else {
349
+ resolve({
350
+ ok: !json.is_error,
351
+ exitCode: code ?? 0,
352
+ sessionId: json.session_id || chatId,
353
+ resultText: json.result || '',
354
+ });
355
+ }
356
+ });
357
+
358
+ child.on('error', (err) => {
359
+ clearTimeout(timeoutHandle);
360
+ clearInterval(heartbeatInterval);
361
+ resolve({ ok: false, exitCode: -1, error: `Failed to start cursor-agent: ${err.message}` });
362
+ });
363
+ });
364
+ }
365
+
366
+ /**
367
+ * Execute cursor-agent command with retries for transient errors
368
+ */
369
+ export async function cursorAgentSend(options: {
370
+ workspaceDir: string;
371
+ chatId: string;
372
+ prompt: string;
373
+ model?: string;
374
+ signalDir?: string;
375
+ timeout?: number;
376
+ enableIntervention?: boolean;
377
+ outputFormat?: 'stream-json' | 'json' | 'plain';
378
+ taskName?: string;
379
+ }): Promise<AgentSendResult> {
380
+ const laneName = options.signalDir ? path.basename(path.dirname(options.signalDir)) : 'agent';
381
+
382
+ return withRetry(
383
+ laneName,
384
+ () => cursorAgentSendRaw(options),
385
+ (res) => ({ ok: res.ok, error: res.error }),
386
+ { maxRetries: 3 }
387
+ );
388
+ }
389
+
390
+ /**
391
+ * Extract dependency change request from agent response
392
+ */
393
+ export function extractDependencyRequest(text: string): { required: boolean; plan?: DependencyRequestPlan; raw: string } {
394
+ const t = String(text || '');
395
+ const marker = 'DEPENDENCY_CHANGE_REQUIRED';
396
+
397
+ if (!t.includes(marker)) {
398
+ return { required: false, raw: t };
399
+ }
400
+
401
+ const after = t.split(marker).slice(1).join(marker);
402
+ const match = after.match(/\{[\s\S]*?\}/);
403
+
404
+ if (match) {
405
+ try {
406
+ return {
407
+ required: true,
408
+ plan: JSON.parse(match[0]!) as DependencyRequestPlan,
409
+ raw: t,
410
+ };
411
+ } catch {
412
+ return { required: true, raw: t };
413
+ }
414
+ }
415
+
416
+ return { required: true, raw: t };
417
+ }
418
+
419
+ /**
420
+ * Inter-task state file name
421
+ */
422
+ const LANE_STATE_FILE = '_cursorflow/lane-state.json';
423
+
424
+ /**
425
+ * Dependency request file name - agent writes here when dependency changes are needed
426
+ */
427
+ const DEPENDENCY_REQUEST_FILE = '_cursorflow/dependency-request.json';
428
+
429
+ /**
430
+ * Read dependency request from file if it exists
431
+ */
432
+ export function readDependencyRequestFile(worktreeDir: string): { required: boolean; plan?: DependencyRequestPlan } {
433
+ const filePath = safeJoin(worktreeDir, DEPENDENCY_REQUEST_FILE);
434
+
435
+ if (!fs.existsSync(filePath)) {
436
+ return { required: false };
437
+ }
438
+
439
+ try {
440
+ const content = fs.readFileSync(filePath, 'utf8');
441
+ const plan = JSON.parse(content) as DependencyRequestPlan;
442
+
443
+ // Validate required fields
444
+ if (plan.reason && Array.isArray(plan.commands) && plan.commands.length > 0) {
445
+ logger.info(`📦 Dependency request file detected: ${filePath}`);
446
+ return { required: true, plan };
447
+ }
448
+
449
+ logger.warn(`Invalid dependency request file format: ${filePath}`);
450
+ return { required: false };
451
+ } catch (e) {
452
+ logger.warn(`Failed to parse dependency request file: ${e}`);
453
+ return { required: false };
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Clear dependency request file after processing
459
+ */
460
+ export function clearDependencyRequestFile(worktreeDir: string): void {
461
+ const filePath = safeJoin(worktreeDir, DEPENDENCY_REQUEST_FILE);
462
+
463
+ if (fs.existsSync(filePath)) {
464
+ try {
465
+ fs.unlinkSync(filePath);
466
+ logger.info(`🗑️ Cleared dependency request file: ${filePath}`);
467
+ } catch (e) {
468
+ logger.warn(`Failed to clear dependency request file: ${e}`);
469
+ }
470
+ }
471
+ }
472
+
473
+ /**
474
+ * Wrap prompt with dependency policy instructions (legacy, used by tests)
475
+ */
476
+ export function wrapPromptForDependencyPolicy(prompt: string, policy: DependencyPolicy): string {
477
+ if (policy.allowDependencyChange && !policy.lockfileReadOnly) {
478
+ return prompt;
479
+ }
480
+
481
+ let wrapped = `### 📦 Dependency Policy\n`;
482
+ wrapped += `- allowDependencyChange: ${policy.allowDependencyChange}\n`;
483
+ wrapped += `- lockfileReadOnly: ${policy.lockfileReadOnly}\n\n`;
484
+ wrapped += prompt;
485
+
486
+ return wrapped;
487
+ }
488
+
489
+ /**
490
+ * Wrap prompt with global context, dependency policy, and worktree instructions
491
+ */
492
+ export function wrapPrompt(
493
+ prompt: string,
494
+ config: RunnerConfig,
495
+ options: {
496
+ noGit?: boolean;
497
+ isWorktree?: boolean;
498
+ previousState?: string | null;
499
+ } = {}
500
+ ): string {
501
+ const { noGit = false, isWorktree = true, previousState = null } = options;
502
+
503
+ // 1. PREFIX: Environment & Worktree context
504
+ let wrapped = `### 🛠 Environment & Context\n`;
505
+ wrapped += `- **Workspace**: 당신은 독립된 **Git 워크트리** (프로젝트 루트)에서 작업 중입니다.\n`;
506
+ wrapped += `- **Path Rule**: 모든 파일 참조 및 터미널 명령어는 **현재 디렉토리(./)**를 기준으로 하세요.\n`;
507
+
508
+ if (isWorktree) {
509
+ wrapped += `- **File Availability**: Git 추적 파일만 존재합니다. (node_modules, .env 등은 기본적으로 없음)\n`;
510
+ }
511
+
512
+ // 2. Previous Task State (if available)
513
+ if (previousState) {
514
+ wrapped += `\n### 💡 Previous Task State\n`;
515
+ wrapped += `이전 태스크에서 전달된 상태 정보입니다:\n`;
516
+ wrapped += `\`\`\`json\n${previousState}\n\`\`\`\n`;
517
+ }
518
+
519
+ // 3. Dependency Policy (Integrated)
520
+ const policy = config.dependencyPolicy;
521
+ wrapped += `\n### 📦 Dependency Policy\n`;
522
+ wrapped += `- allowDependencyChange: ${policy.allowDependencyChange}\n`;
523
+ wrapped += `- lockfileReadOnly: ${policy.lockfileReadOnly}\n`;
524
+
525
+ if (noGit) {
526
+ wrapped += `- NO_GIT_MODE: Git 명령어를 사용하지 마세요. 파일 수정만 가능합니다.\n`;
527
+ }
528
+
529
+ wrapped += `\n**📦 Dependency Change Rules:**\n`;
530
+ wrapped += `1. 코드를 수정하기 전, 의존성 변경이 필요한지 **먼저** 판단하세요.\n`;
531
+ wrapped += `2. 의존성 변경이 필요하다면:\n`;
532
+ wrapped += ` - **다른 파일을 절대 수정하지 마세요.**\n`;
533
+ wrapped += ` - 아래 JSON을 \`./${DEPENDENCY_REQUEST_FILE}\` 파일에 저장하세요:\n`;
534
+ wrapped += ` \`\`\`json\n`;
535
+ wrapped += ` {\n`;
536
+ wrapped += ` "reason": "왜 이 의존성이 필요한지 설명",\n`;
537
+ wrapped += ` "changes": ["add lodash@^4.17.21", "remove unused-pkg"],\n`;
538
+ wrapped += ` "commands": ["pnpm add lodash@^4.17.21", "pnpm remove unused-pkg"],\n`;
539
+ wrapped += ` "notes": "추가 참고사항 (선택)" \n`;
540
+ wrapped += ` }\n`;
541
+ wrapped += ` \`\`\`\n`;
542
+ wrapped += ` - 파일 저장 후 **즉시 작업을 종료**하세요. 오케스트레이터가 처리합니다.\n`;
543
+ wrapped += `3. 의존성 변경이 불필요하면 바로 본 작업을 진행하세요.\n`;
544
+
545
+ wrapped += `\n---\n\n${prompt}\n\n---\n`;
546
+
547
+ // 4. SUFFIX: Task Completion & Git Requirements
548
+ wrapped += `\n### 📝 Task Completion Requirements\n`;
549
+ wrapped += `**반드시 다음 순서로 작업을 마무리하세요:**\n\n`;
550
+
551
+ if (!noGit) {
552
+ wrapped += `1. **Git Commit & Push** (필수!):\n`;
553
+ wrapped += ` \`\`\`bash\n`;
554
+ wrapped += ` git add -A\n`;
555
+ wrapped += ` git commit -m "feat: <작업 내용 요약>"\n`;
556
+ wrapped += ` git push origin HEAD\n`;
557
+ wrapped += ` \`\`\`\n`;
558
+ wrapped += ` ⚠️ 커밋과 푸시 없이 작업을 종료하면 변경사항이 손실됩니다!\n\n`;
559
+ }
560
+
561
+ wrapped += `2. **State Passing**: 다음 태스크로 전달할 정보가 있다면 \`./${LANE_STATE_FILE}\`에 JSON으로 저장하세요.\n\n`;
562
+ wrapped += `3. **Summary**: 작업 완료 다음을 요약해 주세요:\n`;
563
+ wrapped += ` - 생성/수정된 파일 목록\n`;
564
+ wrapped += ` - 주요 변경 사항\n`;
565
+ wrapped += ` - 커밋 해시 (git log --oneline -1)\n\n`;
566
+ wrapped += `4. 지시된 문서(docs/...)를 찾을 수 없다면 즉시 보고하세요.\n`;
567
+
568
+ return wrapped;
569
+ }
570
+
571
+ /**
572
+ * Apply file permissions based on dependency policy
573
+ */
574
+ export function applyDependencyFilePermissions(worktreeDir: string, policy: DependencyPolicy): void {
575
+ const targets: string[] = [];
576
+
577
+ if (!policy.allowDependencyChange) {
578
+ targets.push('package.json');
579
+ }
580
+
581
+ if (policy.lockfileReadOnly) {
582
+ targets.push('pnpm-lock.yaml', 'package-lock.json', 'yarn.lock');
583
+ }
584
+
585
+ for (const file of targets) {
586
+ const filePath = safeJoin(worktreeDir, file);
587
+ if (!fs.existsSync(filePath)) continue;
588
+
589
+ try {
590
+ const stats = fs.statSync(filePath);
591
+ const mode = stats.mode & 0o777;
592
+ fs.chmodSync(filePath, mode & ~0o222); // Remove write bits
593
+ } catch {
594
+ // Best effort
595
+ }
596
+ }
597
+ }
598
+
599
+ /**
600
+ * Wait for task-level dependencies to be completed by other lanes
601
+ * Now uses the enhanced dependency module with timeout support
602
+ */
603
+ export async function waitForTaskDependencies(
604
+ deps: string[],
605
+ runDir: string,
606
+ options: DependencyWaitOptions = {}
607
+ ): Promise<void> {
608
+ if (!deps || deps.length === 0) return;
609
+
610
+ const lanesRoot = path.dirname(runDir);
611
+
612
+ const result = await waitForDeps(deps, lanesRoot, {
613
+ timeoutMs: options.timeoutMs || 30 * 60 * 1000, // 30 minutes default
614
+ pollIntervalMs: options.pollIntervalMs || 5000,
615
+ onTimeout: options.onTimeout || 'fail',
616
+ onProgress: (pending, completed) => {
617
+ if (completed.length > 0) {
618
+ logger.info(`Dependencies progress: ${completed.length}/${deps.length} completed`);
619
+ }
620
+ },
621
+ });
622
+
623
+ if (!result.success) {
624
+ if (result.timedOut) {
625
+ throw new Error(`Dependency wait timed out after ${Math.round(result.elapsedMs / 1000)}s. Pending: ${result.failedDependencies.join(', ')}`);
626
+ }
627
+ throw new Error(`Dependencies failed: ${result.failedDependencies.join(', ')}`);
628
+ }
629
+ }
630
+
631
+ /**
632
+ * Merge branches from dependency lanes with safe merge
633
+ */
634
+ export async function mergeDependencyBranches(deps: string[], runDir: string, worktreeDir: string): Promise<void> {
635
+ if (!deps || deps.length === 0) return;
636
+
637
+ const lanesRoot = path.dirname(runDir);
638
+ const lanesToMerge = new Set(deps.map(d => d.split(':')[0]!));
639
+
640
+ for (const laneName of lanesToMerge) {
641
+ const depStatePath = safeJoin(lanesRoot, laneName, 'state.json');
642
+ if (!fs.existsSync(depStatePath)) continue;
643
+
644
+ try {
645
+ const state = loadState<LaneState>(depStatePath);
646
+ if (!state?.pipelineBranch) continue;
647
+
648
+ logger.info(`Merging branch from ${laneName}: ${state.pipelineBranch}`);
649
+
650
+ // Ensure we have the latest
651
+ git.runGit(['fetch', 'origin', state.pipelineBranch], { cwd: worktreeDir, silent: true });
652
+
653
+ // Use safe merge with conflict detection
654
+ const mergeResult = git.safeMerge(state.pipelineBranch, {
655
+ cwd: worktreeDir,
656
+ noFf: true,
657
+ message: `chore: merge task dependency from ${laneName}`,
658
+ abortOnConflict: true,
659
+ });
660
+
661
+ if (!mergeResult.success) {
662
+ if (mergeResult.conflict) {
663
+ logger.error(`Merge conflict with ${laneName}: ${mergeResult.conflictingFiles.join(', ')}`);
664
+ throw new Error(`Merge conflict: ${mergeResult.conflictingFiles.join(', ')}`);
665
+ }
666
+ throw new Error(mergeResult.error || 'Merge failed');
667
+ }
668
+
669
+ logger.success(`✓ Merged ${laneName}`);
670
+ } catch (e) {
671
+ logger.error(`Failed to merge branch from ${laneName}: ${e}`);
672
+ throw e;
673
+ }
674
+ }
675
+ }
676
+
677
+ /**
678
+ * Run a single task
679
+ */
680
+ export async function runTask({
681
+ task,
682
+ config,
683
+ index,
684
+ worktreeDir,
685
+ pipelineBranch,
686
+ taskBranch,
687
+ chatId,
688
+ runDir,
689
+ noGit = false,
690
+ }: {
691
+ task: Task;
692
+ config: RunnerConfig;
693
+ index: number;
694
+ worktreeDir: string;
695
+ pipelineBranch: string;
696
+ taskBranch: string;
697
+ chatId: string;
698
+ runDir: string;
699
+ noGit?: boolean;
700
+ }): Promise<TaskExecutionResult> {
701
+ const model = task.model || config.model || 'sonnet-4.5';
702
+ const timeout = task.timeout || config.timeout;
703
+ const convoPath = safeJoin(runDir, 'conversation.jsonl');
704
+
705
+ logger.section(`[${index + 1}/${config.tasks.length}] ${task.name}`);
706
+ logger.info(`Model: ${model}`);
707
+ if (noGit) {
708
+ logger.info('🚫 noGit mode: skipping branch operations');
709
+ } else {
710
+ logger.info(`Branch: ${taskBranch}`);
711
+ }
712
+
713
+ events.emit('task.started', {
714
+ taskName: task.name,
715
+ taskBranch,
716
+ index,
717
+ });
718
+
719
+ // Checkout task branch (skip in noGit mode)
720
+ if (!noGit) {
721
+ git.runGit(['checkout', '-B', taskBranch], { cwd: worktreeDir });
722
+ }
723
+
724
+ // Apply dependency permissions
725
+ applyDependencyFilePermissions(worktreeDir, config.dependencyPolicy);
726
+
727
+ // Read previous task state if available
728
+ let previousState: string | null = null;
729
+ const stateFilePath = safeJoin(worktreeDir, LANE_STATE_FILE);
730
+ if (fs.existsSync(stateFilePath)) {
731
+ try {
732
+ previousState = fs.readFileSync(stateFilePath, 'utf8');
733
+ logger.info('Loaded previous task state from _cursorflow/lane-state.json');
734
+ } catch (e) {
735
+ logger.warn(`Failed to read inter-task state: ${e}`);
736
+ }
737
+ }
738
+
739
+ // Wrap prompt with context, previous state, and completion instructions
740
+ const wrappedPrompt = wrapPrompt(task.prompt, config, {
741
+ noGit,
742
+ isWorktree: !noGit,
743
+ previousState
744
+ });
745
+
746
+ // Log ONLY the original prompt to keep logs clean
747
+ appendLog(convoPath, createConversationEntry('user', task.prompt, {
748
+ task: task.name,
749
+ model,
750
+ }));
751
+
752
+ logger.info('Sending prompt to agent...');
753
+ const startTime = Date.now();
754
+ events.emit('agent.prompt_sent', {
755
+ taskName: task.name,
756
+ model,
757
+ promptLength: wrappedPrompt.length,
758
+ });
759
+
760
+ const r1 = await cursorAgentSend({
761
+ workspaceDir: worktreeDir,
762
+ chatId,
763
+ prompt: wrappedPrompt,
764
+ model,
765
+ signalDir: runDir,
766
+ timeout,
767
+ enableIntervention: config.enableIntervention,
768
+ outputFormat: config.agentOutputFormat,
769
+ taskName: task.name,
770
+ });
771
+
772
+ const duration = Date.now() - startTime;
773
+ events.emit('agent.response_received', {
774
+ taskName: task.name,
775
+ ok: r1.ok,
776
+ duration,
777
+ responseLength: r1.resultText?.length || 0,
778
+ error: r1.error,
779
+ });
780
+
781
+ appendLog(convoPath, createConversationEntry('assistant', r1.resultText || r1.error || 'No response', {
782
+ task: task.name,
783
+ model,
784
+ }));
785
+
786
+ if (!r1.ok) {
787
+ events.emit('task.failed', {
788
+ taskName: task.name,
789
+ taskBranch,
790
+ error: r1.error,
791
+ });
792
+ return {
793
+ taskName: task.name,
794
+ taskBranch,
795
+ status: 'ERROR',
796
+ error: r1.error,
797
+ };
798
+ }
799
+
800
+ // Check for dependency request (file-based takes priority, then text-based)
801
+ const fileDepReq = readDependencyRequestFile(worktreeDir);
802
+ const textDepReq = extractDependencyRequest(r1.resultText || '');
803
+
804
+ // Determine which request to use (file-based is preferred as it's more structured)
805
+ const depReq = fileDepReq.required ? fileDepReq : textDepReq;
806
+
807
+ if (depReq.required) {
808
+ logger.info(`📦 Dependency change requested: ${depReq.plan?.reason || 'No reason provided'}`);
809
+
810
+ if (depReq.plan) {
811
+ logger.info(` Commands: ${depReq.plan.commands.join(', ')}`);
812
+ }
813
+
814
+ if (!config.dependencyPolicy.allowDependencyChange) {
815
+ // Clear the file so it doesn't persist after resolution
816
+ clearDependencyRequestFile(worktreeDir);
817
+
818
+ return {
819
+ taskName: task.name,
820
+ taskBranch,
821
+ status: 'BLOCKED_DEPENDENCY',
822
+ dependencyRequest: depReq.plan || null,
823
+ };
824
+ }
825
+ }
826
+
827
+ // Push task branch (skip in noGit mode)
828
+ if (!noGit) {
829
+ git.push(taskBranch, { cwd: worktreeDir, setUpstream: true });
830
+ }
831
+
832
+ // Automatic Review
833
+ const reviewEnabled = config.reviewAllTasks || task.acceptanceCriteria?.length || config.enableReview;
834
+
835
+ if (reviewEnabled) {
836
+ logger.section(`🔍 Reviewing Task: ${task.name}`);
837
+ const reviewResult = await runReviewLoop({
838
+ taskResult: {
839
+ taskName: task.name,
840
+ taskBranch: taskBranch,
841
+ acceptanceCriteria: task.acceptanceCriteria,
842
+ },
843
+ worktreeDir,
844
+ runDir,
845
+ config,
846
+ workChatId: chatId,
847
+ model, // Use the same model as requested
848
+ cursorAgentSend,
849
+ cursorAgentCreateChat,
850
+ });
851
+
852
+ if (!reviewResult.approved) {
853
+ logger.error(`❌ Task review failed after ${reviewResult.iterations} iterations`);
854
+ return {
855
+ taskName: task.name,
856
+ taskBranch,
857
+ status: 'ERROR',
858
+ error: reviewResult.error || 'Task failed to pass review criteria',
859
+ };
860
+ }
861
+ }
862
+
863
+ events.emit('task.completed', {
864
+ taskName: task.name,
865
+ taskBranch,
866
+ status: 'FINISHED',
867
+ });
868
+
869
+ return {
870
+ taskName: task.name,
871
+ taskBranch,
872
+ status: 'FINISHED',
873
+ };
874
+ }
875
+
876
+ /**
877
+ * Run all tasks in sequence
878
+ */
879
+ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir: string, options: { startIndex?: number; noGit?: boolean; skipPreflight?: boolean } = {}): Promise<TaskExecutionResult[]> {
880
+ const startIndex = options.startIndex || 0;
881
+ const noGit = options.noGit || config.noGit || false;
882
+
883
+ if (noGit) {
884
+ logger.info('🚫 Running in noGit mode - Git operations will be skipped');
885
+ }
886
+
887
+ // Validate configuration before starting
888
+ logger.info('Validating task configuration...');
889
+ try {
890
+ validateTaskConfig(config);
891
+ logger.success('✓ Configuration valid');
892
+ } catch (validationError: any) {
893
+ logger.error('❌ Configuration validation failed');
894
+ logger.error(` ${validationError.message}`);
895
+ throw validationError;
896
+ }
897
+
898
+ // Run preflight checks (can be skipped for resume)
899
+ if (!options.skipPreflight && startIndex === 0) {
900
+ logger.info('Running preflight checks...');
901
+ const preflight = await preflightCheck({
902
+ requireRemote: !noGit,
903
+ requireAuth: true,
904
+ });
905
+
906
+ if (!preflight.canProceed) {
907
+ printPreflightReport(preflight);
908
+ throw new Error('Preflight check failed. Please fix the blockers above.');
909
+ }
910
+
911
+ if (preflight.warnings.length > 0) {
912
+ for (const warning of preflight.warnings) {
913
+ logger.warn(`⚠️ ${warning}`);
914
+ }
915
+ }
916
+
917
+ logger.success('✓ Preflight checks passed');
918
+ }
919
+
920
+ // Warn if baseBranch is set in config (it will be ignored)
921
+ if (config.baseBranch) {
922
+ logger.warn(`⚠️ config.baseBranch="${config.baseBranch}" will be ignored. Using current branch instead.`);
923
+ }
924
+
925
+ // Ensure cursor-agent is installed
926
+ ensureCursorAgent();
927
+
928
+ // Check authentication before starting
929
+ logger.info('Checking Cursor authentication...');
930
+ const authStatus = checkCursorAuth();
931
+
932
+ if (!authStatus.authenticated) {
933
+ logger.error('❌ Cursor authentication failed');
934
+ logger.error(` ${authStatus.message}`);
935
+
936
+ if (authStatus.details) {
937
+ logger.error(` Details: ${authStatus.details}`);
938
+ }
939
+
940
+ if (authStatus.help) {
941
+ logger.error(` ${authStatus.help}`);
942
+ }
943
+
944
+ console.log('');
945
+ printAuthHelp();
946
+
947
+ throw new Error('Cursor authentication required. Please authenticate and try again.');
948
+ }
949
+
950
+ logger.success('✓ Cursor authentication OK');
951
+
952
+ // In noGit mode, we don't need repoRoot - use current directory
953
+ const repoRoot = noGit ? process.cwd() : git.getMainRepoRoot();
954
+
955
+ // ALWAYS use current branch as base - ignore config.baseBranch
956
+ // This ensures dependency structure is maintained in the worktree
957
+ const currentBranch = noGit ? 'main' : git.getCurrentBranch(repoRoot);
958
+ logger.info(`📍 Base branch: ${currentBranch} (current branch)`);
959
+
960
+ // Load existing state if resuming
961
+ const statePath = safeJoin(runDir, 'state.json');
962
+ let state: LaneState | null = null;
963
+
964
+ if (fs.existsSync(statePath)) {
965
+ // Check if state needs recovery
966
+ if (stateNeedsRecovery(statePath)) {
967
+ logger.warn('State file indicates incomplete previous run. Attempting recovery...');
968
+ const repairedState = repairLaneState(statePath);
969
+ if (repairedState) {
970
+ state = repairedState;
971
+ logger.success('✓ State recovered');
972
+ } else {
973
+ logger.warn('Could not recover state. Starting fresh.');
974
+ }
975
+ } else {
976
+ state = loadState<LaneState>(statePath);
977
+
978
+ // Validate loaded state
979
+ if (state) {
980
+ const validation = validateLaneState(statePath, {
981
+ checkWorktree: !noGit,
982
+ checkBranch: !noGit,
983
+ autoRepair: true,
984
+ });
985
+
986
+ if (!validation.valid) {
987
+ logger.warn(`State validation issues: ${validation.issues.join(', ')}`);
988
+ if (validation.repaired) {
989
+ logger.info('State was auto-repaired');
990
+ state = validation.repairedState || state;
991
+ }
992
+ }
993
+ }
994
+ }
995
+ }
996
+
997
+ const randomSuffix = Math.random().toString(36).substring(2, 7);
998
+ const pipelineBranch = state?.pipelineBranch || config.pipelineBranch || `${config.branchPrefix || 'cursorflow/'}${Date.now().toString(36)}-${randomSuffix}`;
999
+
1000
+ // In noGit mode, use a simple local directory instead of worktree
1001
+ // Flatten the path by replacing slashes with hyphens to avoid race conditions in parent directory creation
1002
+ const worktreeDir = state?.worktreeDir || config.worktreeDir || (noGit
1003
+ ? safeJoin(repoRoot, config.worktreeRoot || '_cursorflow/workdir', pipelineBranch.replace(/\//g, '-'))
1004
+ : safeJoin(repoRoot, config.worktreeRoot || '_cursorflow/worktrees', pipelineBranch.replace(/\//g, '-')));
1005
+
1006
+ if (startIndex === 0) {
1007
+ logger.section('🚀 Starting Pipeline');
1008
+ } else {
1009
+ logger.section(`🔁 Resuming Pipeline from task ${startIndex + 1}`);
1010
+ }
1011
+
1012
+ logger.info(`Pipeline Branch: ${pipelineBranch}`);
1013
+ logger.info(`Worktree: ${worktreeDir}`);
1014
+ logger.info(`Tasks: ${config.tasks.length}`);
1015
+
1016
+ // Create worktree only if starting fresh and worktree doesn't exist
1017
+ if (!fs.existsSync(worktreeDir)) {
1018
+ if (noGit) {
1019
+ // In noGit mode, just create the directory
1020
+ logger.info(`Creating work directory: ${worktreeDir}`);
1021
+ fs.mkdirSync(worktreeDir, { recursive: true });
1022
+ } else {
1023
+ // Use a simple retry mechanism for Git worktree creation to handle potential race conditions
1024
+ let retries = 3;
1025
+ let lastError: Error | null = null;
1026
+
1027
+ while (retries > 0) {
1028
+ try {
1029
+ // Ensure parent directory exists before calling git worktree
1030
+ const worktreeParent = path.dirname(worktreeDir);
1031
+ if (!fs.existsSync(worktreeParent)) {
1032
+ fs.mkdirSync(worktreeParent, { recursive: true });
1033
+ }
1034
+
1035
+ // Always use the current branch (already captured at start) as the base branch
1036
+ git.createWorktree(worktreeDir, pipelineBranch, {
1037
+ baseBranch: currentBranch,
1038
+ cwd: repoRoot,
1039
+ });
1040
+ break; // Success
1041
+ } catch (e: any) {
1042
+ lastError = e;
1043
+ retries--;
1044
+ if (retries > 0) {
1045
+ const delay = Math.floor(Math.random() * 1000) + 500;
1046
+ logger.warn(`Worktree creation failed, retrying in ${delay}ms... (${retries} retries left)`);
1047
+ await new Promise(resolve => setTimeout(resolve, delay));
1048
+ }
1049
+ }
1050
+ }
1051
+
1052
+ if (retries === 0 && lastError) {
1053
+ throw new Error(`Failed to create Git worktree after retries: ${lastError.message}`);
1054
+ }
1055
+ }
1056
+ } else if (!noGit) {
1057
+ // If it exists but we are in Git mode, ensure it's actually a worktree and on the right branch
1058
+ logger.info(`Reusing existing worktree: ${worktreeDir}`);
1059
+ try {
1060
+ git.runGit(['checkout', pipelineBranch], { cwd: worktreeDir });
1061
+ } catch (e) {
1062
+ // If checkout fails, maybe the worktree is in a weird state.
1063
+ // For now, just log it. In a more robust impl, we might want to repair it.
1064
+ logger.warn(`Failed to checkout branch ${pipelineBranch} in existing worktree: ${e}`);
1065
+ }
1066
+ }
1067
+
1068
+ // Create chat
1069
+ logger.info('Creating chat session...');
1070
+ const chatId = cursorAgentCreateChat();
1071
+
1072
+ // Initialize state if not loaded
1073
+ if (!state) {
1074
+ state = {
1075
+ status: 'running',
1076
+ pipelineBranch,
1077
+ worktreeDir,
1078
+ totalTasks: config.tasks.length,
1079
+ currentTaskIndex: 0,
1080
+ label: pipelineBranch,
1081
+ startTime: Date.now(),
1082
+ endTime: null,
1083
+ error: null,
1084
+ dependencyRequest: null,
1085
+ tasksFile, // Store tasks file for resume
1086
+ dependsOn: config.dependsOn || [],
1087
+ completedTasks: [],
1088
+ };
1089
+ } else {
1090
+ state.status = 'running';
1091
+ state.error = null;
1092
+ state.dependencyRequest = null;
1093
+ state.pipelineBranch = pipelineBranch;
1094
+ state.worktreeDir = worktreeDir;
1095
+ state.label = state.label || pipelineBranch;
1096
+ state.dependsOn = config.dependsOn || [];
1097
+ state.completedTasks = state.completedTasks || [];
1098
+ }
1099
+
1100
+ saveState(statePath, state);
1101
+
1102
+ // Merge dependencies if any (skip in noGit mode)
1103
+ if (!noGit && startIndex === 0 && config.dependsOn && config.dependsOn.length > 0) {
1104
+ logger.section('🔗 Merging Dependencies');
1105
+
1106
+ // The runDir for the lane is passed in. Dependencies are in ../<depName> relative to this runDir
1107
+ const lanesRoot = path.dirname(runDir);
1108
+
1109
+ for (const depName of config.dependsOn) {
1110
+ const depRunDir = path.join(lanesRoot, depName); // nosemgrep
1111
+ const depStatePath = path.join(depRunDir, 'state.json'); // nosemgrep
1112
+
1113
+ if (!fs.existsSync(depStatePath)) {
1114
+ logger.warn(`Dependency state not found for ${depName} at ${depStatePath}`);
1115
+ continue;
1116
+ }
1117
+
1118
+ try {
1119
+ const depState = JSON.parse(fs.readFileSync(depStatePath, 'utf8')) as LaneState;
1120
+ if (depState.status !== 'completed') {
1121
+ logger.warn(`Dependency ${depName} is in status ${depState.status}, merge might be incomplete`);
1122
+ }
1123
+
1124
+ if (depState.pipelineBranch) {
1125
+ logger.info(`Merging dependency branch: ${depState.pipelineBranch} (${depName})`);
1126
+
1127
+ // Fetch first to ensure we have the branch
1128
+ git.runGit(['fetch', 'origin', depState.pipelineBranch], { cwd: worktreeDir, silent: true });
1129
+
1130
+ // Merge
1131
+ git.merge(depState.pipelineBranch, {
1132
+ cwd: worktreeDir,
1133
+ noFf: true,
1134
+ message: `chore: merge dependency ${depName} (${depState.pipelineBranch})`
1135
+ });
1136
+
1137
+ // Log changed files
1138
+ const stats = git.getLastOperationStats(worktreeDir);
1139
+ if (stats) {
1140
+ logger.info('Changed files:\n' + stats);
1141
+ }
1142
+ }
1143
+ } catch (e) {
1144
+ logger.error(`Failed to merge dependency ${depName}: ${e}`);
1145
+ }
1146
+ }
1147
+
1148
+ // Push the merged state
1149
+ git.push(pipelineBranch, { cwd: worktreeDir });
1150
+ } else if (noGit && startIndex === 0 && config.dependsOn && config.dependsOn.length > 0) {
1151
+ logger.info('⚠️ Dependencies specified but Git is disabled - copying files instead of merging');
1152
+
1153
+ // The runDir for the lane is passed in. Dependencies are in ../<depName> relative to this runDir
1154
+ const lanesRoot = path.dirname(runDir);
1155
+
1156
+ for (const depName of config.dependsOn) {
1157
+ const depRunDir = safeJoin(lanesRoot, depName);
1158
+ const depStatePath = safeJoin(depRunDir, 'state.json');
1159
+
1160
+ if (!fs.existsSync(depStatePath)) {
1161
+ continue;
1162
+ }
1163
+
1164
+ try {
1165
+ const depState = JSON.parse(fs.readFileSync(depStatePath, 'utf8')) as LaneState;
1166
+ if (depState.worktreeDir && fs.existsSync(depState.worktreeDir)) {
1167
+ logger.info(`Copying files from dependency ${depName}: ${depState.worktreeDir} → ${worktreeDir}`);
1168
+
1169
+ // Use a simple recursive copy (excluding Git and internal dirs)
1170
+ const copyFiles = (src: string, dest: string) => {
1171
+ if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
1172
+ const entries = fs.readdirSync(src, { withFileTypes: true });
1173
+
1174
+ for (const entry of entries) {
1175
+ if (entry.name === '.git' || entry.name === '_cursorflow' || entry.name === 'node_modules') continue;
1176
+
1177
+ const srcPath = safeJoin(src, entry.name);
1178
+ const destPath = safeJoin(dest, entry.name);
1179
+
1180
+ if (entry.isDirectory()) {
1181
+ copyFiles(srcPath, destPath);
1182
+ } else {
1183
+ fs.copyFileSync(srcPath, destPath);
1184
+ }
1185
+ }
1186
+ };
1187
+
1188
+ copyFiles(depState.worktreeDir, worktreeDir);
1189
+ }
1190
+ } catch (e) {
1191
+ logger.error(`Failed to copy dependency ${depName}: ${e}`);
1192
+ }
1193
+ }
1194
+ }
1195
+
1196
+ // Run tasks
1197
+ const results: TaskExecutionResult[] = [];
1198
+ const laneName = state.label || path.basename(runDir);
1199
+
1200
+ for (let i = startIndex; i < config.tasks.length; i++) {
1201
+ const task = config.tasks[i]!;
1202
+ const taskBranch = `${pipelineBranch}--${String(i + 1).padStart(2, '0')}-${task.name}`;
1203
+
1204
+ // Create checkpoint before each task
1205
+ try {
1206
+ await createCheckpoint(laneName, runDir, noGit ? null : worktreeDir, {
1207
+ description: `Before task ${i + 1}: ${task.name}`,
1208
+ maxCheckpoints: 5,
1209
+ });
1210
+ } catch (e: any) {
1211
+ logger.warn(`Failed to create checkpoint: ${e.message}`);
1212
+ }
1213
+
1214
+ // Handle task-level dependencies
1215
+ if (task.dependsOn && task.dependsOn.length > 0) {
1216
+ state.status = 'waiting';
1217
+ state.waitingFor = task.dependsOn;
1218
+ saveState(statePath, state);
1219
+
1220
+ try {
1221
+ // Use enhanced dependency wait with timeout
1222
+ await waitForTaskDependencies(task.dependsOn, runDir, {
1223
+ timeoutMs: config.timeout || 30 * 60 * 1000,
1224
+ onTimeout: 'fail',
1225
+ });
1226
+
1227
+ if (!noGit) {
1228
+ await mergeDependencyBranches(task.dependsOn, runDir, worktreeDir);
1229
+ }
1230
+
1231
+ state.status = 'running';
1232
+ state.waitingFor = [];
1233
+ saveState(statePath, state);
1234
+ } catch (e: any) {
1235
+ state.status = 'failed';
1236
+ state.waitingFor = [];
1237
+ state.error = e.message;
1238
+ saveState(statePath, state);
1239
+ logger.error(`Task dependency wait/merge failed: ${e.message}`);
1240
+
1241
+ // Try to restore from checkpoint
1242
+ const latestCheckpoint = getLatestCheckpoint(runDir);
1243
+ if (latestCheckpoint) {
1244
+ logger.info(`💾 Checkpoint available: ${latestCheckpoint.id}`);
1245
+ logger.info(` Resume with: cursorflow resume --checkpoint ${latestCheckpoint.id}`);
1246
+ }
1247
+
1248
+ process.exit(1);
1249
+ }
1250
+ }
1251
+
1252
+ const result = await runTask({
1253
+ task,
1254
+ config,
1255
+ index: i,
1256
+ worktreeDir,
1257
+ pipelineBranch,
1258
+ taskBranch,
1259
+ chatId,
1260
+ runDir,
1261
+ noGit,
1262
+ });
1263
+
1264
+ results.push(result);
1265
+
1266
+ // Update state
1267
+ state.currentTaskIndex = i + 1;
1268
+ state.completedTasks = state.completedTasks || [];
1269
+ if (!state.completedTasks.includes(task.name)) {
1270
+ state.completedTasks.push(task.name);
1271
+ }
1272
+ saveState(statePath, state);
1273
+
1274
+ // Handle blocked or error
1275
+ if (result.status === 'BLOCKED_DEPENDENCY') {
1276
+ state.status = 'failed';
1277
+ state.dependencyRequest = result.dependencyRequest || null;
1278
+ saveState(statePath, state);
1279
+
1280
+ if (result.dependencyRequest) {
1281
+ events.emit('lane.dependency_requested', {
1282
+ laneName: state.label,
1283
+ dependencyRequest: result.dependencyRequest,
1284
+ });
1285
+ }
1286
+
1287
+ logger.warn('Task blocked on dependency change');
1288
+ process.exit(2);
1289
+ }
1290
+
1291
+ if (result.status !== 'FINISHED') {
1292
+ state.status = 'failed';
1293
+ state.error = result.error || 'Unknown error';
1294
+ saveState(statePath, state);
1295
+ logger.error(`Task failed: ${result.error}`);
1296
+ process.exit(1);
1297
+ }
1298
+
1299
+ // Merge into pipeline (skip in noGit mode)
1300
+ if (!noGit) {
1301
+ logger.info(`Merging ${taskBranch} → ${pipelineBranch}`);
1302
+ git.merge(taskBranch, { cwd: worktreeDir, noFf: true });
1303
+
1304
+ // Log changed files
1305
+ const stats = git.getLastOperationStats(worktreeDir);
1306
+ if (stats) {
1307
+ logger.info('Changed files:\n' + stats);
1308
+ }
1309
+
1310
+ git.push(pipelineBranch, { cwd: worktreeDir });
1311
+ } else {
1312
+ logger.info(`✓ Task ${task.name} completed (noGit mode - no branch operations)`);
1313
+ }
1314
+ }
1315
+
1316
+ // Complete
1317
+ state.status = 'completed';
1318
+ state.endTime = Date.now();
1319
+ saveState(statePath, state);
1320
+
1321
+ // Log final file summary
1322
+ if (noGit) {
1323
+ const getFileSummary = (dir: string): { files: number; dirs: number } => {
1324
+ let stats = { files: 0, dirs: 0 };
1325
+ if (!fs.existsSync(dir)) return stats;
1326
+
1327
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
1328
+ for (const entry of entries) {
1329
+ if (entry.name === '.git' || entry.name === '_cursorflow' || entry.name === 'node_modules') continue;
1330
+
1331
+ if (entry.isDirectory()) {
1332
+ stats.dirs++;
1333
+ const sub = getFileSummary(safeJoin(dir, entry.name));
1334
+ stats.files += sub.files;
1335
+ stats.dirs += sub.dirs;
1336
+ } else {
1337
+ stats.files++;
1338
+ }
1339
+ }
1340
+ return stats;
1341
+ };
1342
+
1343
+ const summary = getFileSummary(worktreeDir);
1344
+ logger.info(`Final Workspace Summary (noGit): ${summary.files} files, ${summary.dirs} directories created/modified`);
1345
+ } else {
1346
+ try {
1347
+ // Always use current branch for comparison (already captured at start)
1348
+ const stats = git.runGit(['diff', '--stat', currentBranch, pipelineBranch], { cwd: repoRoot, silent: true });
1349
+ if (stats) {
1350
+ logger.info('Final Workspace Summary (Git):\n' + stats);
1351
+ }
1352
+ } catch (e) {
1353
+ // Ignore
1354
+ }
1355
+ }
1356
+
1357
+ logger.success('All tasks completed!');
1358
+ return results;
1359
+ }
1360
+
1361
+ /**
1362
+ * CLI entry point
1363
+ */
1364
+ if (require.main === module) {
1365
+ const args = process.argv.slice(2);
1366
+
1367
+ if (args.length < 1) {
1368
+ console.error('Usage: node runner.js <tasks-file> --run-dir <dir> --executor <executor>');
1369
+ process.exit(1);
1370
+ }
1371
+
1372
+ const tasksFile = args[0]!;
1373
+ const runDirIdx = args.indexOf('--run-dir');
1374
+ const startIdxIdx = args.indexOf('--start-index');
1375
+ const pipelineBranchIdx = args.indexOf('--pipeline-branch');
1376
+ const worktreeDirIdx = args.indexOf('--worktree-dir');
1377
+ const noGit = args.includes('--no-git');
1378
+
1379
+ const runDir = runDirIdx >= 0 ? args[runDirIdx + 1]! : '.';
1380
+ const startIndex = startIdxIdx >= 0 ? parseInt(args[startIdxIdx + 1] || '0') : 0;
1381
+ const forcedPipelineBranch = pipelineBranchIdx >= 0 ? args[pipelineBranchIdx + 1] : null;
1382
+ const forcedWorktreeDir = worktreeDirIdx >= 0 ? args[worktreeDirIdx + 1] : null;
1383
+
1384
+ // Extract runId from runDir (format: .../runs/run-123/lanes/lane-name)
1385
+ const parts = runDir.split(path.sep);
1386
+ const runsIdx = parts.lastIndexOf('runs');
1387
+ const runId = runsIdx >= 0 && parts[runsIdx + 1] ? parts[runsIdx + 1]! : `run-${Date.now()}`;
1388
+
1389
+ events.setRunId(runId);
1390
+
1391
+ // Load global config for defaults and webhooks
1392
+ let globalConfig;
1393
+ try {
1394
+ globalConfig = loadConfig();
1395
+ if (globalConfig.webhooks) {
1396
+ registerWebhooks(globalConfig.webhooks);
1397
+ }
1398
+ } catch (e) {
1399
+ // Non-blocking
1400
+ }
1401
+
1402
+ if (!fs.existsSync(tasksFile)) {
1403
+ console.error(`Tasks file not found: ${tasksFile}`);
1404
+ process.exit(1);
1405
+ }
1406
+
1407
+ // Load tasks configuration
1408
+ let config: RunnerConfig;
1409
+ try {
1410
+ config = JSON.parse(fs.readFileSync(tasksFile, 'utf8')) as RunnerConfig;
1411
+ if (forcedPipelineBranch) {
1412
+ config.pipelineBranch = forcedPipelineBranch;
1413
+ }
1414
+ if (forcedWorktreeDir) {
1415
+ config.worktreeDir = forcedWorktreeDir;
1416
+ }
1417
+ } catch (error: any) {
1418
+ console.error(`Failed to load tasks file: ${error.message}`);
1419
+ process.exit(1);
1420
+ }
1421
+
1422
+ // Add defaults from global config or hardcoded
1423
+ config.dependencyPolicy = config.dependencyPolicy || {
1424
+ allowDependencyChange: globalConfig?.allowDependencyChange ?? false,
1425
+ lockfileReadOnly: globalConfig?.lockfileReadOnly ?? true,
1426
+ };
1427
+
1428
+ // Add agent output format default
1429
+ config.agentOutputFormat = config.agentOutputFormat || globalConfig?.agentOutputFormat || 'stream-json';
1430
+
1431
+ // Run tasks
1432
+ runTasks(tasksFile, config, runDir, { startIndex, noGit })
1433
+ .then(() => {
1434
+ process.exit(0);
1435
+ })
1436
+ .catch(error => {
1437
+ console.error(`Runner failed: ${error.message}`);
1438
+ if (process.env['DEBUG']) {
1439
+ console.error(error.stack);
1440
+ }
1441
+ process.exit(1);
1442
+ });
1443
+ }