@litmers/cursorflow-orchestrator 0.1.15 → 0.1.20

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 (90) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/README.md +26 -7
  3. package/commands/cursorflow-run.md +2 -0
  4. package/commands/cursorflow-triggers.md +250 -0
  5. package/dist/cli/clean.js +8 -7
  6. package/dist/cli/clean.js.map +1 -1
  7. package/dist/cli/index.js +5 -1
  8. package/dist/cli/index.js.map +1 -1
  9. package/dist/cli/init.js +20 -14
  10. package/dist/cli/init.js.map +1 -1
  11. package/dist/cli/logs.js +64 -47
  12. package/dist/cli/logs.js.map +1 -1
  13. package/dist/cli/monitor.js +27 -17
  14. package/dist/cli/monitor.js.map +1 -1
  15. package/dist/cli/prepare.js +73 -33
  16. package/dist/cli/prepare.js.map +1 -1
  17. package/dist/cli/resume.js +193 -40
  18. package/dist/cli/resume.js.map +1 -1
  19. package/dist/cli/run.js +3 -2
  20. package/dist/cli/run.js.map +1 -1
  21. package/dist/cli/signal.js +7 -7
  22. package/dist/cli/signal.js.map +1 -1
  23. package/dist/core/orchestrator.d.ts +2 -1
  24. package/dist/core/orchestrator.js +54 -93
  25. package/dist/core/orchestrator.js.map +1 -1
  26. package/dist/core/reviewer.d.ts +6 -4
  27. package/dist/core/reviewer.js +7 -5
  28. package/dist/core/reviewer.js.map +1 -1
  29. package/dist/core/runner.d.ts +8 -0
  30. package/dist/core/runner.js +219 -32
  31. package/dist/core/runner.js.map +1 -1
  32. package/dist/utils/config.js +20 -10
  33. package/dist/utils/config.js.map +1 -1
  34. package/dist/utils/doctor.js +35 -7
  35. package/dist/utils/doctor.js.map +1 -1
  36. package/dist/utils/enhanced-logger.d.ts +2 -2
  37. package/dist/utils/enhanced-logger.js +114 -43
  38. package/dist/utils/enhanced-logger.js.map +1 -1
  39. package/dist/utils/git.js +163 -10
  40. package/dist/utils/git.js.map +1 -1
  41. package/dist/utils/log-formatter.d.ts +16 -0
  42. package/dist/utils/log-formatter.js +194 -0
  43. package/dist/utils/log-formatter.js.map +1 -0
  44. package/dist/utils/path.d.ts +19 -0
  45. package/dist/utils/path.js +77 -0
  46. package/dist/utils/path.js.map +1 -0
  47. package/dist/utils/repro-thinking-logs.d.ts +1 -0
  48. package/dist/utils/repro-thinking-logs.js +80 -0
  49. package/dist/utils/repro-thinking-logs.js.map +1 -0
  50. package/dist/utils/state.d.ts +4 -1
  51. package/dist/utils/state.js +11 -8
  52. package/dist/utils/state.js.map +1 -1
  53. package/dist/utils/template.d.ts +14 -0
  54. package/dist/utils/template.js +122 -0
  55. package/dist/utils/template.js.map +1 -0
  56. package/dist/utils/types.d.ts +13 -0
  57. package/dist/utils/webhook.js +3 -0
  58. package/dist/utils/webhook.js.map +1 -1
  59. package/package.json +4 -2
  60. package/scripts/ai-security-check.js +3 -0
  61. package/scripts/local-security-gate.sh +9 -1
  62. package/scripts/verify-and-fix.sh +37 -0
  63. package/src/cli/clean.ts +8 -7
  64. package/src/cli/index.ts +5 -1
  65. package/src/cli/init.ts +19 -15
  66. package/src/cli/logs.ts +67 -47
  67. package/src/cli/monitor.ts +28 -18
  68. package/src/cli/prepare.ts +75 -35
  69. package/src/cli/resume.ts +810 -626
  70. package/src/cli/run.ts +3 -2
  71. package/src/cli/signal.ts +7 -6
  72. package/src/core/orchestrator.ts +68 -93
  73. package/src/core/reviewer.ts +14 -9
  74. package/src/core/runner.ts +229 -33
  75. package/src/utils/config.ts +19 -11
  76. package/src/utils/doctor.ts +38 -7
  77. package/src/utils/enhanced-logger.ts +117 -49
  78. package/src/utils/git.ts +145 -11
  79. package/src/utils/log-formatter.ts +162 -0
  80. package/src/utils/path.ts +45 -0
  81. package/src/utils/repro-thinking-logs.ts +54 -0
  82. package/src/utils/state.ts +16 -8
  83. package/src/utils/template.ts +92 -0
  84. package/src/utils/types.ts +13 -0
  85. package/src/utils/webhook.ts +3 -0
  86. package/templates/basic.json +21 -0
  87. package/scripts/simple-logging-test.sh +0 -97
  88. package/scripts/test-real-cursor-lifecycle.sh +0 -289
  89. package/scripts/test-real-logging.sh +0 -289
  90. package/scripts/test-streaming-multi-task.sh +0 -247
@@ -15,7 +15,8 @@ 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
+ import { safeJoin } from '../utils/path';
19
20
  import {
20
21
  RunnerConfig,
21
22
  Task,
@@ -191,6 +192,8 @@ export async function cursorAgentSend({ workspaceDir, chatId, prompt, model, sig
191
192
  const format = outputFormat || 'stream-json';
192
193
  const args = [
193
194
  '--print',
195
+ '--force',
196
+ '--approve-mcps',
194
197
  '--output-format', format,
195
198
  '--workspace', workspaceDir,
196
199
  ...(model ? ['--model', model] : []),
@@ -232,17 +235,18 @@ export async function cursorAgentSend({ workspaceDir, chatId, prompt, model, sig
232
235
  env: childEnv,
233
236
  });
234
237
 
235
- // Save PID to state if possible
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)
236
241
  if (child.pid && signalDir) {
237
242
  try {
238
- 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
+ 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
246
250
  }
247
251
  }
248
252
 
@@ -471,7 +475,7 @@ export function applyDependencyFilePermissions(worktreeDir: string, policy: Depe
471
475
  }
472
476
 
473
477
  for (const file of targets) {
474
- const filePath = path.join(worktreeDir, file);
478
+ const filePath = safeJoin(worktreeDir, file);
475
479
  if (!fs.existsSync(filePath)) continue;
476
480
 
477
481
  try {
@@ -484,6 +488,82 @@ export function applyDependencyFilePermissions(worktreeDir: string, policy: Depe
484
488
  }
485
489
  }
486
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
+
487
567
  /**
488
568
  * Run a single task
489
569
  */
@@ -509,7 +589,8 @@ export async function runTask({
509
589
  noGit?: boolean;
510
590
  }): Promise<TaskExecutionResult> {
511
591
  const model = task.model || config.model || 'sonnet-4.5';
512
- const convoPath = path.join(runDir, 'conversation.jsonl');
592
+ const timeout = task.timeout || config.timeout;
593
+ const convoPath = safeJoin(runDir, 'conversation.jsonl');
513
594
 
514
595
  logger.section(`[${index + 1}/${config.tasks.length}] ${task.name}`);
515
596
  logger.info(`Model: ${model}`);
@@ -555,7 +636,7 @@ export async function runTask({
555
636
  prompt: prompt1,
556
637
  model,
557
638
  signalDir: runDir,
558
- timeout: config.timeout,
639
+ timeout,
559
640
  enableIntervention: config.enableIntervention,
560
641
  outputFormat: config.agentOutputFormat,
561
642
  });
@@ -603,6 +684,37 @@ export async function runTask({
603
684
  if (!noGit) {
604
685
  git.push(taskBranch, { cwd: worktreeDir, setUpstream: true });
605
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
+ }
606
718
 
607
719
  events.emit('task.completed', {
608
720
  taskName: task.name,
@@ -670,18 +782,25 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
670
782
  const repoRoot = noGit ? process.cwd() : git.getRepoRoot();
671
783
 
672
784
  // Load existing state if resuming
673
- const statePath = path.join(runDir, 'state.json');
785
+ const statePath = safeJoin(runDir, 'state.json');
674
786
  let state: LaneState | null = null;
675
787
 
676
- if (startIndex > 0 && fs.existsSync(statePath)) {
677
- state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
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
+ }
678
794
  }
679
795
 
680
- const pipelineBranch = state?.pipelineBranch || config.pipelineBranch || `${config.branchPrefix || 'cursorflow/'}${Date.now().toString(36)}`;
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
+
681
799
  // In noGit mode, use a simple local directory instead of worktree
682
- const worktreeDir = state?.worktreeDir || (noGit
683
- ? path.join(repoRoot, config.worktreeRoot || '_cursorflow/workdir', pipelineBranch.replace(/\//g, '-'))
684
- : path.join(repoRoot, config.worktreeRoot || '_cursorflow/worktrees', pipelineBranch));
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, '-')));
685
804
 
686
805
  if (startIndex === 0) {
687
806
  logger.section('🚀 Starting Pipeline');
@@ -693,17 +812,54 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
693
812
  logger.info(`Worktree: ${worktreeDir}`);
694
813
  logger.info(`Tasks: ${config.tasks.length}`);
695
814
 
696
- // Create worktree only if starting fresh
697
- if (startIndex === 0 || !fs.existsSync(worktreeDir)) {
815
+ // Create worktree only if starting fresh and worktree doesn't exist
816
+ if (!fs.existsSync(worktreeDir)) {
698
817
  if (noGit) {
699
818
  // In noGit mode, just create the directory
700
819
  logger.info(`Creating work directory: ${worktreeDir}`);
701
820
  fs.mkdirSync(worktreeDir, { recursive: true });
702
821
  } else {
703
- git.createWorktree(worktreeDir, pipelineBranch, {
704
- baseBranch: config.baseBranch || 'main',
705
- cwd: repoRoot,
706
- });
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}`);
707
863
  }
708
864
  }
709
865
 
@@ -726,12 +882,17 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
726
882
  dependencyRequest: null,
727
883
  tasksFile, // Store tasks file for resume
728
884
  dependsOn: config.dependsOn || [],
885
+ completedTasks: [],
729
886
  };
730
887
  } else {
731
888
  state.status = 'running';
732
889
  state.error = null;
733
890
  state.dependencyRequest = null;
891
+ state.pipelineBranch = pipelineBranch;
892
+ state.worktreeDir = worktreeDir;
893
+ state.label = state.label || pipelineBranch;
734
894
  state.dependsOn = config.dependsOn || [];
895
+ state.completedTasks = state.completedTasks || [];
735
896
  }
736
897
 
737
898
  saveState(statePath, state);
@@ -744,8 +905,8 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
744
905
  const lanesRoot = path.dirname(runDir);
745
906
 
746
907
  for (const depName of config.dependsOn) {
747
- const depRunDir = path.join(lanesRoot, depName);
748
- const depStatePath = path.join(depRunDir, 'state.json');
908
+ const depRunDir = path.join(lanesRoot, depName); // nosemgrep
909
+ const depStatePath = path.join(depRunDir, 'state.json'); // nosemgrep
749
910
 
750
911
  if (!fs.existsSync(depStatePath)) {
751
912
  logger.warn(`Dependency state not found for ${depName} at ${depStatePath}`);
@@ -791,8 +952,8 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
791
952
  const lanesRoot = path.dirname(runDir);
792
953
 
793
954
  for (const depName of config.dependsOn) {
794
- const depRunDir = path.join(lanesRoot, depName);
795
- const depStatePath = path.join(depRunDir, 'state.json');
955
+ const depRunDir = safeJoin(lanesRoot, depName);
956
+ const depStatePath = safeJoin(depRunDir, 'state.json');
796
957
 
797
958
  if (!fs.existsSync(depStatePath)) {
798
959
  continue;
@@ -811,8 +972,8 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
811
972
  for (const entry of entries) {
812
973
  if (entry.name === '.git' || entry.name === '_cursorflow' || entry.name === 'node_modules') continue;
813
974
 
814
- const srcPath = path.join(src, entry.name);
815
- const destPath = path.join(dest, entry.name);
975
+ const srcPath = safeJoin(src, entry.name);
976
+ const destPath = safeJoin(dest, entry.name);
816
977
 
817
978
  if (entry.isDirectory()) {
818
979
  copyFiles(srcPath, destPath);
@@ -836,6 +997,32 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
836
997
  for (let i = startIndex; i < config.tasks.length; i++) {
837
998
  const task = config.tasks[i]!;
838
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
+ }
839
1026
 
840
1027
  const result = await runTask({
841
1028
  task,
@@ -853,6 +1040,10 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
853
1040
 
854
1041
  // Update state
855
1042
  state.currentTaskIndex = i + 1;
1043
+ state.completedTasks = state.completedTasks || [];
1044
+ if (!state.completedTasks.includes(task.name)) {
1045
+ state.completedTasks.push(task.name);
1046
+ }
856
1047
  saveState(statePath, state);
857
1048
 
858
1049
  // Handle blocked or error
@@ -914,7 +1105,7 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
914
1105
 
915
1106
  if (entry.isDirectory()) {
916
1107
  stats.dirs++;
917
- const sub = getFileSummary(path.join(dir, entry.name));
1108
+ const sub = getFileSummary(safeJoin(dir, entry.name));
918
1109
  stats.files += sub.files;
919
1110
  stats.dirs += sub.dirs;
920
1111
  } else {
@@ -956,11 +1147,13 @@ if (require.main === module) {
956
1147
  const runDirIdx = args.indexOf('--run-dir');
957
1148
  const startIdxIdx = args.indexOf('--start-index');
958
1149
  const pipelineBranchIdx = args.indexOf('--pipeline-branch');
1150
+ const worktreeDirIdx = args.indexOf('--worktree-dir');
959
1151
  const noGit = args.includes('--no-git');
960
1152
 
961
1153
  const runDir = runDirIdx >= 0 ? args[runDirIdx + 1]! : '.';
962
1154
  const startIndex = startIdxIdx >= 0 ? parseInt(args[startIdxIdx + 1] || '0') : 0;
963
1155
  const forcedPipelineBranch = pipelineBranchIdx >= 0 ? args[pipelineBranchIdx + 1] : null;
1156
+ const forcedWorktreeDir = worktreeDirIdx >= 0 ? args[worktreeDirIdx + 1] : null;
964
1157
 
965
1158
  // Extract runId from runDir (format: .../runs/run-123/lanes/lane-name)
966
1159
  const parts = runDir.split(path.sep);
@@ -992,6 +1185,9 @@ if (require.main === module) {
992
1185
  if (forcedPipelineBranch) {
993
1186
  config.pipelineBranch = forcedPipelineBranch;
994
1187
  }
1188
+ if (forcedWorktreeDir) {
1189
+ config.worktreeDir = forcedWorktreeDir;
1190
+ }
995
1191
  } catch (error: any) {
996
1192
  console.error(`Failed to load tasks file: ${error.message}`);
997
1193
  process.exit(1);
@@ -7,6 +7,7 @@
7
7
  import * as path from 'path';
8
8
  import * as fs from 'fs';
9
9
  import { CursorFlowConfig } from './types';
10
+ import { safeJoin } from './path';
10
11
  export { CursorFlowConfig };
11
12
 
12
13
  /**
@@ -16,8 +17,8 @@ export function findProjectRoot(cwd = process.cwd()): string {
16
17
  let current = cwd;
17
18
 
18
19
  while (current !== path.parse(current).root) {
19
- const packagePath = path.join(current, 'package.json');
20
- const configPath = path.join(current, 'cursorflow.config.js');
20
+ const packagePath = safeJoin(current, 'package.json');
21
+ const configPath = safeJoin(current, 'cursorflow.config.js');
21
22
 
22
23
  if (fs.existsSync(packagePath) || fs.existsSync(configPath)) {
23
24
  return current;
@@ -36,7 +37,7 @@ export function loadConfig(projectRoot: string | null = null): CursorFlowConfig
36
37
  projectRoot = findProjectRoot();
37
38
  }
38
39
 
39
- const configPath = path.join(projectRoot, 'cursorflow.config.js');
40
+ const configPath = safeJoin(projectRoot, 'cursorflow.config.js');
40
41
 
41
42
  // Default configuration
42
43
  const defaults: CursorFlowConfig = {
@@ -59,6 +60,7 @@ export function loadConfig(projectRoot: string | null = null): CursorFlowConfig
59
60
  // Review
60
61
  enableReview: false,
61
62
  reviewModel: 'sonnet-4.5-thinking',
63
+ reviewAllTasks: false,
62
64
  maxReviewIterations: 3,
63
65
 
64
66
  // Lane defaults
@@ -113,14 +115,14 @@ export function loadConfig(projectRoot: string | null = null): CursorFlowConfig
113
115
  * Get absolute path for tasks directory
114
116
  */
115
117
  export function getTasksDir(config: CursorFlowConfig): string {
116
- return path.join(config.projectRoot, config.tasksDir);
118
+ return safeJoin(config.projectRoot, config.tasksDir);
117
119
  }
118
120
 
119
121
  /**
120
122
  * Get absolute path for logs directory
121
123
  */
122
124
  export function getLogsDir(config: CursorFlowConfig): string {
123
- return path.join(config.projectRoot, config.logsDir);
125
+ return safeJoin(config.projectRoot, config.logsDir);
124
126
  }
125
127
 
126
128
  /**
@@ -156,11 +158,7 @@ export function validateConfig(config: CursorFlowConfig): boolean {
156
158
  * Create default config file
157
159
  */
158
160
  export function createDefaultConfig(projectRoot: string, force = false): string {
159
- const configPath = path.join(projectRoot, 'cursorflow.config.js');
160
-
161
- if (fs.existsSync(configPath) && !force) {
162
- throw new Error(`Config file already exists: ${configPath}`);
163
- }
161
+ const configPath = safeJoin(projectRoot, 'cursorflow.config.js');
164
162
 
165
163
  const template = `module.exports = {
166
164
  // Directory configuration
@@ -182,6 +180,7 @@ export function createDefaultConfig(projectRoot: string, force = false): string
182
180
  // Review configuration
183
181
  enableReview: false,
184
182
  reviewModel: 'sonnet-4.5-thinking',
183
+ reviewAllTasks: false,
185
184
  maxReviewIterations: 3,
186
185
 
187
186
  // Lane configuration
@@ -222,6 +221,15 @@ export function createDefaultConfig(projectRoot: string, force = false): string
222
221
  };
223
222
  `;
224
223
 
225
- fs.writeFileSync(configPath, template, 'utf8');
224
+ // Use atomic write with wx flag to avoid TOCTOU race condition (unless force is set)
225
+ try {
226
+ const writeFlag = force ? 'w' : 'wx';
227
+ fs.writeFileSync(configPath, template, { encoding: 'utf8', flag: writeFlag });
228
+ } catch (err: any) {
229
+ if (err.code === 'EEXIST') {
230
+ throw new Error(`Config file already exists: ${configPath}`);
231
+ }
232
+ throw err;
233
+ }
226
234
  return configPath;
227
235
  }
@@ -18,6 +18,7 @@ import * as path from 'path';
18
18
  import * as git from './git';
19
19
  import { checkCursorAgentInstalled, checkCursorAuth } from './cursor-agent';
20
20
  import { areCommandsInstalled } from '../cli/setup-commands';
21
+ import { safeJoin } from './path';
21
22
 
22
23
  export type DoctorSeverity = 'error' | 'warn';
23
24
 
@@ -149,7 +150,7 @@ function readLaneJsonFiles(tasksDir: string): { path: string; json: any; fileNam
149
150
  .readdirSync(tasksDir)
150
151
  .filter(f => f.endsWith('.json'))
151
152
  .sort()
152
- .map(f => path.join(tasksDir, f));
153
+ .map(f => safeJoin(tasksDir, f));
153
154
 
154
155
  return files.map(p => {
155
156
  const raw = fs.readFileSync(p, 'utf8');
@@ -428,7 +429,7 @@ function checkDiskSpace(dir: string): { ok: boolean; freeBytes?: number; error?:
428
429
  const { spawnSync } = require('child_process');
429
430
  try {
430
431
  // Validate and normalize the directory path to prevent command injection
431
- const safePath = path.resolve(dir);
432
+ const safePath = path.resolve(dir); // nosemgrep
432
433
 
433
434
  // Use spawnSync instead of execSync to avoid shell interpolation vulnerabilities
434
435
  // df -B1 returns bytes. We look for the line corresponding to our directory.
@@ -486,16 +487,46 @@ function validateBranchNames(
486
487
  const remoteBranches = getAllRemoteBranches(repoRoot);
487
488
  const allExistingBranches = new Set([...localBranches, ...remoteBranches]);
488
489
 
489
- // Collect branch prefixes from lanes
490
+ // Collect branch prefixes and pipeline branches from lanes
490
491
  const branchPrefixes: { laneName: string; prefix: string }[] = [];
492
+ const pipelineBranches: { laneName: string; branch: string }[] = [];
491
493
 
492
494
  for (const lane of lanes) {
493
495
  const branchPrefix = lane.json?.branchPrefix;
494
496
  if (branchPrefix) {
495
497
  branchPrefixes.push({ laneName: lane.fileName, prefix: branchPrefix });
496
498
  }
499
+
500
+ const pipelineBranch = lane.json?.pipelineBranch;
501
+ if (pipelineBranch) {
502
+ pipelineBranches.push({ laneName: lane.fileName, branch: pipelineBranch });
503
+ }
497
504
  }
498
505
 
506
+ // Check for pipeline branch collisions
507
+ const pipeMap = new Map<string, string[]>();
508
+ for (const { laneName, branch } of pipelineBranches) {
509
+ const existing = pipeMap.get(branch) || [];
510
+ existing.push(laneName);
511
+ pipeMap.set(branch, existing);
512
+ }
513
+
514
+ for (const [branch, laneNames] of pipeMap) {
515
+ if (laneNames.length > 1) {
516
+ addIssue(issues, {
517
+ id: 'branch.pipeline_collision',
518
+ severity: 'error',
519
+ title: 'Pipeline branch collision',
520
+ message: `Multiple lanes use the same pipelineBranch "${branch}": ${laneNames.join(', ')}`,
521
+ details: 'Each lane should have a unique pipelineBranch to avoid worktree conflicts during parallel execution.',
522
+ fixes: [
523
+ 'Update the pipelineBranch in each lane JSON file to be unique',
524
+ 'Or remove pipelineBranch to let CursorFlow generate unique ones',
525
+ ],
526
+ });
527
+ }
528
+ }
529
+
499
530
  // Check for branch prefix collisions between lanes
500
531
  const prefixMap = new Map<string, string[]>();
501
532
  for (const { laneName, prefix } of branchPrefixes) {
@@ -582,7 +613,7 @@ function validateBranchNames(
582
613
  const DOCTOR_STATUS_FILE = '.cursorflow/doctor-status.json';
583
614
 
584
615
  export function saveDoctorStatus(repoRoot: string, report: DoctorReport): void {
585
- const statusPath = path.join(repoRoot, DOCTOR_STATUS_FILE);
616
+ const statusPath = safeJoin(repoRoot, DOCTOR_STATUS_FILE);
586
617
  const statusDir = path.dirname(statusPath);
587
618
 
588
619
  if (!fs.existsSync(statusDir)) {
@@ -600,7 +631,7 @@ export function saveDoctorStatus(repoRoot: string, report: DoctorReport): void {
600
631
  }
601
632
 
602
633
  export function getDoctorStatus(repoRoot: string): { lastRun: number; ok: boolean; issueCount: number } | null {
603
- const statusPath = path.join(repoRoot, DOCTOR_STATUS_FILE);
634
+ const statusPath = safeJoin(repoRoot, DOCTOR_STATUS_FILE);
604
635
  if (!fs.existsSync(statusPath)) return null;
605
636
 
606
637
  try {
@@ -800,7 +831,7 @@ export function runDoctor(options: DoctorOptions = {}): DoctorReport {
800
831
  });
801
832
  } else {
802
833
  // Advanced check: .gitignore check for worktrees
803
- const gitignorePath = path.join(gitCwd, '.gitignore');
834
+ const gitignorePath = safeJoin(gitCwd, '.gitignore');
804
835
  const worktreeDirName = '_cursorflow'; // Default directory name
805
836
  if (fs.existsSync(gitignorePath)) {
806
837
  const content = fs.readFileSync(gitignorePath, 'utf8');
@@ -823,7 +854,7 @@ export function runDoctor(options: DoctorOptions = {}): DoctorReport {
823
854
  if (options.tasksDir) {
824
855
  const tasksDirAbs = path.isAbsolute(options.tasksDir)
825
856
  ? options.tasksDir
826
- : path.resolve(cwd, options.tasksDir);
857
+ : safeJoin(cwd, options.tasksDir);
827
858
  context.tasksDir = tasksDirAbs;
828
859
 
829
860
  if (!fs.existsSync(tasksDirAbs)) {