@litmers/cursorflow-orchestrator 0.1.31 → 0.1.34

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 (129) hide show
  1. package/README.md +144 -52
  2. package/commands/cursorflow-add.md +159 -0
  3. package/commands/cursorflow-monitor.md +23 -2
  4. package/commands/cursorflow-new.md +87 -0
  5. package/dist/cli/add.d.ts +7 -0
  6. package/dist/cli/add.js +377 -0
  7. package/dist/cli/add.js.map +1 -0
  8. package/dist/cli/clean.js +1 -0
  9. package/dist/cli/clean.js.map +1 -1
  10. package/dist/cli/config.d.ts +7 -0
  11. package/dist/cli/config.js +181 -0
  12. package/dist/cli/config.js.map +1 -0
  13. package/dist/cli/index.js +34 -30
  14. package/dist/cli/index.js.map +1 -1
  15. package/dist/cli/logs.js +7 -33
  16. package/dist/cli/logs.js.map +1 -1
  17. package/dist/cli/monitor.js +51 -62
  18. package/dist/cli/monitor.js.map +1 -1
  19. package/dist/cli/new.d.ts +7 -0
  20. package/dist/cli/new.js +232 -0
  21. package/dist/cli/new.js.map +1 -0
  22. package/dist/cli/prepare.js +95 -193
  23. package/dist/cli/prepare.js.map +1 -1
  24. package/dist/cli/resume.js +11 -47
  25. package/dist/cli/resume.js.map +1 -1
  26. package/dist/cli/run.js +27 -22
  27. package/dist/cli/run.js.map +1 -1
  28. package/dist/cli/tasks.js +1 -2
  29. package/dist/cli/tasks.js.map +1 -1
  30. package/dist/core/failure-policy.d.ts +9 -0
  31. package/dist/core/failure-policy.js +9 -0
  32. package/dist/core/failure-policy.js.map +1 -1
  33. package/dist/core/orchestrator.d.ts +20 -6
  34. package/dist/core/orchestrator.js +213 -333
  35. package/dist/core/orchestrator.js.map +1 -1
  36. package/dist/core/runner/agent.d.ts +27 -0
  37. package/dist/core/runner/agent.js +294 -0
  38. package/dist/core/runner/agent.js.map +1 -0
  39. package/dist/core/runner/index.d.ts +5 -0
  40. package/dist/core/runner/index.js +22 -0
  41. package/dist/core/runner/index.js.map +1 -0
  42. package/dist/core/runner/pipeline.d.ts +9 -0
  43. package/dist/core/runner/pipeline.js +539 -0
  44. package/dist/core/runner/pipeline.js.map +1 -0
  45. package/dist/core/runner/prompt.d.ts +25 -0
  46. package/dist/core/runner/prompt.js +175 -0
  47. package/dist/core/runner/prompt.js.map +1 -0
  48. package/dist/core/runner/task.d.ts +26 -0
  49. package/dist/core/runner/task.js +283 -0
  50. package/dist/core/runner/task.js.map +1 -0
  51. package/dist/core/runner/utils.d.ts +37 -0
  52. package/dist/core/runner/utils.js +161 -0
  53. package/dist/core/runner/utils.js.map +1 -0
  54. package/dist/core/runner.d.ts +2 -96
  55. package/dist/core/runner.js +11 -1136
  56. package/dist/core/runner.js.map +1 -1
  57. package/dist/core/stall-detection.d.ts +326 -0
  58. package/dist/core/stall-detection.js +781 -0
  59. package/dist/core/stall-detection.js.map +1 -0
  60. package/dist/types/config.d.ts +6 -6
  61. package/dist/types/flow.d.ts +84 -0
  62. package/dist/types/flow.js +10 -0
  63. package/dist/types/flow.js.map +1 -0
  64. package/dist/types/index.d.ts +1 -0
  65. package/dist/types/index.js +3 -3
  66. package/dist/types/index.js.map +1 -1
  67. package/dist/types/lane.d.ts +0 -2
  68. package/dist/types/logging.d.ts +5 -1
  69. package/dist/types/task.d.ts +7 -11
  70. package/dist/utils/config.js +7 -15
  71. package/dist/utils/config.js.map +1 -1
  72. package/dist/utils/dependency.d.ts +36 -1
  73. package/dist/utils/dependency.js +256 -1
  74. package/dist/utils/dependency.js.map +1 -1
  75. package/dist/utils/enhanced-logger.d.ts +45 -82
  76. package/dist/utils/enhanced-logger.js +238 -844
  77. package/dist/utils/enhanced-logger.js.map +1 -1
  78. package/dist/utils/git.d.ts +29 -0
  79. package/dist/utils/git.js +115 -5
  80. package/dist/utils/git.js.map +1 -1
  81. package/dist/utils/state.js +0 -2
  82. package/dist/utils/state.js.map +1 -1
  83. package/dist/utils/task-service.d.ts +2 -2
  84. package/dist/utils/task-service.js +40 -31
  85. package/dist/utils/task-service.js.map +1 -1
  86. package/package.json +4 -3
  87. package/src/cli/add.ts +397 -0
  88. package/src/cli/clean.ts +1 -0
  89. package/src/cli/config.ts +177 -0
  90. package/src/cli/index.ts +36 -32
  91. package/src/cli/logs.ts +7 -31
  92. package/src/cli/monitor.ts +55 -71
  93. package/src/cli/new.ts +235 -0
  94. package/src/cli/prepare.ts +98 -205
  95. package/src/cli/resume.ts +13 -56
  96. package/src/cli/run.ts +311 -306
  97. package/src/cli/tasks.ts +1 -2
  98. package/src/core/failure-policy.ts +9 -0
  99. package/src/core/orchestrator.ts +277 -378
  100. package/src/core/runner/agent.ts +314 -0
  101. package/src/core/runner/index.ts +6 -0
  102. package/src/core/runner/pipeline.ts +567 -0
  103. package/src/core/runner/prompt.ts +174 -0
  104. package/src/core/runner/task.ts +320 -0
  105. package/src/core/runner/utils.ts +142 -0
  106. package/src/core/runner.ts +8 -1347
  107. package/src/core/stall-detection.ts +936 -0
  108. package/src/types/config.ts +6 -6
  109. package/src/types/flow.ts +91 -0
  110. package/src/types/index.ts +15 -3
  111. package/src/types/lane.ts +0 -2
  112. package/src/types/logging.ts +5 -1
  113. package/src/types/task.ts +7 -11
  114. package/src/utils/config.ts +8 -16
  115. package/src/utils/dependency.ts +311 -2
  116. package/src/utils/enhanced-logger.ts +263 -927
  117. package/src/utils/git.ts +145 -5
  118. package/src/utils/state.ts +0 -2
  119. package/src/utils/task-service.ts +48 -40
  120. package/commands/cursorflow-review.md +0 -56
  121. package/commands/cursorflow-runs.md +0 -59
  122. package/dist/cli/runs.d.ts +0 -5
  123. package/dist/cli/runs.js +0 -214
  124. package/dist/cli/runs.js.map +0 -1
  125. package/dist/core/reviewer.d.ts +0 -66
  126. package/dist/core/reviewer.js +0 -265
  127. package/dist/core/reviewer.js.map +0 -1
  128. package/src/cli/runs.ts +0 -212
  129. package/src/core/reviewer.ts +0 -285
@@ -1,1362 +1,23 @@
1
1
  /**
2
2
  * Core Runner - Execute tasks sequentially in a lane
3
3
  *
4
- * Features:
5
- * - Enhanced retry with circuit breaker
6
- * - Checkpoint system for recovery
7
- * - State validation and repair
8
- * - Improved dependency management
4
+ * This file is now a wrapper around modular components in ./runner/
9
5
  */
10
6
 
11
7
  import * as fs from 'fs';
12
8
  import * as path from 'path';
13
- import { spawn, spawnSync } from 'child_process';
14
9
 
15
- import * as git from '../utils/git';
16
10
  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
11
  import { loadConfig } from '../utils/config';
21
12
  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 });
13
+ import { events } from '../utils/events';
14
+ import { RunnerConfig } from '../types';
1303
15
 
1304
- // Log changed files
1305
- const stats = git.getLastOperationStats(worktreeDir);
1306
- if (stats) {
1307
- logger.info('Changed files:\n' + stats);
1308
- }
16
+ // Re-export everything from modular components
17
+ export * from './runner/index';
1309
18
 
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
- }
19
+ // Import necessary parts for the CLI entry point
20
+ import { runTasks } from './runner/pipeline';
1360
21
 
1361
22
  /**
1362
23
  * CLI entry point
@@ -1426,7 +87,7 @@ if (require.main === module) {
1426
87
  };
1427
88
 
1428
89
  // Add agent output format default
1429
- config.agentOutputFormat = config.agentOutputFormat || globalConfig?.agentOutputFormat || 'stream-json';
90
+ config.agentOutputFormat = config.agentOutputFormat || globalConfig?.agentOutputFormat || 'json';
1430
91
 
1431
92
  // Run tasks
1432
93
  runTasks(tasksFile, config, runDir, { startIndex, noGit })