@litmers/cursorflow-orchestrator 0.1.18 → 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 (68) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +25 -7
  3. package/dist/cli/clean.js +7 -6
  4. package/dist/cli/clean.js.map +1 -1
  5. package/dist/cli/index.js +5 -1
  6. package/dist/cli/index.js.map +1 -1
  7. package/dist/cli/init.js +7 -6
  8. package/dist/cli/init.js.map +1 -1
  9. package/dist/cli/logs.js +50 -42
  10. package/dist/cli/logs.js.map +1 -1
  11. package/dist/cli/monitor.js +15 -14
  12. package/dist/cli/monitor.js.map +1 -1
  13. package/dist/cli/prepare.js +37 -20
  14. package/dist/cli/prepare.js.map +1 -1
  15. package/dist/cli/resume.js +193 -40
  16. package/dist/cli/resume.js.map +1 -1
  17. package/dist/cli/run.js +3 -2
  18. package/dist/cli/run.js.map +1 -1
  19. package/dist/cli/signal.js +7 -7
  20. package/dist/cli/signal.js.map +1 -1
  21. package/dist/core/orchestrator.d.ts +2 -1
  22. package/dist/core/orchestrator.js +48 -91
  23. package/dist/core/orchestrator.js.map +1 -1
  24. package/dist/core/runner.js +55 -20
  25. package/dist/core/runner.js.map +1 -1
  26. package/dist/utils/config.js +7 -6
  27. package/dist/utils/config.js.map +1 -1
  28. package/dist/utils/doctor.js +7 -6
  29. package/dist/utils/doctor.js.map +1 -1
  30. package/dist/utils/enhanced-logger.js +14 -11
  31. package/dist/utils/enhanced-logger.js.map +1 -1
  32. package/dist/utils/git.js +163 -10
  33. package/dist/utils/git.js.map +1 -1
  34. package/dist/utils/log-formatter.d.ts +16 -0
  35. package/dist/utils/log-formatter.js +194 -0
  36. package/dist/utils/log-formatter.js.map +1 -0
  37. package/dist/utils/path.d.ts +19 -0
  38. package/dist/utils/path.js +77 -0
  39. package/dist/utils/path.js.map +1 -0
  40. package/dist/utils/state.d.ts +4 -1
  41. package/dist/utils/state.js +11 -8
  42. package/dist/utils/state.js.map +1 -1
  43. package/dist/utils/template.d.ts +14 -0
  44. package/dist/utils/template.js +122 -0
  45. package/dist/utils/template.js.map +1 -0
  46. package/dist/utils/types.d.ts +1 -0
  47. package/package.json +1 -1
  48. package/src/cli/clean.ts +7 -6
  49. package/src/cli/index.ts +5 -1
  50. package/src/cli/init.ts +7 -6
  51. package/src/cli/logs.ts +52 -42
  52. package/src/cli/monitor.ts +15 -14
  53. package/src/cli/prepare.ts +39 -20
  54. package/src/cli/resume.ts +810 -626
  55. package/src/cli/run.ts +3 -2
  56. package/src/cli/signal.ts +7 -6
  57. package/src/core/orchestrator.ts +62 -91
  58. package/src/core/runner.ts +58 -20
  59. package/src/utils/config.ts +7 -6
  60. package/src/utils/doctor.ts +7 -6
  61. package/src/utils/enhanced-logger.ts +14 -11
  62. package/src/utils/git.ts +145 -11
  63. package/src/utils/log-formatter.ts +162 -0
  64. package/src/utils/path.ts +45 -0
  65. package/src/utils/state.ts +16 -8
  66. package/src/utils/template.ts +92 -0
  67. package/src/utils/types.ts +1 -0
  68. package/templates/basic.json +21 -0
package/src/cli/run.ts CHANGED
@@ -9,6 +9,7 @@ import { orchestrate } from '../core/orchestrator';
9
9
  import { getLogsDir, loadConfig } from '../utils/config';
10
10
  import { runDoctor, getDoctorStatus } from '../utils/doctor';
11
11
  import { areCommandsInstalled, setupCommands } from './setup-commands';
12
+ import { safeJoin } from '../utils/path';
12
13
 
13
14
  interface RunOptions {
14
15
  tasksDir?: string;
@@ -90,8 +91,8 @@ async function run(args: string[]): Promise<void> {
90
91
  path.isAbsolute(options.tasksDir)
91
92
  ? options.tasksDir
92
93
  : (fs.existsSync(options.tasksDir)
93
- ? path.resolve(process.cwd(), options.tasksDir)
94
- : path.join(config.projectRoot, options.tasksDir));
94
+ ? path.resolve(process.cwd(), options.tasksDir) // nosemgrep
95
+ : safeJoin(config.projectRoot, options.tasksDir));
95
96
 
96
97
  if (!fs.existsSync(tasksDir)) {
97
98
  throw new Error(`Tasks directory not found: ${tasksDir}`);
package/src/cli/signal.ts CHANGED
@@ -9,6 +9,7 @@ import * as fs from 'fs';
9
9
  import * as logger from '../utils/logger';
10
10
  import { loadConfig, getLogsDir } from '../utils/config';
11
11
  import { appendLog, createConversationEntry } from '../utils/state';
12
+ import { safeJoin } from '../utils/path';
12
13
 
13
14
  interface SignalOptions {
14
15
  lane: string | null;
@@ -51,7 +52,7 @@ function parseArgs(args: string[]): SignalOptions {
51
52
  }
52
53
 
53
54
  function findLatestRunDir(logsDir: string): string | null {
54
- const runsDir = path.join(logsDir, 'runs');
55
+ const runsDir = safeJoin(logsDir, 'runs');
55
56
  if (!fs.existsSync(runsDir)) return null;
56
57
 
57
58
  const runs = fs.readdirSync(runsDir)
@@ -59,7 +60,7 @@ function findLatestRunDir(logsDir: string): string | null {
59
60
  .sort()
60
61
  .reverse();
61
62
 
62
- return runs.length > 0 ? path.join(runsDir, runs[0]!) : null;
63
+ return runs.length > 0 ? safeJoin(runsDir, runs[0]!) : null;
63
64
  }
64
65
 
65
66
  async function signal(args: string[]): Promise<void> {
@@ -86,14 +87,14 @@ async function signal(args: string[]): Promise<void> {
86
87
  throw new Error(`Run directory not found: ${runDir || 'latest'}`);
87
88
  }
88
89
 
89
- const laneDir = path.join(runDir, 'lanes', options.lane);
90
+ const laneDir = safeJoin(runDir, 'lanes', options.lane);
90
91
  if (!fs.existsSync(laneDir)) {
91
92
  throw new Error(`Lane directory not found: ${laneDir}`);
92
93
  }
93
94
 
94
95
  // Case 1: Timeout update
95
96
  if (options.timeout !== null) {
96
- const timeoutPath = path.join(laneDir, 'timeout.txt');
97
+ const timeoutPath = safeJoin(laneDir, 'timeout.txt');
97
98
  fs.writeFileSync(timeoutPath, String(options.timeout));
98
99
  logger.success(`Timeout update signal sent to ${options.lane}: ${options.timeout}ms`);
99
100
  return;
@@ -101,8 +102,8 @@ async function signal(args: string[]): Promise<void> {
101
102
 
102
103
  // Case 2: Intervention message
103
104
  if (options.message) {
104
- const interventionPath = path.join(laneDir, 'intervention.txt');
105
- const convoPath = path.join(laneDir, 'conversation.jsonl');
105
+ const interventionPath = safeJoin(laneDir, 'intervention.txt');
106
+ const convoPath = safeJoin(laneDir, 'conversation.jsonl');
106
107
 
107
108
  logger.info(`Sending signal to lane: ${options.lane}`);
108
109
  logger.info(`Message: "${options.message}"`);
@@ -9,13 +9,14 @@ import * as path from 'path';
9
9
  import { spawn, ChildProcess } from 'child_process';
10
10
 
11
11
  import * as logger from '../utils/logger';
12
- import { loadState } from '../utils/state';
12
+ import { loadState, saveState, createLaneState } from '../utils/state';
13
13
  import { LaneState, RunnerConfig, WebhookConfig, DependencyRequestPlan, EnhancedLogConfig } from '../utils/types';
14
14
  import { events } from '../utils/events';
15
15
  import { registerWebhooks } from '../utils/webhook';
16
16
  import { loadConfig, getLogsDir } from '../utils/config';
17
17
  import * as git from '../utils/git';
18
18
  import { execSync } from 'child_process';
19
+ import { safeJoin } from '../utils/path';
19
20
  import {
20
21
  EnhancedLogManager,
21
22
  createLogManager,
@@ -23,6 +24,7 @@ import {
23
24
  ParsedMessage,
24
25
  stripAnsi
25
26
  } from '../utils/enhanced-logger';
27
+ import { formatMessageForConsole } from '../utils/log-formatter';
26
28
 
27
29
  export interface LaneInfo {
28
30
  name: string;
@@ -47,6 +49,7 @@ export function spawnLane({
47
49
  executor,
48
50
  startIndex = 0,
49
51
  pipelineBranch,
52
+ worktreeDir,
50
53
  enhancedLogConfig,
51
54
  noGit = false,
52
55
  }: {
@@ -56,6 +59,7 @@ export function spawnLane({
56
59
  executor: string;
57
60
  startIndex?: number;
58
61
  pipelineBranch?: string;
62
+ worktreeDir?: string;
59
63
  enhancedLogConfig?: Partial<EnhancedLogConfig>;
60
64
  noGit?: boolean;
61
65
  }): SpawnLaneResult {
@@ -75,6 +79,10 @@ export function spawnLane({
75
79
  if (pipelineBranch) {
76
80
  args.push('--pipeline-branch', pipelineBranch);
77
81
  }
82
+
83
+ if (worktreeDir) {
84
+ args.push('--worktree-dir', worktreeDir);
85
+ }
78
86
 
79
87
  if (noGit) {
80
88
  args.push('--no-git');
@@ -94,85 +102,11 @@ export function spawnLane({
94
102
  if (logConfig.enabled) {
95
103
  // Create callback for clean console output
96
104
  const onParsedMessage = (msg: ParsedMessage) => {
97
- // Print a clean, colored version of the message to the console
98
- const ts = new Date(msg.timestamp).toLocaleTimeString('en-US', { hour12: false });
99
- const laneLabel = `[${laneName}]`.padEnd(12);
100
-
101
- let prefix = '';
102
- let content = msg.content;
103
-
104
- switch (msg.type) {
105
- case 'user':
106
- prefix = `${logger.COLORS.cyan}🧑 USER${logger.COLORS.reset}`;
107
- // No truncation for user prompt to ensure full command visibility
108
- content = content.replace(/\n/g, ' ');
109
- break;
110
- case 'assistant':
111
- prefix = `${logger.COLORS.green}🤖 ASST${logger.COLORS.reset}`;
112
- break;
113
- case 'tool':
114
- prefix = `${logger.COLORS.yellow}🔧 TOOL${logger.COLORS.reset}`;
115
- // Simplify tool call: [Tool: read_file] {"target_file":"..."} -> read_file(target_file: ...)
116
- const toolMatch = content.match(/\[Tool: ([^\]]+)\] (.*)/);
117
- if (toolMatch) {
118
- const [, name, args] = toolMatch;
119
- try {
120
- const parsedArgs = JSON.parse(args!);
121
- let argStr = '';
122
-
123
- if (name === 'read_file' && parsedArgs.target_file) {
124
- argStr = parsedArgs.target_file;
125
- } else if (name === 'run_terminal_cmd' && parsedArgs.command) {
126
- argStr = parsedArgs.command;
127
- } else if (name === 'write' && parsedArgs.file_path) {
128
- argStr = parsedArgs.file_path;
129
- } else if (name === 'search_replace' && parsedArgs.file_path) {
130
- argStr = parsedArgs.file_path;
131
- } else {
132
- // Generic summary for other tools
133
- const keys = Object.keys(parsedArgs);
134
- if (keys.length > 0) {
135
- argStr = String(parsedArgs[keys[0]]).substring(0, 50);
136
- }
137
- }
138
- content = `${logger.COLORS.bold}${name}${logger.COLORS.reset}(${argStr})`;
139
- } catch {
140
- content = `${logger.COLORS.bold}${name}${logger.COLORS.reset}: ${args}`;
141
- }
142
- }
143
- break;
144
- case 'tool_result':
145
- prefix = `${logger.COLORS.gray}📄 RESL${logger.COLORS.reset}`;
146
- // Simplify tool result: [Tool Result: read_file] ... -> read_file OK
147
- const resMatch = content.match(/\[Tool Result: ([^\]]+)\]/);
148
- content = resMatch ? `${resMatch[1]} OK` : 'result';
149
- break;
150
- case 'result':
151
- prefix = `${logger.COLORS.green}✅ DONE${logger.COLORS.reset}`;
152
- break;
153
- case 'system':
154
- prefix = `${logger.COLORS.gray}⚙️ SYS${logger.COLORS.reset}`;
155
- break;
156
- case 'thinking':
157
- prefix = `${logger.COLORS.gray}🤔 THNK${logger.COLORS.reset}`;
158
- break;
159
- }
160
-
161
- if (prefix) {
162
- const lines = content.split('\n');
163
- const tsPrefix = `${logger.COLORS.gray}[${ts}]${logger.COLORS.reset} ${logger.COLORS.magenta}${laneLabel}${logger.COLORS.reset}`;
164
-
165
- if (msg.type === 'user' || msg.type === 'assistant' || msg.type === 'result' || msg.type === 'thinking') {
166
- const header = `${prefix} ┌${'─'.repeat(60)}`;
167
- process.stdout.write(`${tsPrefix} ${header}\n`);
168
- for (const line of lines) {
169
- process.stdout.write(`${tsPrefix} ${' '.repeat(stripAnsi(prefix).length)} │ ${line}\n`);
170
- }
171
- process.stdout.write(`${tsPrefix} ${' '.repeat(stripAnsi(prefix).length)} └${'─'.repeat(60)}\n`);
172
- } else {
173
- process.stdout.write(`${tsPrefix} ${prefix} ${content}\n`);
174
- }
175
- }
105
+ const formatted = formatMessageForConsole(msg, {
106
+ laneLabel: `[${laneName}]`,
107
+ includeTimestamp: true
108
+ });
109
+ process.stdout.write(formatted + '\n');
176
110
  };
177
111
 
178
112
  logManager = createLogManager(laneRunDir, laneName, logConfig, onParsedMessage);
@@ -242,7 +176,7 @@ export function spawnLane({
242
176
  });
243
177
  } else {
244
178
  // Fallback to simple file logging
245
- logPath = path.join(laneRunDir, 'terminal.log');
179
+ logPath = safeJoin(laneRunDir, 'terminal.log');
246
180
  const logFd = fs.openSync(logPath, 'a');
247
181
 
248
182
  child = spawn('node', args, {
@@ -289,7 +223,7 @@ export function listLaneFiles(tasksDir: string): LaneInfo[] {
289
223
  .filter(f => f.endsWith('.json'))
290
224
  .sort()
291
225
  .map(f => {
292
- const filePath = path.join(tasksDir, f);
226
+ const filePath = safeJoin(tasksDir, f);
293
227
  const name = path.basename(f, '.json');
294
228
  let dependsOn: string[] = [];
295
229
 
@@ -316,7 +250,7 @@ export function printLaneStatus(lanes: LaneInfo[], laneRunDirs: Record<string, s
316
250
  const dir = laneRunDirs[lane.name];
317
251
  if (!dir) return { lane: lane.name, status: '(unknown)', task: '-' };
318
252
 
319
- const statePath = path.join(dir, 'state.json');
253
+ const statePath = safeJoin(dir, 'state.json');
320
254
  const state = loadState<LaneState>(statePath);
321
255
 
322
256
  if (!state) {
@@ -364,9 +298,9 @@ async function resolveAllDependencies(
364
298
 
365
299
  // 2. Setup a temporary worktree for resolution if needed, or use the first available one
366
300
  const firstLaneName = Array.from(blockedLanes.keys())[0]!;
367
- const statePath = path.join(laneRunDirs[firstLaneName]!, 'state.json');
301
+ const statePath = safeJoin(laneRunDirs[firstLaneName]!, 'state.json');
368
302
  const state = loadState<LaneState>(statePath);
369
- const worktreeDir = state?.worktreeDir || path.join(runRoot, 'resolution-worktree');
303
+ const worktreeDir = state?.worktreeDir || safeJoin(runRoot, 'resolution-worktree');
370
304
 
371
305
  if (!fs.existsSync(worktreeDir)) {
372
306
  logger.info(`Creating resolution worktree at ${worktreeDir}`);
@@ -405,7 +339,7 @@ async function resolveAllDependencies(
405
339
  const laneDir = laneRunDirs[lane.name];
406
340
  if (!laneDir) continue;
407
341
 
408
- const laneState = loadState<LaneState>(path.join(laneDir, 'state.json'));
342
+ const laneState = loadState<LaneState>(safeJoin(laneDir, 'state.json'));
409
343
  if (!laneState || laneState.status === 'completed' || laneState.status === 'failed') continue;
410
344
 
411
345
  // Merge pipelineBranch into the lane's current task branch
@@ -465,8 +399,8 @@ export async function orchestrate(tasksDir: string, options: {
465
399
  const runId = `run-${Date.now()}`;
466
400
  // Use absolute path for runRoot to avoid issues with subfolders
467
401
  const runRoot = options.runDir
468
- ? (path.isAbsolute(options.runDir) ? options.runDir : path.resolve(process.cwd(), options.runDir))
469
- : path.join(logsDir, 'runs', runId);
402
+ ? (path.isAbsolute(options.runDir) ? options.runDir : path.resolve(process.cwd(), options.runDir)) // nosemgrep
403
+ : safeJoin(logsDir, 'runs', runId);
470
404
 
471
405
  fs.mkdirSync(runRoot, { recursive: true });
472
406
 
@@ -499,9 +433,45 @@ export async function orchestrate(tasksDir: string, options: {
499
433
  }
500
434
 
501
435
  const laneRunDirs: Record<string, string> = {};
436
+ const laneWorktreeDirs: Record<string, string> = {};
437
+ const repoRoot = git.getRepoRoot();
438
+
502
439
  for (const lane of lanes) {
503
- laneRunDirs[lane.name] = path.join(runRoot, 'lanes', lane.name);
504
- fs.mkdirSync(laneRunDirs[lane.name], { recursive: true });
440
+ laneRunDirs[lane.name] = safeJoin(runRoot, 'lanes', lane.name);
441
+ fs.mkdirSync(laneRunDirs[lane.name]!, { recursive: true });
442
+
443
+ // Create initial state for ALL lanes so resume can find them even if they didn't start
444
+ try {
445
+ const taskConfig = JSON.parse(fs.readFileSync(lane.path, 'utf8')) as RunnerConfig;
446
+
447
+ // Calculate unique branch and worktree for this lane
448
+ const lanePipelineBranch = `${pipelineBranch}/${lane.name}`;
449
+
450
+ // Use a flat worktree directory name to avoid race conditions in parent directory creation
451
+ // repoRoot/_cursorflow/worktrees/cursorflow-run-xxx-lane-name
452
+ const laneWorktreeDir = safeJoin(
453
+ repoRoot,
454
+ taskConfig.worktreeRoot || '_cursorflow/worktrees',
455
+ lanePipelineBranch.replace(/\//g, '-')
456
+ );
457
+
458
+ // Ensure the parent directory exists before spawning the runner
459
+ // to avoid race conditions in git worktree add or fs operations
460
+ const worktreeParent = path.dirname(laneWorktreeDir);
461
+ if (!fs.existsSync(worktreeParent)) {
462
+ fs.mkdirSync(worktreeParent, { recursive: true });
463
+ }
464
+
465
+ laneWorktreeDirs[lane.name] = laneWorktreeDir;
466
+
467
+ const initialState = createLaneState(lane.name, taskConfig, lane.path, {
468
+ pipelineBranch: lanePipelineBranch,
469
+ worktreeDir: laneWorktreeDir
470
+ });
471
+ saveState(safeJoin(laneRunDirs[lane.name]!, 'state.json'), initialState);
472
+ } catch (e) {
473
+ logger.warn(`Failed to create initial state for lane ${lane.name}: ${e}`);
474
+ }
505
475
  }
506
476
 
507
477
  logger.section('🧭 Starting Orchestration');
@@ -575,6 +545,7 @@ export async function orchestrate(tasksDir: string, options: {
575
545
  executor: options.executor || 'cursor-agent',
576
546
  startIndex: lane.startIndex,
577
547
  pipelineBranch: `${pipelineBranch}/${lane.name}`,
548
+ worktreeDir: laneWorktreeDirs[lane.name],
578
549
  enhancedLogConfig: options.enhancedLogging,
579
550
  noGit: options.noGit,
580
551
  });
@@ -607,7 +578,7 @@ export async function orchestrate(tasksDir: string, options: {
607
578
  });
608
579
  } else if (finished.code === 2) {
609
580
  // Blocked by dependency
610
- const statePath = path.join(laneRunDirs[finished.name]!, 'state.json');
581
+ const statePath = safeJoin(laneRunDirs[finished.name]!, 'state.json');
611
582
  const state = loadState<LaneState>(statePath);
612
583
 
613
584
  if (state && state.dependencyRequest) {
@@ -16,6 +16,7 @@ import { events } from '../utils/events';
16
16
  import { loadConfig } from '../utils/config';
17
17
  import { registerWebhooks } from '../utils/webhook';
18
18
  import { runReviewLoop } from './reviewer';
19
+ import { safeJoin } from '../utils/path';
19
20
  import {
20
21
  RunnerConfig,
21
22
  Task,
@@ -239,7 +240,7 @@ export async function cursorAgentSend({ workspaceDir, chatId, prompt, model, sig
239
240
  // Save PID to state if possible (avoid TOCTOU by reading directly)
240
241
  if (child.pid && signalDir) {
241
242
  try {
242
- const statePath = path.join(signalDir, 'state.json');
243
+ const statePath = safeJoin(signalDir, 'state.json');
243
244
  // Read directly without existence check to avoid race condition
244
245
  const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));
245
246
  state.pid = child.pid;
@@ -474,7 +475,7 @@ export function applyDependencyFilePermissions(worktreeDir: string, policy: Depe
474
475
  }
475
476
 
476
477
  for (const file of targets) {
477
- const filePath = path.join(worktreeDir, file);
478
+ const filePath = safeJoin(worktreeDir, file);
478
479
  if (!fs.existsSync(filePath)) continue;
479
480
 
480
481
  try {
@@ -507,7 +508,7 @@ export async function waitForTaskDependencies(deps: string[], runDir: string): P
507
508
  continue;
508
509
  }
509
510
 
510
- const depStatePath = path.join(lanesRoot, laneName, 'state.json');
511
+ const depStatePath = safeJoin(lanesRoot, laneName, 'state.json');
511
512
  if (fs.existsSync(depStatePath)) {
512
513
  try {
513
514
  const state = JSON.parse(fs.readFileSync(depStatePath, 'utf8')) as LaneState;
@@ -540,7 +541,7 @@ export async function mergeDependencyBranches(deps: string[], runDir: string, wo
540
541
  const lanesToMerge = new Set(deps.map(d => d.split(':')[0]!));
541
542
 
542
543
  for (const laneName of lanesToMerge) {
543
- const depStatePath = path.join(lanesRoot, laneName, 'state.json');
544
+ const depStatePath = safeJoin(lanesRoot, laneName, 'state.json');
544
545
  if (!fs.existsSync(depStatePath)) continue;
545
546
 
546
547
  try {
@@ -589,7 +590,7 @@ export async function runTask({
589
590
  }): Promise<TaskExecutionResult> {
590
591
  const model = task.model || config.model || 'sonnet-4.5';
591
592
  const timeout = task.timeout || config.timeout;
592
- const convoPath = path.join(runDir, 'conversation.jsonl');
593
+ const convoPath = safeJoin(runDir, 'conversation.jsonl');
593
594
 
594
595
  logger.section(`[${index + 1}/${config.tasks.length}] ${task.name}`);
595
596
  logger.info(`Model: ${model}`);
@@ -781,7 +782,7 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
781
782
  const repoRoot = noGit ? process.cwd() : git.getRepoRoot();
782
783
 
783
784
  // Load existing state if resuming
784
- const statePath = path.join(runDir, 'state.json');
785
+ const statePath = safeJoin(runDir, 'state.json');
785
786
  let state: LaneState | null = null;
786
787
 
787
788
  if (fs.existsSync(statePath)) {
@@ -794,10 +795,12 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
794
795
 
795
796
  const randomSuffix = Math.random().toString(36).substring(2, 7);
796
797
  const pipelineBranch = state?.pipelineBranch || config.pipelineBranch || `${config.branchPrefix || 'cursorflow/'}${Date.now().toString(36)}-${randomSuffix}`;
798
+
797
799
  // In noGit mode, use a simple local directory instead of worktree
798
- const worktreeDir = state?.worktreeDir || (noGit
799
- ? path.join(repoRoot, config.worktreeRoot || '_cursorflow/workdir', pipelineBranch.replace(/\//g, '-'))
800
- : 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, '-')));
801
804
 
802
805
  if (startIndex === 0) {
803
806
  logger.section('🚀 Starting Pipeline');
@@ -816,10 +819,37 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
816
819
  logger.info(`Creating work directory: ${worktreeDir}`);
817
820
  fs.mkdirSync(worktreeDir, { recursive: true });
818
821
  } else {
819
- git.createWorktree(worktreeDir, pipelineBranch, {
820
- baseBranch: config.baseBranch || 'main',
821
- cwd: repoRoot,
822
- });
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
+ }
823
853
  }
824
854
  } else if (!noGit) {
825
855
  // If it exists but we are in Git mode, ensure it's actually a worktree and on the right branch
@@ -858,6 +888,9 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
858
888
  state.status = 'running';
859
889
  state.error = null;
860
890
  state.dependencyRequest = null;
891
+ state.pipelineBranch = pipelineBranch;
892
+ state.worktreeDir = worktreeDir;
893
+ state.label = state.label || pipelineBranch;
861
894
  state.dependsOn = config.dependsOn || [];
862
895
  state.completedTasks = state.completedTasks || [];
863
896
  }
@@ -872,8 +905,8 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
872
905
  const lanesRoot = path.dirname(runDir);
873
906
 
874
907
  for (const depName of config.dependsOn) {
875
- const depRunDir = path.join(lanesRoot, depName);
876
- const depStatePath = path.join(depRunDir, 'state.json');
908
+ const depRunDir = path.join(lanesRoot, depName); // nosemgrep
909
+ const depStatePath = path.join(depRunDir, 'state.json'); // nosemgrep
877
910
 
878
911
  if (!fs.existsSync(depStatePath)) {
879
912
  logger.warn(`Dependency state not found for ${depName} at ${depStatePath}`);
@@ -919,8 +952,8 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
919
952
  const lanesRoot = path.dirname(runDir);
920
953
 
921
954
  for (const depName of config.dependsOn) {
922
- const depRunDir = path.join(lanesRoot, depName);
923
- const depStatePath = path.join(depRunDir, 'state.json');
955
+ const depRunDir = safeJoin(lanesRoot, depName);
956
+ const depStatePath = safeJoin(depRunDir, 'state.json');
924
957
 
925
958
  if (!fs.existsSync(depStatePath)) {
926
959
  continue;
@@ -939,8 +972,8 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
939
972
  for (const entry of entries) {
940
973
  if (entry.name === '.git' || entry.name === '_cursorflow' || entry.name === 'node_modules') continue;
941
974
 
942
- const srcPath = path.join(src, entry.name);
943
- const destPath = path.join(dest, entry.name);
975
+ const srcPath = safeJoin(src, entry.name);
976
+ const destPath = safeJoin(dest, entry.name);
944
977
 
945
978
  if (entry.isDirectory()) {
946
979
  copyFiles(srcPath, destPath);
@@ -1072,7 +1105,7 @@ export async function runTasks(tasksFile: string, config: RunnerConfig, runDir:
1072
1105
 
1073
1106
  if (entry.isDirectory()) {
1074
1107
  stats.dirs++;
1075
- const sub = getFileSummary(path.join(dir, entry.name));
1108
+ const sub = getFileSummary(safeJoin(dir, entry.name));
1076
1109
  stats.files += sub.files;
1077
1110
  stats.dirs += sub.dirs;
1078
1111
  } else {
@@ -1114,11 +1147,13 @@ if (require.main === module) {
1114
1147
  const runDirIdx = args.indexOf('--run-dir');
1115
1148
  const startIdxIdx = args.indexOf('--start-index');
1116
1149
  const pipelineBranchIdx = args.indexOf('--pipeline-branch');
1150
+ const worktreeDirIdx = args.indexOf('--worktree-dir');
1117
1151
  const noGit = args.includes('--no-git');
1118
1152
 
1119
1153
  const runDir = runDirIdx >= 0 ? args[runDirIdx + 1]! : '.';
1120
1154
  const startIndex = startIdxIdx >= 0 ? parseInt(args[startIdxIdx + 1] || '0') : 0;
1121
1155
  const forcedPipelineBranch = pipelineBranchIdx >= 0 ? args[pipelineBranchIdx + 1] : null;
1156
+ const forcedWorktreeDir = worktreeDirIdx >= 0 ? args[worktreeDirIdx + 1] : null;
1122
1157
 
1123
1158
  // Extract runId from runDir (format: .../runs/run-123/lanes/lane-name)
1124
1159
  const parts = runDir.split(path.sep);
@@ -1150,6 +1185,9 @@ if (require.main === module) {
1150
1185
  if (forcedPipelineBranch) {
1151
1186
  config.pipelineBranch = forcedPipelineBranch;
1152
1187
  }
1188
+ if (forcedWorktreeDir) {
1189
+ config.worktreeDir = forcedWorktreeDir;
1190
+ }
1153
1191
  } catch (error: any) {
1154
1192
  console.error(`Failed to load tasks file: ${error.message}`);
1155
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 = {
@@ -114,14 +115,14 @@ export function loadConfig(projectRoot: string | null = null): CursorFlowConfig
114
115
  * Get absolute path for tasks directory
115
116
  */
116
117
  export function getTasksDir(config: CursorFlowConfig): string {
117
- return path.join(config.projectRoot, config.tasksDir);
118
+ return safeJoin(config.projectRoot, config.tasksDir);
118
119
  }
119
120
 
120
121
  /**
121
122
  * Get absolute path for logs directory
122
123
  */
123
124
  export function getLogsDir(config: CursorFlowConfig): string {
124
- return path.join(config.projectRoot, config.logsDir);
125
+ return safeJoin(config.projectRoot, config.logsDir);
125
126
  }
126
127
 
127
128
  /**
@@ -157,7 +158,7 @@ export function validateConfig(config: CursorFlowConfig): boolean {
157
158
  * Create default config file
158
159
  */
159
160
  export function createDefaultConfig(projectRoot: string, force = false): string {
160
- const configPath = path.join(projectRoot, 'cursorflow.config.js');
161
+ const configPath = safeJoin(projectRoot, 'cursorflow.config.js');
161
162
 
162
163
  const template = `module.exports = {
163
164
  // Directory configuration
@@ -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.
@@ -612,7 +613,7 @@ function validateBranchNames(
612
613
  const DOCTOR_STATUS_FILE = '.cursorflow/doctor-status.json';
613
614
 
614
615
  export function saveDoctorStatus(repoRoot: string, report: DoctorReport): void {
615
- const statusPath = path.join(repoRoot, DOCTOR_STATUS_FILE);
616
+ const statusPath = safeJoin(repoRoot, DOCTOR_STATUS_FILE);
616
617
  const statusDir = path.dirname(statusPath);
617
618
 
618
619
  if (!fs.existsSync(statusDir)) {
@@ -630,7 +631,7 @@ export function saveDoctorStatus(repoRoot: string, report: DoctorReport): void {
630
631
  }
631
632
 
632
633
  export function getDoctorStatus(repoRoot: string): { lastRun: number; ok: boolean; issueCount: number } | null {
633
- const statusPath = path.join(repoRoot, DOCTOR_STATUS_FILE);
634
+ const statusPath = safeJoin(repoRoot, DOCTOR_STATUS_FILE);
634
635
  if (!fs.existsSync(statusPath)) return null;
635
636
 
636
637
  try {
@@ -830,7 +831,7 @@ export function runDoctor(options: DoctorOptions = {}): DoctorReport {
830
831
  });
831
832
  } else {
832
833
  // Advanced check: .gitignore check for worktrees
833
- const gitignorePath = path.join(gitCwd, '.gitignore');
834
+ const gitignorePath = safeJoin(gitCwd, '.gitignore');
834
835
  const worktreeDirName = '_cursorflow'; // Default directory name
835
836
  if (fs.existsSync(gitignorePath)) {
836
837
  const content = fs.readFileSync(gitignorePath, 'utf8');
@@ -853,7 +854,7 @@ export function runDoctor(options: DoctorOptions = {}): DoctorReport {
853
854
  if (options.tasksDir) {
854
855
  const tasksDirAbs = path.isAbsolute(options.tasksDir)
855
856
  ? options.tasksDir
856
- : path.resolve(cwd, options.tasksDir);
857
+ : safeJoin(cwd, options.tasksDir);
857
858
  context.tasksDir = tasksDirAbs;
858
859
 
859
860
  if (!fs.existsSync(tasksDirAbs)) {