@litmers/cursorflow-orchestrator 0.1.15 → 0.1.18

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 (59) hide show
  1. package/CHANGELOG.md +7 -1
  2. package/README.md +1 -0
  3. package/commands/cursorflow-run.md +2 -0
  4. package/commands/cursorflow-triggers.md +250 -0
  5. package/dist/cli/clean.js +1 -1
  6. package/dist/cli/clean.js.map +1 -1
  7. package/dist/cli/init.js +13 -8
  8. package/dist/cli/init.js.map +1 -1
  9. package/dist/cli/logs.js +24 -15
  10. package/dist/cli/logs.js.map +1 -1
  11. package/dist/cli/monitor.js +12 -3
  12. package/dist/cli/monitor.js.map +1 -1
  13. package/dist/cli/prepare.js +36 -13
  14. package/dist/cli/prepare.js.map +1 -1
  15. package/dist/cli/resume.js.map +1 -1
  16. package/dist/core/orchestrator.js +10 -6
  17. package/dist/core/orchestrator.js.map +1 -1
  18. package/dist/core/reviewer.d.ts +6 -4
  19. package/dist/core/reviewer.js +7 -5
  20. package/dist/core/reviewer.js.map +1 -1
  21. package/dist/core/runner.d.ts +8 -0
  22. package/dist/core/runner.js +166 -14
  23. package/dist/core/runner.js.map +1 -1
  24. package/dist/utils/config.js +13 -4
  25. package/dist/utils/config.js.map +1 -1
  26. package/dist/utils/doctor.js +28 -1
  27. package/dist/utils/doctor.js.map +1 -1
  28. package/dist/utils/enhanced-logger.d.ts +2 -2
  29. package/dist/utils/enhanced-logger.js +102 -34
  30. package/dist/utils/enhanced-logger.js.map +1 -1
  31. package/dist/utils/repro-thinking-logs.d.ts +1 -0
  32. package/dist/utils/repro-thinking-logs.js +80 -0
  33. package/dist/utils/repro-thinking-logs.js.map +1 -0
  34. package/dist/utils/types.d.ts +12 -0
  35. package/dist/utils/webhook.js +3 -0
  36. package/dist/utils/webhook.js.map +1 -1
  37. package/package.json +4 -2
  38. package/scripts/ai-security-check.js +3 -0
  39. package/scripts/local-security-gate.sh +9 -1
  40. package/scripts/verify-and-fix.sh +37 -0
  41. package/src/cli/clean.ts +1 -1
  42. package/src/cli/init.ts +12 -9
  43. package/src/cli/logs.ts +25 -15
  44. package/src/cli/monitor.ts +13 -4
  45. package/src/cli/prepare.ts +36 -15
  46. package/src/cli/resume.ts +1 -1
  47. package/src/core/orchestrator.ts +10 -6
  48. package/src/core/reviewer.ts +14 -9
  49. package/src/core/runner.ts +173 -15
  50. package/src/utils/config.ts +12 -5
  51. package/src/utils/doctor.ts +31 -1
  52. package/src/utils/enhanced-logger.ts +105 -40
  53. package/src/utils/repro-thinking-logs.ts +54 -0
  54. package/src/utils/types.ts +12 -0
  55. package/src/utils/webhook.ts +3 -0
  56. package/scripts/simple-logging-test.sh +0 -97
  57. package/scripts/test-real-cursor-lifecycle.sh +0 -289
  58. package/scripts/test-real-logging.sh +0 -289
  59. package/scripts/test-streaming-multi-task.sh +0 -247
@@ -97,12 +97,16 @@ Prepare task files for a new feature - Terminal-first workflow.
97
97
  --prompt <text> Task prompt (uses preset or single task)
98
98
  --criteria <list> Comma-separated acceptance criteria
99
99
  --model <model> Model to use (default: sonnet-4.5)
100
- --task <spec> Full task spec: "name|model|prompt|criteria" (repeatable)
100
+ --task <spec> Full task spec: "name|model|prompt|criteria|dependsOn|timeout" (repeatable)
101
101
 
102
102
  Dependencies:
103
103
  --sequential Chain lanes: 1 → 2 → 3
104
104
  --deps <spec> Custom dependencies: "2:1;3:1,2"
105
105
  --depends-on <lanes> Dependencies for --add-lane: "01-lane-1,02-lane-2"
106
+ Task-level deps: In --task, add "lane:task" at the end.
107
+ Example: "test|sonnet-4.5|Run tests|All pass|01-lane-1:setup"
108
+ Task-level timeout: In --task, add milliseconds at the end.
109
+ Example: "heavy|sonnet-4.5|Big task|Done||1200000"
106
110
 
107
111
  Incremental (add to existing):
108
112
  --add-lane <dir> Add a new lane to existing task directory
@@ -203,23 +207,31 @@ function parseArgs(args: string[]): PrepareOptions {
203
207
  }
204
208
 
205
209
  function parseTaskSpec(spec: string): Task {
206
- // Format: "name|model|prompt|criteria1,criteria2"
210
+ // Format: "name|model|prompt|criteria1,criteria2|lane:task1,lane:task2|timeoutMs"
207
211
  const parts = spec.split('|');
208
212
 
209
213
  if (parts.length < 3) {
210
- throw new Error(`Invalid task spec: "${spec}". Expected format: "name|model|prompt[|criteria1,criteria2]"`);
214
+ throw new Error(`Invalid task spec: "${spec}". Expected format: "name|model|prompt[|criteria[|dependsOn[|timeout]]]"`);
211
215
  }
212
216
 
213
- const [name, model, prompt, criteriaStr] = parts;
217
+ const [name, model, prompt, criteriaStr, depsStr, timeoutStr] = parts;
214
218
  const acceptanceCriteria = criteriaStr
215
219
  ? criteriaStr.split(',').map(c => c.trim()).filter(c => c)
216
220
  : undefined;
217
221
 
222
+ const dependsOn = depsStr
223
+ ? depsStr.split(',').map(d => d.trim()).filter(d => d)
224
+ : undefined;
225
+
226
+ const timeout = timeoutStr ? parseInt(timeoutStr) : undefined;
227
+
218
228
  return {
219
229
  name: name.trim(),
220
230
  model: model.trim() || 'sonnet-4.5',
221
231
  prompt: prompt.trim(),
222
232
  ...(acceptanceCriteria && acceptanceCriteria.length > 0 ? { acceptanceCriteria } : {}),
233
+ ...(dependsOn && dependsOn.length > 0 ? { dependsOn } : {}),
234
+ ...(timeout ? { timeout } : {}),
223
235
  };
224
236
  }
225
237
 
@@ -612,10 +624,6 @@ async function addLaneToDir(options: PrepareOptions): Promise<void> {
612
624
  const fileName = `${laneNumber.toString().padStart(2, '0')}-${laneName}.json`;
613
625
  const filePath = path.join(taskDir, fileName);
614
626
 
615
- if (fs.existsSync(filePath) && !options.force) {
616
- throw new Error(`Lane file already exists: ${filePath}. Use --force to overwrite.`);
617
- }
618
-
619
627
  const hasDependencies = options.dependsOnLanes.length > 0;
620
628
 
621
629
  // Build tasks from options (auto-detects merge preset if has dependencies)
@@ -628,7 +636,16 @@ async function addLaneToDir(options: PrepareOptions): Promise<void> {
628
636
  ...(hasDependencies ? { dependsOn: options.dependsOnLanes } : {}),
629
637
  };
630
638
 
631
- fs.writeFileSync(filePath, JSON.stringify(finalConfig, null, 2) + '\n', 'utf8');
639
+ // Use atomic write with wx flag to avoid TOCTOU race condition (unless force is set)
640
+ try {
641
+ const writeFlag = options.force ? 'w' : 'wx';
642
+ fs.writeFileSync(filePath, JSON.stringify(finalConfig, null, 2) + '\n', { encoding: 'utf8', flag: writeFlag });
643
+ } catch (err: any) {
644
+ if (err.code === 'EEXIST') {
645
+ throw new Error(`Lane file already exists: ${filePath}. Use --force to overwrite.`);
646
+ }
647
+ throw err;
648
+ }
632
649
 
633
650
  const taskSummary = tasks.map(t => t.name).join(' → ');
634
651
  const depsInfo = hasDependencies ? ` (depends: ${options.dependsOnLanes.join(', ')})` : '';
@@ -645,16 +662,20 @@ async function addLaneToDir(options: PrepareOptions): Promise<void> {
645
662
  async function addTaskToLane(options: PrepareOptions): Promise<void> {
646
663
  const laneFile = path.resolve(process.cwd(), options.addTask!);
647
664
 
648
- if (!fs.existsSync(laneFile)) {
649
- throw new Error(`Lane file not found: ${laneFile}`);
650
- }
651
-
652
665
  if (options.taskSpecs.length === 0) {
653
666
  throw new Error('No task specified. Use --task "name|model|prompt|criteria" to define a task.');
654
667
  }
655
668
 
656
- // Read existing config
657
- const existingConfig = JSON.parse(fs.readFileSync(laneFile, 'utf8'));
669
+ // Read existing config - let the error propagate if file doesn't exist (avoids TOCTOU)
670
+ let existingConfig: any;
671
+ try {
672
+ existingConfig = JSON.parse(fs.readFileSync(laneFile, 'utf8'));
673
+ } catch (err: any) {
674
+ if (err.code === 'ENOENT') {
675
+ throw new Error(`Lane file not found: ${laneFile}`);
676
+ }
677
+ throw err;
678
+ }
658
679
 
659
680
  if (!existingConfig.tasks || !Array.isArray(existingConfig.tasks)) {
660
681
  existingConfig.tasks = [];
package/src/cli/resume.ts CHANGED
@@ -7,7 +7,7 @@ import * as fs from 'fs';
7
7
  import { spawn, ChildProcess } from 'child_process';
8
8
  import * as logger from '../utils/logger';
9
9
  import { loadConfig, getLogsDir } from '../utils/config';
10
- import { loadState, listLanesInRun } from '../utils/state';
10
+ import { loadState } from '../utils/state';
11
11
  import { LaneState } from '../utils/types';
12
12
  import { runDoctor } from '../utils/doctor';
13
13
 
@@ -153,13 +153,16 @@ export function spawnLane({
153
153
  case 'system':
154
154
  prefix = `${logger.COLORS.gray}⚙️ SYS${logger.COLORS.reset}`;
155
155
  break;
156
+ case 'thinking':
157
+ prefix = `${logger.COLORS.gray}🤔 THNK${logger.COLORS.reset}`;
158
+ break;
156
159
  }
157
160
 
158
161
  if (prefix) {
159
162
  const lines = content.split('\n');
160
163
  const tsPrefix = `${logger.COLORS.gray}[${ts}]${logger.COLORS.reset} ${logger.COLORS.magenta}${laneLabel}${logger.COLORS.reset}`;
161
164
 
162
- if (msg.type === 'user' || msg.type === 'assistant' || msg.type === 'result') {
165
+ if (msg.type === 'user' || msg.type === 'assistant' || msg.type === 'result' || msg.type === 'thinking') {
163
166
  const header = `${prefix} ┌${'─'.repeat(60)}`;
164
167
  process.stdout.write(`${tsPrefix} ${header}\n`);
165
168
  for (const line of lines) {
@@ -202,8 +205,7 @@ export function spawnLane({
202
205
  if (trimmed &&
203
206
  !trimmed.startsWith('{') &&
204
207
  !trimmed.startsWith('[') &&
205
- !trimmed.includes('{"type"') &&
206
- !trimmed.includes('Heartbeat:')) {
208
+ !trimmed.includes('{"type"')) {
207
209
  process.stdout.write(`${logger.COLORS.gray}[${new Date().toLocaleTimeString('en-US', { hour12: false })}]${logger.COLORS.reset} ${logger.COLORS.magenta}${laneName.padEnd(10)}${logger.COLORS.reset} ${line}\n`);
208
210
  }
209
211
  }
@@ -412,7 +414,8 @@ async function resolveAllDependencies(
412
414
  const task = taskConfig.tasks[currentIdx];
413
415
 
414
416
  if (task) {
415
- const taskBranch = `${pipelineBranch}--${String(currentIdx + 1).padStart(2, '0')}-${task.name}`;
417
+ const lanePipelineBranch = `${pipelineBranch}/${lane.name}`;
418
+ const taskBranch = `${lanePipelineBranch}--${String(currentIdx + 1).padStart(2, '0')}-${task.name}`;
416
419
  logger.info(`Syncing lane ${lane.name} branch ${taskBranch}`);
417
420
 
418
421
  try {
@@ -467,7 +470,8 @@ export async function orchestrate(tasksDir: string, options: {
467
470
 
468
471
  fs.mkdirSync(runRoot, { recursive: true });
469
472
 
470
- const pipelineBranch = `cursorflow/run-${Date.now().toString(36)}`;
473
+ const randomSuffix = Math.random().toString(36).substring(2, 7);
474
+ const pipelineBranch = `cursorflow/run-${Date.now().toString(36)}-${randomSuffix}`;
471
475
 
472
476
  // Initialize event system
473
477
  events.setRunId(runId);
@@ -570,7 +574,7 @@ export async function orchestrate(tasksDir: string, options: {
570
574
  laneRunDir: laneRunDirs[lane.name]!,
571
575
  executor: options.executor || 'cursor-agent',
572
576
  startIndex: lane.startIndex,
573
- pipelineBranch,
577
+ pipelineBranch: `${pipelineBranch}/${lane.name}`,
574
578
  enhancedLogConfig: options.enhancedLogging,
575
579
  noGit: options.noGit,
576
580
  });
@@ -144,24 +144,25 @@ export function buildFeedbackPrompt(review: ReviewResult): string {
144
144
  /**
145
145
  * Review task
146
146
  */
147
- export async function reviewTask({ taskResult, worktreeDir, runDir, config, cursorAgentSend, cursorAgentCreateChat }: {
147
+ export async function reviewTask({ taskResult, worktreeDir, runDir, config, model, cursorAgentSend, cursorAgentCreateChat }: {
148
148
  taskResult: TaskResult;
149
149
  worktreeDir: string;
150
150
  runDir: string;
151
151
  config: RunnerConfig;
152
+ model?: string;
152
153
  cursorAgentSend: (options: {
153
154
  workspaceDir: string;
154
155
  chatId: string;
155
156
  prompt: string;
156
157
  model?: string;
157
158
  outputFormat?: 'stream-json' | 'json' | 'plain';
158
- }) => AgentSendResult;
159
+ }) => Promise<AgentSendResult>;
159
160
  cursorAgentCreateChat: () => string;
160
161
  }): Promise<ReviewResult> {
161
162
  const reviewPrompt = buildReviewPrompt({
162
163
  taskName: taskResult.taskName,
163
164
  taskBranch: taskResult.taskBranch,
164
- acceptanceCriteria: config.acceptanceCriteria || [],
165
+ acceptanceCriteria: taskResult.acceptanceCriteria || config.acceptanceCriteria || [],
165
166
  });
166
167
 
167
168
  logger.info(`Reviewing: ${taskResult.taskName}`);
@@ -172,11 +173,13 @@ export async function reviewTask({ taskResult, worktreeDir, runDir, config, curs
172
173
  });
173
174
 
174
175
  const reviewChatId = cursorAgentCreateChat();
176
+ const reviewModel = model || config.reviewModel || config.model || 'sonnet-4.5';
177
+
175
178
  const reviewResult = await cursorAgentSend({
176
179
  workspaceDir: worktreeDir,
177
180
  chatId: reviewChatId,
178
181
  prompt: reviewPrompt,
179
- model: config.reviewModel || 'sonnet-4.5-thinking',
182
+ model: reviewModel,
180
183
  outputFormat: config.agentOutputFormat,
181
184
  });
182
185
 
@@ -186,7 +189,7 @@ export async function reviewTask({ taskResult, worktreeDir, runDir, config, curs
186
189
  const convoPath = path.join(runDir, 'conversation.jsonl');
187
190
  appendLog(convoPath, createConversationEntry('reviewer', reviewResult.resultText || 'No result', {
188
191
  task: taskResult.taskName,
189
- model: config.reviewModel,
192
+ model: reviewModel,
190
193
  }));
191
194
 
192
195
  logger.info(`Review result: ${review.status} (${review.issues?.length || 0} issues)`);
@@ -204,20 +207,21 @@ export async function reviewTask({ taskResult, worktreeDir, runDir, config, curs
204
207
  /**
205
208
  * Review loop with feedback
206
209
  */
207
- export async function runReviewLoop({ taskResult, worktreeDir, runDir, config, workChatId, cursorAgentSend, cursorAgentCreateChat }: {
210
+ export async function runReviewLoop({ taskResult, worktreeDir, runDir, config, workChatId, model, cursorAgentSend, cursorAgentCreateChat }: {
208
211
  taskResult: TaskResult;
209
212
  worktreeDir: string;
210
213
  runDir: string;
211
214
  config: RunnerConfig;
212
215
  workChatId: string;
216
+ model?: string;
213
217
  cursorAgentSend: (options: {
214
218
  workspaceDir: string;
215
219
  chatId: string;
216
220
  prompt: string;
217
221
  model?: string;
218
222
  outputFormat?: 'stream-json' | 'json' | 'plain';
219
- }) => AgentSendResult;
220
- cursorAgentCreateChat: () => string;
223
+ }) => Promise<AgentSendResult>;
224
+ cursorAgentCreateChat: () => string;
221
225
  }): Promise<{ approved: boolean; review: ReviewResult; iterations: number; error?: string }> {
222
226
  const maxIterations = config.maxReviewIterations || 3;
223
227
  let iteration = 0;
@@ -229,10 +233,11 @@ export async function runReviewLoop({ taskResult, worktreeDir, runDir, config, w
229
233
  worktreeDir,
230
234
  runDir,
231
235
  config,
236
+ model,
232
237
  cursorAgentSend,
233
238
  cursorAgentCreateChat,
234
239
  });
235
-
240
+
236
241
  if (currentReview.status === 'approved') {
237
242
  logger.success(`Review passed: ${taskResult.taskName} (iteration ${iteration + 1})`);
238
243
  events.emit('review.approved', {
@@ -15,7 +15,7 @@ import { saveState, appendLog, createConversationEntry } from '../utils/state';
15
15
  import { events } from '../utils/events';
16
16
  import { loadConfig } from '../utils/config';
17
17
  import { registerWebhooks } from '../utils/webhook';
18
- import { stripAnsi } from '../utils/enhanced-logger';
18
+ import { runReviewLoop } from './reviewer';
19
19
  import {
20
20
  RunnerConfig,
21
21
  Task,
@@ -191,6 +191,8 @@ export async function cursorAgentSend({ workspaceDir, chatId, prompt, model, sig
191
191
  const format = outputFormat || 'stream-json';
192
192
  const args = [
193
193
  '--print',
194
+ '--force',
195
+ '--approve-mcps',
194
196
  '--output-format', format,
195
197
  '--workspace', workspaceDir,
196
198
  ...(model ? ['--model', model] : []),
@@ -232,17 +234,18 @@ export async function cursorAgentSend({ workspaceDir, chatId, prompt, model, sig
232
234
  env: childEnv,
233
235
  });
234
236
 
235
- // Save PID to state if possible
237
+ logger.info(`Executing cursor-agent... (timeout: ${Math.round(timeoutMs / 1000)}s)`);
238
+
239
+ // Save PID to state if possible (avoid TOCTOU by reading directly)
236
240
  if (child.pid && signalDir) {
237
241
  try {
238
242
  const statePath = path.join(signalDir, 'state.json');
239
- if (fs.existsSync(statePath)) {
240
- const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
241
- state.pid = child.pid;
242
- fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
243
- }
244
- } catch (e) {
245
- // Best effort
243
+ // Read directly without existence check to avoid race condition
244
+ const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
245
+ state.pid = child.pid;
246
+ fs.writeFileSync(statePath, JSON.stringify(state, null, 2));
247
+ } catch {
248
+ // Best effort - file may not exist yet
246
249
  }
247
250
  }
248
251
 
@@ -484,6 +487,82 @@ export function applyDependencyFilePermissions(worktreeDir: string, policy: Depe
484
487
  }
485
488
  }
486
489
 
490
+ /**
491
+ * Wait for task-level dependencies to be completed by other lanes
492
+ */
493
+ export async function waitForTaskDependencies(deps: string[], runDir: string): Promise<void> {
494
+ if (!deps || deps.length === 0) return;
495
+
496
+ const lanesRoot = path.dirname(runDir);
497
+ const pendingDeps = new Set(deps);
498
+
499
+ logger.info(`Waiting for task dependencies: ${deps.join(', ')}`);
500
+
501
+ while (pendingDeps.size > 0) {
502
+ for (const dep of pendingDeps) {
503
+ const [laneName, taskName] = dep.split(':');
504
+ if (!laneName || !taskName) {
505
+ logger.warn(`Invalid dependency format: ${dep}. Expected "lane:task"`);
506
+ pendingDeps.delete(dep);
507
+ continue;
508
+ }
509
+
510
+ const depStatePath = path.join(lanesRoot, laneName, 'state.json');
511
+ if (fs.existsSync(depStatePath)) {
512
+ try {
513
+ const state = JSON.parse(fs.readFileSync(depStatePath, 'utf8')) as LaneState;
514
+ if (state.completedTasks && state.completedTasks.includes(taskName)) {
515
+ logger.info(`✓ Dependency met: ${dep}`);
516
+ pendingDeps.delete(dep);
517
+ } else if (state.status === 'failed') {
518
+ throw new Error(`Dependency failed: ${dep} (Lane ${laneName} failed)`);
519
+ }
520
+ } catch (e: any) {
521
+ if (e.message.includes('Dependency failed')) throw e;
522
+ // Ignore parse errors, file might be being written
523
+ }
524
+ }
525
+ }
526
+
527
+ if (pendingDeps.size > 0) {
528
+ await new Promise(resolve => setTimeout(resolve, 5000)); // Poll every 5 seconds
529
+ }
530
+ }
531
+ }
532
+
533
+ /**
534
+ * Merge branches from dependency lanes
535
+ */
536
+ export async function mergeDependencyBranches(deps: string[], runDir: string, worktreeDir: string): Promise<void> {
537
+ if (!deps || deps.length === 0) return;
538
+
539
+ const lanesRoot = path.dirname(runDir);
540
+ const lanesToMerge = new Set(deps.map(d => d.split(':')[0]!));
541
+
542
+ for (const laneName of lanesToMerge) {
543
+ const depStatePath = path.join(lanesRoot, laneName, 'state.json');
544
+ if (!fs.existsSync(depStatePath)) continue;
545
+
546
+ try {
547
+ const state = JSON.parse(fs.readFileSync(depStatePath, 'utf8')) as LaneState;
548
+ if (state.pipelineBranch) {
549
+ logger.info(`Merging branch from ${laneName}: ${state.pipelineBranch}`);
550
+
551
+ // Ensure we have the latest
552
+ git.runGit(['fetch', 'origin', state.pipelineBranch], { cwd: worktreeDir, silent: true });
553
+
554
+ git.merge(state.pipelineBranch, {
555
+ cwd: worktreeDir,
556
+ noFf: true,
557
+ message: `chore: merge task dependency from ${laneName}`
558
+ });
559
+ }
560
+ } catch (e) {
561
+ logger.error(`Failed to merge branch from ${laneName}: ${e}`);
562
+ }
563
+ }
564
+ }
565
+
487
566
  /**
488
567
  * Run a single task
489
568
  */
@@ -509,6 +588,7 @@ export async function runTask({
509
588
  noGit?: boolean;
510
589
  }): Promise<TaskExecutionResult> {
511
590
  const model = task.model || config.model || 'sonnet-4.5';
591
+ const timeout = task.timeout || config.timeout;
512
592
  const convoPath = path.join(runDir, 'conversation.jsonl');
513
593
 
514
594
  logger.section(`[${index + 1}/${config.tasks.length}] ${task.name}`);
@@ -555,7 +635,7 @@ export async function runTask({
555
635
  prompt: prompt1,
556
636
  model,
557
637
  signalDir: runDir,
558
- timeout: config.timeout,
638
+ timeout,
559
639
  enableIntervention: config.enableIntervention,
560
640
  outputFormat: config.agentOutputFormat,
561
641
  });
@@ -603,6 +683,37 @@ export async function runTask({
603
683
  if (!noGit) {
604
684
  git.push(taskBranch, { cwd: worktreeDir, setUpstream: true });
605
685
  }
686
+
687
+ // Automatic Review
688
+ const reviewEnabled = config.reviewAllTasks || task.acceptanceCriteria?.length || config.enableReview;
689
+
690
+ if (reviewEnabled) {
691
+ logger.section(`🔍 Reviewing Task: ${task.name}`);
692
+ const reviewResult = await runReviewLoop({
693
+ taskResult: {
694
+ taskName: task.name,
695
+ taskBranch: taskBranch,
696
+ acceptanceCriteria: task.acceptanceCriteria,
697
+ },
698
+ worktreeDir,
699
+ runDir,
700
+ config,
701
+ workChatId: chatId,
702
+ model, // Use the same model as requested
703
+ cursorAgentSend,
704
+ cursorAgentCreateChat,
705
+ });
706
+
707
+ if (!reviewResult.approved) {
708
+ logger.error(`❌ Task review failed after ${reviewResult.iterations} iterations`);
709
+ return {
710
+ taskName: task.name,
711
+ taskBranch,
712
+ status: 'ERROR',
713
+ error: reviewResult.error || 'Task failed to pass review criteria',
714
+ };
715
+ }
716
+ }
606
717
 
607
718
  events.emit('task.completed', {
608
719
  taskName: task.name,
@@ -673,11 +784,16 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
673
784
  const statePath = path.join(runDir, 'state.json');
674
785
  let state: LaneState | null = null;
675
786
 
676
- if (startIndex > 0 && fs.existsSync(statePath)) {
677
- state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
787
+ if (fs.existsSync(statePath)) {
788
+ try {
789
+ state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
790
+ } catch (e) {
791
+ logger.warn(`Failed to load existing state from ${statePath}: ${e}`);
792
+ }
678
793
  }
679
794
 
680
- const pipelineBranch = state?.pipelineBranch || config.pipelineBranch || `${config.branchPrefix || 'cursorflow/'}${Date.now().toString(36)}`;
795
+ const randomSuffix = Math.random().toString(36).substring(2, 7);
796
+ const pipelineBranch = state?.pipelineBranch || config.pipelineBranch || `${config.branchPrefix || 'cursorflow/'}${Date.now().toString(36)}-${randomSuffix}`;
681
797
  // In noGit mode, use a simple local directory instead of worktree
682
798
  const worktreeDir = state?.worktreeDir || (noGit
683
799
  ? path.join(repoRoot, config.worktreeRoot || '_cursorflow/workdir', pipelineBranch.replace(/\//g, '-'))
@@ -693,8 +809,8 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
693
809
  logger.info(`Worktree: ${worktreeDir}`);
694
810
  logger.info(`Tasks: ${config.tasks.length}`);
695
811
 
696
- // Create worktree only if starting fresh
697
- if (startIndex === 0 || !fs.existsSync(worktreeDir)) {
812
+ // Create worktree only if starting fresh and worktree doesn't exist
813
+ if (!fs.existsSync(worktreeDir)) {
698
814
  if (noGit) {
699
815
  // In noGit mode, just create the directory
700
816
  logger.info(`Creating work directory: ${worktreeDir}`);
@@ -705,6 +821,16 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
705
821
  cwd: repoRoot,
706
822
  });
707
823
  }
824
+ } else if (!noGit) {
825
+ // If it exists but we are in Git mode, ensure it's actually a worktree and on the right branch
826
+ logger.info(`Reusing existing worktree: ${worktreeDir}`);
827
+ try {
828
+ git.runGit(['checkout', pipelineBranch], { cwd: worktreeDir });
829
+ } catch (e) {
830
+ // If checkout fails, maybe the worktree is in a weird state.
831
+ // For now, just log it. In a more robust impl, we might want to repair it.
832
+ logger.warn(`Failed to checkout branch ${pipelineBranch} in existing worktree: ${e}`);
833
+ }
708
834
  }
709
835
 
710
836
  // Create chat
@@ -726,12 +852,14 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
726
852
  dependencyRequest: null,
727
853
  tasksFile, // Store tasks file for resume
728
854
  dependsOn: config.dependsOn || [],
855
+ completedTasks: [],
729
856
  };
730
857
  } else {
731
858
  state.status = 'running';
732
859
  state.error = null;
733
860
  state.dependencyRequest = null;
734
861
  state.dependsOn = config.dependsOn || [];
862
+ state.completedTasks = state.completedTasks || [];
735
863
  }
736
864
 
737
865
  saveState(statePath, state);
@@ -836,6 +964,32 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
836
964
  for (let i = startIndex; i < config.tasks.length; i++) {
837
965
  const task = config.tasks[i]!;
838
966
  const taskBranch = `${pipelineBranch}--${String(i + 1).padStart(2, '0')}-${task.name}`;
967
+
968
+ // Handle task-level dependencies
969
+ if (task.dependsOn && task.dependsOn.length > 0) {
970
+ state.status = 'waiting';
971
+ state.waitingFor = task.dependsOn;
972
+ saveState(statePath, state);
973
+
974
+ try {
975
+ await waitForTaskDependencies(task.dependsOn, runDir);
976
+
977
+ if (!noGit) {
978
+ await mergeDependencyBranches(task.dependsOn, runDir, worktreeDir);
979
+ }
980
+
981
+ state.status = 'running';
982
+ state.waitingFor = [];
983
+ saveState(statePath, state);
984
+ } catch (e: any) {
985
+ state.status = 'failed';
986
+ state.waitingFor = [];
987
+ state.error = e.message;
988
+ saveState(statePath, state);
989
+ logger.error(`Task dependency wait/merge failed: ${e.message}`);
990
+ process.exit(1);
991
+ }
992
+ }
839
993
 
840
994
  const result = await runTask({
841
995
  task,
@@ -853,6 +1007,10 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
853
1007
 
854
1008
  // Update state
855
1009
  state.currentTaskIndex = i + 1;
1010
+ state.completedTasks = state.completedTasks || [];
1011
+ if (!state.completedTasks.includes(task.name)) {
1012
+ state.completedTasks.push(task.name);
1013
+ }
856
1014
  saveState(statePath, state);
857
1015
 
858
1016
  // Handle blocked or error
@@ -59,6 +59,7 @@ export function loadConfig(projectRoot: string | null = null): CursorFlowConfig
59
59
  // Review
60
60
  enableReview: false,
61
61
  reviewModel: 'sonnet-4.5-thinking',
62
+ reviewAllTasks: false,
62
63
  maxReviewIterations: 3,
63
64
 
64
65
  // Lane defaults
@@ -158,10 +159,6 @@ export function validateConfig(config: CursorFlowConfig): boolean {
158
159
  export function createDefaultConfig(projectRoot: string, force = false): string {
159
160
  const configPath = path.join(projectRoot, 'cursorflow.config.js');
160
161
 
161
- if (fs.existsSync(configPath) && !force) {
162
- throw new Error(`Config file already exists: ${configPath}`);
163
- }
164
-
165
162
  const template = `module.exports = {
166
163
  // Directory configuration
167
164
  tasksDir: '_cursorflow/tasks',
@@ -182,6 +179,7 @@ export function createDefaultConfig(projectRoot: string, force = false): string
182
179
  // Review configuration
183
180
  enableReview: false,
184
181
  reviewModel: 'sonnet-4.5-thinking',
182
+ reviewAllTasks: false,
185
183
  maxReviewIterations: 3,
186
184
 
187
185
  // Lane configuration
@@ -222,6 +220,15 @@ export function createDefaultConfig(projectRoot: string, force = false): string
222
220
  };
223
221
  `;
224
222
 
225
- fs.writeFileSync(configPath, template, 'utf8');
223
+ // Use atomic write with wx flag to avoid TOCTOU race condition (unless force is set)
224
+ try {
225
+ const writeFlag = force ? 'w' : 'wx';
226
+ fs.writeFileSync(configPath, template, { encoding: 'utf8', flag: writeFlag });
227
+ } catch (err: any) {
228
+ if (err.code === 'EEXIST') {
229
+ throw new Error(`Config file already exists: ${configPath}`);
230
+ }
231
+ throw err;
232
+ }
226
233
  return configPath;
227
234
  }
@@ -486,16 +486,46 @@ function validateBranchNames(
486
486
  const remoteBranches = getAllRemoteBranches(repoRoot);
487
487
  const allExistingBranches = new Set([...localBranches, ...remoteBranches]);
488
488
 
489
- // Collect branch prefixes from lanes
489
+ // Collect branch prefixes and pipeline branches from lanes
490
490
  const branchPrefixes: { laneName: string; prefix: string }[] = [];
491
+ const pipelineBranches: { laneName: string; branch: string }[] = [];
491
492
 
492
493
  for (const lane of lanes) {
493
494
  const branchPrefix = lane.json?.branchPrefix;
494
495
  if (branchPrefix) {
495
496
  branchPrefixes.push({ laneName: lane.fileName, prefix: branchPrefix });
496
497
  }
498
+
499
+ const pipelineBranch = lane.json?.pipelineBranch;
500
+ if (pipelineBranch) {
501
+ pipelineBranches.push({ laneName: lane.fileName, branch: pipelineBranch });
502
+ }
497
503
  }
498
504
 
505
+ // Check for pipeline branch collisions
506
+ const pipeMap = new Map<string, string[]>();
507
+ for (const { laneName, branch } of pipelineBranches) {
508
+ const existing = pipeMap.get(branch) || [];
509
+ existing.push(laneName);
510
+ pipeMap.set(branch, existing);
511
+ }
512
+
513
+ for (const [branch, laneNames] of pipeMap) {
514
+ if (laneNames.length > 1) {
515
+ addIssue(issues, {
516
+ id: 'branch.pipeline_collision',
517
+ severity: 'error',
518
+ title: 'Pipeline branch collision',
519
+ message: `Multiple lanes use the same pipelineBranch "${branch}": ${laneNames.join(', ')}`,
520
+ details: 'Each lane should have a unique pipelineBranch to avoid worktree conflicts during parallel execution.',
521
+ fixes: [
522
+ 'Update the pipelineBranch in each lane JSON file to be unique',
523
+ 'Or remove pipelineBranch to let CursorFlow generate unique ones',
524
+ ],
525
+ });
526
+ }
527
+ }
528
+
499
529
  // Check for branch prefix collisions between lanes
500
530
  const prefixMap = new Map<string, string[]>();
501
531
  for (const { laneName, prefix } of branchPrefixes) {