@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
package/src/cli/index.ts CHANGED
@@ -34,10 +34,11 @@ function printHelp(): void {
34
34
 
35
35
  \x1b[1mCOMMANDS\x1b[0m
36
36
  \x1b[33minit\x1b[0m [options] Initialize CursorFlow in project
37
+ \x1b[33msetup\x1b[0m [options] Install Cursor IDE commands
37
38
  \x1b[33mprepare\x1b[0m <feature> [opts] Prepare task directory and JSON files
38
39
  \x1b[33mrun\x1b[0m <tasks-dir> [options] Run orchestration (DAG-based)
39
40
  \x1b[33mmonitor\x1b[0m [run-dir] [options] \x1b[36mInteractive\x1b[0m lane dashboard
40
- \x1b[33mclean\x1b[0m <type> [options] Clean branches/worktrees/logs
41
+ \x1b[33mclean\x1b[0m <type> [options] Clean branches/worktrees/logs/tasks
41
42
  \x1b[33mresume\x1b[0m [lane] [options] Resume lane(s) - use --all for batch resume
42
43
  \x1b[33mdoctor\x1b[0m [options] Check environment and preflight
43
44
  \x1b[33msignal\x1b[0m <lane> <msg> Directly intervene in a running lane
@@ -54,6 +55,9 @@ function printHelp(): void {
54
55
  $ \x1b[32mcursorflow prepare NewFeature --lanes 3\x1b[0m
55
56
  $ \x1b[32mcursorflow run _cursorflow/tasks/MyFeature/\x1b[0m
56
57
  $ \x1b[32mcursorflow monitor latest\x1b[0m
58
+ $ \x1b[32mcursorflow logs --all --follow\x1b[0m
59
+ $ \x1b[32mcursorflow resume --all\x1b[0m
60
+ $ \x1b[32mcursorflow doctor\x1b[0m
57
61
  $ \x1b[32mcursorflow models\x1b[0m
58
62
 
59
63
  \x1b[1mDOCUMENTATION\x1b[0m
package/src/cli/init.ts CHANGED
@@ -9,6 +9,7 @@ import * as path from 'path';
9
9
  import * as logger from '../utils/logger';
10
10
  import { findProjectRoot, createDefaultConfig, CursorFlowConfig } from '../utils/config';
11
11
  import { setupCommands } from './setup-commands';
12
+ import { safeJoin } from '../utils/path';
12
13
 
13
14
  interface InitOptions {
14
15
  example: boolean;
@@ -84,8 +85,8 @@ Examples:
84
85
  }
85
86
 
86
87
  function createDirectories(projectRoot: string, config: CursorFlowConfig): void {
87
- const tasksDir = path.join(projectRoot, config.tasksDir);
88
- const logsDir = path.join(projectRoot, config.logsDir);
88
+ const tasksDir = safeJoin(projectRoot, config.tasksDir);
89
+ const logsDir = safeJoin(projectRoot, config.logsDir);
89
90
 
90
91
  if (!fs.existsSync(tasksDir)) {
91
92
  fs.mkdirSync(tasksDir, { recursive: true });
@@ -103,7 +104,7 @@ function createDirectories(projectRoot: string, config: CursorFlowConfig): void
103
104
  }
104
105
 
105
106
  function createExampleTasks(projectRoot: string, config: CursorFlowConfig): void {
106
- const exampleDir = path.join(projectRoot, config.tasksDir, 'example');
107
+ const exampleDir = safeJoin(projectRoot, config.tasksDir, 'example');
107
108
 
108
109
  if (!fs.existsSync(exampleDir)) {
109
110
  fs.mkdirSync(exampleDir, { recursive: true });
@@ -134,13 +135,13 @@ Create a simple hello.txt file with a greeting message.
134
135
  ]
135
136
  };
136
137
 
137
- const taskPath = path.join(exampleDir, '01-hello.json');
138
+ const taskPath = safeJoin(exampleDir, '01-hello.json');
138
139
  fs.writeFileSync(taskPath, JSON.stringify(exampleTask, null, 2) + '\n', 'utf8');
139
140
 
140
141
  logger.success(`Created example task: ${path.relative(projectRoot, taskPath)}`);
141
142
 
142
143
  // Create README
143
- const readmePath = path.join(exampleDir, 'README.md');
144
+ const readmePath = safeJoin(exampleDir, 'README.md');
144
145
  const readme = `# Example Task
145
146
 
146
147
  This is an example CursorFlow task to help you get started.
@@ -172,20 +173,23 @@ cursorflow run ${config.tasksDir}/example/
172
173
  * Add _cursorflow to .gitignore
173
174
  */
174
175
  function updateGitignore(projectRoot: string): void {
175
- const gitignorePath = path.join(projectRoot, '.gitignore');
176
+ const gitignorePath = safeJoin(projectRoot, '.gitignore');
176
177
  const entry = '_cursorflow/';
177
178
 
178
- // Check if .gitignore exists
179
- if (!fs.existsSync(gitignorePath)) {
180
- // Create new .gitignore
181
- fs.writeFileSync(gitignorePath, `# CursorFlow\n${entry}\n`, 'utf8');
182
- logger.success('Created .gitignore with _cursorflow/');
183
- return;
179
+ // Try to read existing .gitignore (avoid TOCTOU by reading directly)
180
+ let content: string;
181
+ try {
182
+ content = fs.readFileSync(gitignorePath, 'utf8');
183
+ } catch (err: any) {
184
+ if (err.code === 'ENOENT') {
185
+ // File doesn't exist - create new .gitignore
186
+ fs.writeFileSync(gitignorePath, `# CursorFlow\n${entry}\n`, 'utf8');
187
+ logger.success('Created .gitignore with _cursorflow/');
188
+ return;
189
+ }
190
+ throw err;
184
191
  }
185
192
 
186
- // Read existing .gitignore
187
- const content = fs.readFileSync(gitignorePath, 'utf8');
188
-
189
193
  // Check if already included
190
194
  const lines = content.split('\n');
191
195
  const hasEntry = lines.some(line => {
package/src/cli/logs.ts CHANGED
@@ -6,12 +6,14 @@ import * as fs from 'fs';
6
6
  import * as path from 'path';
7
7
  import * as logger from '../utils/logger';
8
8
  import { loadConfig } from '../utils/config';
9
+ import { safeJoin } from '../utils/path';
9
10
  import {
10
11
  readJsonLog,
11
12
  exportLogs,
12
13
  stripAnsi,
13
14
  JsonLogEntry
14
15
  } from '../utils/enhanced-logger';
16
+ import { formatPotentialJsonMessage } from '../utils/log-formatter';
15
17
 
16
18
  interface LogsOptions {
17
19
  runDir?: string;
@@ -29,6 +31,13 @@ interface LogsOptions {
29
31
  help: boolean;
30
32
  }
31
33
 
34
+ /**
35
+ * Escape special regex characters to prevent regex injection
36
+ */
37
+ function escapeRegex(str: string): string {
38
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
39
+ }
40
+
32
41
  function printHelp(): void {
33
42
  console.log(`
34
43
  Usage: cursorflow logs [run-dir] [options]
@@ -107,15 +116,15 @@ function parseArgs(args: string[]): LogsOptions {
107
116
  * Find the latest run directory
108
117
  */
109
118
  function findLatestRunDir(logsDir: string): string | null {
110
- const runsDir = path.join(logsDir, 'runs');
119
+ const runsDir = safeJoin(logsDir, 'runs');
111
120
  if (!fs.existsSync(runsDir)) return null;
112
121
 
113
122
  const runs = fs.readdirSync(runsDir)
114
123
  .filter(d => d.startsWith('run-'))
115
124
  .map(d => ({
116
125
  name: d,
117
- path: path.join(runsDir, d),
118
- mtime: fs.statSync(path.join(runsDir, d)).mtime.getTime()
126
+ path: safeJoin(runsDir, d),
127
+ mtime: fs.statSync(safeJoin(runsDir, d)).mtime.getTime()
119
128
  }))
120
129
  .sort((a, b) => b.mtime - a.mtime);
121
130
 
@@ -126,11 +135,11 @@ function findLatestRunDir(logsDir: string): string | null {
126
135
  * List lanes in a run directory
127
136
  */
128
137
  function listLanes(runDir: string): string[] {
129
- const lanesDir = path.join(runDir, 'lanes');
138
+ const lanesDir = safeJoin(runDir, 'lanes');
130
139
  if (!fs.existsSync(lanesDir)) return [];
131
140
 
132
141
  return fs.readdirSync(lanesDir)
133
- .filter(d => fs.statSync(path.join(lanesDir, d)).isDirectory());
142
+ .filter(d => fs.statSync(safeJoin(lanesDir, d)).isDirectory());
134
143
  }
135
144
 
136
145
  /**
@@ -141,9 +150,9 @@ function displayTextLogs(
141
150
  options: LogsOptions
142
151
  ): void {
143
152
  let logFile: string;
144
- const readableLog = path.join(laneDir, 'terminal-readable.log');
145
- const rawLog = path.join(laneDir, 'terminal-raw.log');
146
- const cleanLog = path.join(laneDir, 'terminal.log');
153
+ const readableLog = safeJoin(laneDir, 'terminal-readable.log');
154
+ const rawLog = safeJoin(laneDir, 'terminal-raw.log');
155
+ const cleanLog = safeJoin(laneDir, 'terminal.log');
147
156
 
148
157
  if (options.raw) {
149
158
  logFile = rawLog;
@@ -164,10 +173,10 @@ function displayTextLogs(
164
173
  let content = fs.readFileSync(logFile, 'utf8');
165
174
  let lines = content.split('\n');
166
175
 
167
- // Apply filter
176
+ // Apply filter (case-insensitive string match to avoid ReDoS)
168
177
  if (options.filter) {
169
- const regex = new RegExp(options.filter, 'i');
170
- lines = lines.filter(line => regex.test(line));
178
+ const filterLower = options.filter.toLowerCase();
179
+ lines = lines.filter(line => line.toLowerCase().includes(filterLower));
171
180
  }
172
181
 
173
182
  // Apply tail
@@ -190,7 +199,7 @@ function displayJsonLogs(
190
199
  laneDir: string,
191
200
  options: LogsOptions
192
201
  ): void {
193
- const logFile = path.join(laneDir, 'terminal.jsonl');
202
+ const logFile = safeJoin(laneDir, 'terminal.jsonl');
194
203
 
195
204
  if (!fs.existsSync(logFile)) {
196
205
  console.log('No JSON log file found.');
@@ -204,10 +213,13 @@ function displayJsonLogs(
204
213
  entries = entries.filter(e => e.level === options.level);
205
214
  }
206
215
 
207
- // Apply regex filter
216
+ // Apply filter (case-insensitive string match to avoid ReDoS)
208
217
  if (options.filter) {
209
- const regex = new RegExp(options.filter, 'i');
210
- entries = entries.filter(e => regex.test(e.message) || regex.test(e.task || ''));
218
+ const filterLower = options.filter.toLowerCase();
219
+ entries = entries.filter(e =>
220
+ e.message.toLowerCase().includes(filterLower) ||
221
+ (e.task && e.task.toLowerCase().includes(filterLower))
222
+ );
211
223
  }
212
224
 
213
225
  // Apply tail
@@ -222,7 +234,8 @@ function displayJsonLogs(
222
234
  for (const entry of entries) {
223
235
  const levelColor = getLevelColor(entry.level);
224
236
  const ts = new Date(entry.timestamp).toLocaleTimeString();
225
- console.log(`${levelColor}[${ts}] [${entry.level.toUpperCase().padEnd(6)}]${logger.COLORS.reset} ${entry.message}`);
237
+ const formattedMsg = formatPotentialJsonMessage(entry.message);
238
+ console.log(`${levelColor}[${ts}] [${entry.level.toUpperCase().padEnd(6)}]${logger.COLORS.reset} ${formattedMsg}`);
226
239
  }
227
240
  }
228
241
  }
@@ -283,8 +296,8 @@ function readAllLaneLogs(runDir: string): MergedLogEntry[] {
283
296
  const allEntries: MergedLogEntry[] = [];
284
297
 
285
298
  lanes.forEach((laneName, index) => {
286
- const laneDir = path.join(runDir, 'lanes', laneName);
287
- const jsonLogPath = path.join(laneDir, 'terminal.jsonl');
299
+ const laneDir = safeJoin(runDir, 'lanes', laneName);
300
+ const jsonLogPath = safeJoin(laneDir, 'terminal.jsonl');
288
301
 
289
302
  if (fs.existsSync(jsonLogPath)) {
290
303
  const entries = readJsonLog(jsonLogPath);
@@ -326,13 +339,13 @@ function displayMergedLogs(runDir: string, options: LogsOptions): void {
326
339
  entries = entries.filter(e => e.level === options.level);
327
340
  }
328
341
 
329
- // Apply regex filter
342
+ // Apply filter (case-insensitive string match to avoid ReDoS)
330
343
  if (options.filter) {
331
- const regex = new RegExp(options.filter, 'i');
344
+ const filterLower = options.filter.toLowerCase();
332
345
  entries = entries.filter(e =>
333
- regex.test(e.message) ||
334
- regex.test(e.task || '') ||
335
- regex.test(e.laneName)
346
+ e.message.toLowerCase().includes(filterLower) ||
347
+ (e.task && e.task.toLowerCase().includes(filterLower)) ||
348
+ e.laneName.toLowerCase().includes(filterLower)
336
349
  );
337
350
  }
338
351
 
@@ -380,7 +393,8 @@ function displayMergedLogs(runDir: string, options: LogsOptions): void {
380
393
  continue;
381
394
  }
382
395
 
383
- console.log(`${logger.COLORS.gray}[${ts}]${logger.COLORS.reset} ${laneColor}[${lanePad}]${logger.COLORS.reset} ${levelColor}[${levelPad}]${logger.COLORS.reset} ${entry.message}`);
396
+ const formattedMsg = formatPotentialJsonMessage(entry.message);
397
+ console.log(`${logger.COLORS.gray}[${ts}]${logger.COLORS.reset} ${laneColor}[${lanePad}]${logger.COLORS.reset} ${levelColor}[${levelPad}]${logger.COLORS.reset} ${formattedMsg}`);
384
398
  }
385
399
 
386
400
  console.log(`\n${logger.COLORS.cyan}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${logger.COLORS.reset}`);
@@ -419,12 +433,11 @@ function followAllLogs(runDir: string, options: LogsOptions): void {
419
433
  const newEntries: MergedLogEntry[] = [];
420
434
 
421
435
  for (const lane of lanes) {
422
- const laneDir = path.join(runDir, 'lanes', lane);
423
- const jsonLogPath = path.join(laneDir, 'terminal.jsonl');
424
-
425
- if (!fs.existsSync(jsonLogPath)) continue;
436
+ const laneDir = safeJoin(runDir, 'lanes', lane);
437
+ const jsonLogPath = safeJoin(laneDir, 'terminal.jsonl');
426
438
 
427
439
  try {
440
+ // Use statSync directly to avoid TOCTOU race condition
428
441
  const stats = fs.statSync(jsonLogPath);
429
442
  if (stats.size > lastPositions[lane]!) {
430
443
  const fd = fs.openSync(jsonLogPath, 'r');
@@ -467,10 +480,12 @@ function followAllLogs(runDir: string, options: LogsOptions): void {
467
480
  // Apply level filter
468
481
  if (options.level && entry.level !== options.level) continue;
469
482
 
470
- // Apply regex filter
483
+ // Apply filter (case-insensitive string match to avoid ReDoS)
471
484
  if (options.filter) {
472
- const regex = new RegExp(options.filter, 'i');
473
- if (!regex.test(entry.message) && !regex.test(entry.task || '') && !regex.test(entry.laneName)) {
485
+ const filterLower = options.filter.toLowerCase();
486
+ if (!entry.message.toLowerCase().includes(filterLower) &&
487
+ !(entry.task && entry.task.toLowerCase().includes(filterLower)) &&
488
+ !entry.laneName.toLowerCase().includes(filterLower)) {
474
489
  continue;
475
490
  }
476
491
  }
@@ -490,7 +505,8 @@ function followAllLogs(runDir: string, options: LogsOptions): void {
490
505
  continue;
491
506
  }
492
507
 
493
- console.log(`${logger.COLORS.gray}[${ts}]${logger.COLORS.reset} ${entry.laneColor}[${lanePad}]${logger.COLORS.reset} ${levelColor}[${levelPad}]${logger.COLORS.reset} ${entry.message}`);
508
+ const formattedMsg = formatPotentialJsonMessage(entry.message);
509
+ console.log(`${logger.COLORS.gray}[${ts}]${logger.COLORS.reset} ${entry.laneColor}[${lanePad}]${logger.COLORS.reset} ${levelColor}[${levelPad}]${logger.COLORS.reset} ${formattedMsg}`);
494
510
  }
495
511
  }, 100);
496
512
 
@@ -628,7 +644,9 @@ function escapeHtml(text: string): string {
628
644
  .replace(/</g, '&lt;')
629
645
  .replace(/>/g, '&gt;')
630
646
  .replace(/"/g, '&quot;')
631
- .replace(/'/g, '&#039;');
647
+ .replace(/'/g, '&#039;')
648
+ .replace(/`/g, '&#x60;')
649
+ .replace(/\//g, '&#x2F;');
632
650
  }
633
651
 
634
652
  /**
@@ -636,9 +654,9 @@ function escapeHtml(text: string): string {
636
654
  */
637
655
  function followLogs(laneDir: string, options: LogsOptions): void {
638
656
  let logFile: string;
639
- const readableLog = path.join(laneDir, 'terminal-readable.log');
640
- const rawLog = path.join(laneDir, 'terminal-raw.log');
641
- const cleanLog = path.join(laneDir, 'terminal.log');
657
+ const readableLog = safeJoin(laneDir, 'terminal-readable.log');
658
+ const rawLog = safeJoin(laneDir, 'terminal-raw.log');
659
+ const cleanLog = safeJoin(laneDir, 'terminal.log');
642
660
 
643
661
  if (options.raw) {
644
662
  logFile = rawLog;
@@ -657,9 +675,11 @@ function followLogs(laneDir: string, options: LogsOptions): void {
657
675
 
658
676
  let lastSize = 0;
659
677
  try {
660
- lastSize = fs.existsSync(logFile) ? fs.statSync(logFile).size : 0;
678
+ // Use statSync directly to avoid TOCTOU race condition
679
+ lastSize = fs.statSync(logFile).size;
661
680
  } catch {
662
- // Ignore
681
+ // File doesn't exist yet or other error - start from 0
682
+ lastSize = 0;
663
683
  }
664
684
 
665
685
  console.log(`${logger.COLORS.cyan}Following ${logFile}... (Ctrl+C to stop)${logger.COLORS.reset}\n`);
@@ -677,11 +697,11 @@ function followLogs(laneDir: string, options: LogsOptions): void {
677
697
 
678
698
  let content = buffer.toString();
679
699
 
680
- // Apply filter
700
+ // Apply filter (case-insensitive string match to avoid ReDoS)
681
701
  if (options.filter) {
682
- const regex = new RegExp(options.filter, 'i');
702
+ const filterLower = options.filter.toLowerCase();
683
703
  const lines = content.split('\n');
684
- content = lines.filter(line => regex.test(line)).join('\n');
704
+ content = lines.filter(line => line.toLowerCase().includes(filterLower)).join('\n');
685
705
  }
686
706
 
687
707
  // Clean ANSI if needed (unless raw mode)
@@ -724,11 +744,11 @@ function displaySummary(runDir: string): void {
724
744
  }
725
745
 
726
746
  for (const lane of lanes) {
727
- const laneDir = path.join(runDir, 'lanes', lane);
728
- const cleanLog = path.join(laneDir, 'terminal.log');
729
- const rawLog = path.join(laneDir, 'terminal-raw.log');
730
- const jsonLog = path.join(laneDir, 'terminal.jsonl');
731
- const readableLog = path.join(laneDir, 'terminal-readable.log');
747
+ const laneDir = safeJoin(runDir, 'lanes', lane);
748
+ const cleanLog = safeJoin(laneDir, 'terminal.log');
749
+ const rawLog = safeJoin(laneDir, 'terminal-raw.log');
750
+ const jsonLog = safeJoin(laneDir, 'terminal.jsonl');
751
+ const readableLog = safeJoin(laneDir, 'terminal-readable.log');
732
752
 
733
753
  console.log(` ${logger.COLORS.green}📁 ${lane}${logger.COLORS.reset}`);
734
754
 
@@ -829,7 +849,7 @@ async function logs(args: string[]): Promise<void> {
829
849
  }
830
850
 
831
851
  // Find lane directory
832
- const laneDir = path.join(runDir, 'lanes', options.lane);
852
+ const laneDir = safeJoin(runDir, 'lanes', options.lane);
833
853
  if (!fs.existsSync(laneDir)) {
834
854
  const lanes = listLanes(runDir);
835
855
  throw new Error(`Lane not found: ${options.lane}\nAvailable lanes: ${lanes.join(', ')}`);
@@ -9,6 +9,7 @@ import * as logger from '../utils/logger';
9
9
  import { loadState, readLog } from '../utils/state';
10
10
  import { LaneState, ConversationEntry } from '../utils/types';
11
11
  import { loadConfig } from '../utils/config';
12
+ import { safeJoin } from '../utils/path';
12
13
 
13
14
  interface LaneWithDeps {
14
15
  name: string;
@@ -339,11 +340,11 @@ class InteractiveMonitor {
339
340
  if (!lane) return;
340
341
 
341
342
  try {
342
- const interventionPath = path.join(lane.path, 'intervention.txt');
343
+ const interventionPath = safeJoin(lane.path, 'intervention.txt');
343
344
  fs.writeFileSync(interventionPath, message, 'utf8');
344
345
 
345
346
  // Also log it to the conversation
346
- const convoPath = path.join(lane.path, 'conversation.jsonl');
347
+ const convoPath = safeJoin(lane.path, 'conversation.jsonl');
347
348
  const entry = {
348
349
  timestamp: new Date().toISOString(),
349
350
  role: 'user',
@@ -372,7 +373,7 @@ class InteractiveMonitor {
372
373
  return;
373
374
  }
374
375
 
375
- const timeoutPath = path.join(lane.path, 'timeout.txt');
376
+ const timeoutPath = safeJoin(lane.path, 'timeout.txt');
376
377
  fs.writeFileSync(timeoutPath, String(timeoutMs), 'utf8');
377
378
 
378
379
  this.showNotification(`Timeout updated to ${Math.round(timeoutMs/1000)}s`, 'success');
@@ -385,7 +386,7 @@ class InteractiveMonitor {
385
386
  if (!this.selectedLaneName) return;
386
387
  const lane = this.lanes.find(l => l.name === this.selectedLaneName);
387
388
  if (!lane) return;
388
- const convoPath = path.join(lane.path, 'conversation.jsonl');
389
+ const convoPath = safeJoin(lane.path, 'conversation.jsonl');
389
390
  this.currentLogs = readLog<ConversationEntry>(convoPath);
390
391
  // Keep selection in bounds after refresh
391
392
  if (this.selectedMessageIndex >= this.currentLogs.length) {
@@ -505,8 +506,12 @@ class InteractiveMonitor {
505
506
  nextAction = '🏁 Done';
506
507
  }
507
508
  } else if (status.status === 'waiting') {
508
- const missingDeps = status.dependsOn.filter((d: string) => laneStatuses[d] && laneStatuses[d].status !== 'completed');
509
- nextAction = `Wait for: ${missingDeps.join(', ')}`;
509
+ if (status.waitingFor && status.waitingFor.length > 0) {
510
+ nextAction = `Wait for task: ${status.waitingFor.join(', ')}`;
511
+ } else {
512
+ const missingDeps = status.dependsOn.filter((d: string) => laneStatuses[d] && laneStatuses[d].status !== 'completed');
513
+ nextAction = `Wait for lane: ${missingDeps.join(', ')}`;
514
+ }
510
515
  } else if (status.status === 'running') {
511
516
  nextAction = '🚀 Working...';
512
517
  }
@@ -533,7 +538,7 @@ class InteractiveMonitor {
533
538
  }
534
539
 
535
540
  const status = this.getLaneStatus(lane.path, lane.name);
536
- const logPath = path.join(lane.path, 'terminal.log');
541
+ const logPath = safeJoin(lane.path, 'terminal.log');
537
542
  let liveLog = '(No live terminal output)';
538
543
  if (fs.existsSync(logPath)) {
539
544
  const content = fs.readFileSync(logPath, 'utf8');
@@ -553,6 +558,10 @@ class InteractiveMonitor {
553
558
  process.stdout.write(` Chat ID: ${status.chatId}\n`);
554
559
  process.stdout.write(` Depends: ${status.dependsOn.join(', ') || 'None'}\n`);
555
560
 
561
+ if (status.waitingFor && status.waitingFor.length > 0) {
562
+ process.stdout.write(`\x1b[33m Wait For: ${status.waitingFor.join(', ')}\x1b[0m\n`);
563
+ }
564
+
556
565
  if (status.error) {
557
566
  process.stdout.write(`\x1b[31m Error: ${status.error}\x1b[0m\n`);
558
567
  }
@@ -672,7 +681,7 @@ class InteractiveMonitor {
672
681
  return;
673
682
  }
674
683
 
675
- const logPath = path.join(lane.path, 'terminal.log');
684
+ const logPath = safeJoin(lane.path, 'terminal.log');
676
685
  let logLines: string[] = [];
677
686
  if (fs.existsSync(logPath)) {
678
687
  const content = fs.readFileSync(logPath, 'utf8');
@@ -753,21 +762,21 @@ class InteractiveMonitor {
753
762
  }
754
763
 
755
764
  private listLanesWithDeps(runDir: string): LaneWithDeps[] {
756
- const lanesDir = path.join(runDir, 'lanes');
765
+ const lanesDir = safeJoin(runDir, 'lanes');
757
766
  if (!fs.existsSync(lanesDir)) return [];
758
767
 
759
768
  const config = loadConfig();
760
- const tasksDir = path.join(config.projectRoot, config.tasksDir);
769
+ const tasksDir = safeJoin(config.projectRoot, config.tasksDir);
761
770
 
762
771
  const laneConfigs = this.listLaneFilesFromDir(tasksDir);
763
772
 
764
773
  return fs.readdirSync(lanesDir)
765
- .filter(d => fs.statSync(path.join(lanesDir, d)).isDirectory())
774
+ .filter(d => fs.statSync(safeJoin(lanesDir, d)).isDirectory())
766
775
  .map(name => {
767
776
  const config = laneConfigs.find(c => c.name === name);
768
777
  return {
769
778
  name,
770
- path: path.join(lanesDir, name),
779
+ path: safeJoin(lanesDir, name),
771
780
  dependsOn: config?.dependsOn || [],
772
781
  };
773
782
  });
@@ -778,7 +787,7 @@ class InteractiveMonitor {
778
787
  return fs.readdirSync(tasksDir)
779
788
  .filter(f => f.endsWith('.json'))
780
789
  .map(f => {
781
- const filePath = path.join(tasksDir, f);
790
+ const filePath = safeJoin(tasksDir, f);
782
791
  try {
783
792
  const config = JSON.parse(fs.readFileSync(filePath, 'utf8'));
784
793
  return { name: path.basename(f, '.json'), dependsOn: config.dependsOn || [] };
@@ -789,7 +798,7 @@ class InteractiveMonitor {
789
798
  }
790
799
 
791
800
  private getLaneStatus(lanePath: string, laneName: string) {
792
- const statePath = path.join(lanePath, 'state.json');
801
+ const statePath = safeJoin(lanePath, 'state.json');
793
802
  const state = loadState<LaneState & { chatId?: string }>(statePath);
794
803
 
795
804
  const laneInfo = this.lanes.find(l => l.name === laneName);
@@ -815,9 +824,10 @@ class InteractiveMonitor {
815
824
  dependsOn,
816
825
  duration,
817
826
  error: state.error,
818
- pid: state.pid
827
+ pid: state.pid,
828
+ waitingFor: state.waitingFor || [],
819
829
  };
820
- }
830
+ }
821
831
 
822
832
  private formatDuration(ms: number): string {
823
833
  if (ms <= 0) return '-';
@@ -848,11 +858,11 @@ class InteractiveMonitor {
848
858
  * Find the latest run directory
849
859
  */
850
860
  function findLatestRunDir(logsDir: string): string | null {
851
- const runsDir = path.join(logsDir, 'runs');
861
+ const runsDir = safeJoin(logsDir, 'runs');
852
862
  if (!fs.existsSync(runsDir)) return null;
853
863
  const runs = fs.readdirSync(runsDir)
854
864
  .filter(d => d.startsWith('run-'))
855
- .map(d => ({ name: d, path: path.join(runsDir, d), mtime: fs.statSync(path.join(runsDir, d)).mtime.getTime() }))
865
+ .map(d => ({ name: d, path: safeJoin(runsDir, d), mtime: fs.statSync(safeJoin(runsDir, d)).mtime.getTime() }))
856
866
  .sort((a, b) => b.mtime - a.mtime);
857
867
  return runs.length > 0 ? runs[0]!.path : null;
858
868
  }