@litmers/cursorflow-orchestrator 0.1.5 → 0.1.8

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 (45) hide show
  1. package/CHANGELOG.md +15 -6
  2. package/README.md +33 -2
  3. package/commands/cursorflow-doctor.md +24 -0
  4. package/commands/cursorflow-signal.md +19 -0
  5. package/dist/cli/doctor.d.ts +15 -0
  6. package/dist/cli/doctor.js +139 -0
  7. package/dist/cli/doctor.js.map +1 -0
  8. package/dist/cli/index.js +5 -0
  9. package/dist/cli/index.js.map +1 -1
  10. package/dist/cli/monitor.d.ts +1 -1
  11. package/dist/cli/monitor.js +640 -145
  12. package/dist/cli/monitor.js.map +1 -1
  13. package/dist/cli/resume.d.ts +1 -1
  14. package/dist/cli/resume.js +80 -10
  15. package/dist/cli/resume.js.map +1 -1
  16. package/dist/cli/run.js +60 -5
  17. package/dist/cli/run.js.map +1 -1
  18. package/dist/cli/setup-commands.d.ts +4 -0
  19. package/dist/cli/setup-commands.js +16 -0
  20. package/dist/cli/setup-commands.js.map +1 -1
  21. package/dist/cli/signal.d.ts +7 -0
  22. package/dist/cli/signal.js +99 -0
  23. package/dist/cli/signal.js.map +1 -0
  24. package/dist/core/orchestrator.d.ts +4 -2
  25. package/dist/core/orchestrator.js +92 -23
  26. package/dist/core/orchestrator.js.map +1 -1
  27. package/dist/core/runner.d.ts +9 -3
  28. package/dist/core/runner.js +182 -88
  29. package/dist/core/runner.js.map +1 -1
  30. package/dist/utils/doctor.d.ts +63 -0
  31. package/dist/utils/doctor.js +280 -0
  32. package/dist/utils/doctor.js.map +1 -0
  33. package/dist/utils/types.d.ts +3 -0
  34. package/package.json +1 -1
  35. package/src/cli/doctor.ts +127 -0
  36. package/src/cli/index.ts +5 -0
  37. package/src/cli/monitor.ts +693 -185
  38. package/src/cli/resume.ts +94 -12
  39. package/src/cli/run.ts +63 -7
  40. package/src/cli/setup-commands.ts +19 -0
  41. package/src/cli/signal.ts +89 -0
  42. package/src/core/orchestrator.ts +102 -27
  43. package/src/core/runner.ts +203 -99
  44. package/src/utils/doctor.ts +312 -0
  45. package/src/utils/types.ts +3 -0
package/src/cli/resume.ts CHANGED
@@ -1,37 +1,119 @@
1
1
  /**
2
- * CursorFlow resume command (stub)
2
+ * CursorFlow resume command
3
3
  */
4
4
 
5
+ import * as path from 'path';
6
+ import * as fs from 'fs';
7
+ import { spawn } from 'child_process';
5
8
  import * as logger from '../utils/logger';
9
+ import { loadConfig, getLogsDir } from '../utils/config';
10
+ import { loadState } from '../utils/state';
11
+ import { LaneState } from '../utils/types';
6
12
 
7
13
  interface ResumeOptions {
8
- lane?: string;
14
+ lane: string | null;
9
15
  runDir: string | null;
10
16
  clean: boolean;
11
17
  restart: boolean;
12
18
  }
13
19
 
14
20
  function parseArgs(args: string[]): ResumeOptions {
21
+ const runDirIdx = args.indexOf('--run-dir');
22
+
15
23
  return {
16
- lane: args[0],
17
- runDir: null,
24
+ lane: args.find(a => !a.startsWith('--')) || null,
25
+ runDir: runDirIdx >= 0 ? args[runDirIdx + 1] || null : null,
18
26
  clean: args.includes('--clean'),
19
27
  restart: args.includes('--restart'),
20
28
  };
21
29
  }
22
30
 
23
- async function resume(args: string[]): Promise<void> {
24
- logger.section('🔁 Resuming Lane');
31
+ /**
32
+ * Find the latest run directory
33
+ */
34
+ function findLatestRunDir(logsDir: string): string | null {
35
+ const runsDir = path.join(logsDir, 'runs');
36
+ if (!fs.existsSync(runsDir)) return null;
25
37
 
38
+ const runs = fs.readdirSync(runsDir)
39
+ .filter(d => d.startsWith('run-'))
40
+ .sort()
41
+ .reverse();
42
+
43
+ return runs.length > 0 ? path.join(runsDir, runs[0]!) : null;
44
+ }
45
+
46
+ async function resume(args: string[]): Promise<void> {
26
47
  const options = parseArgs(args);
48
+ const config = loadConfig();
49
+ const logsDir = getLogsDir(config);
50
+
51
+ if (!options.lane) {
52
+ throw new Error('Lane name required (e.g., cursorflow resume lane-1)');
53
+ }
54
+
55
+ let runDir = options.runDir;
56
+ if (!runDir) {
57
+ runDir = findLatestRunDir(logsDir);
58
+ }
59
+
60
+ if (!runDir || !fs.existsSync(runDir)) {
61
+ throw new Error(`Run directory not found: ${runDir || 'latest'}`);
62
+ }
63
+
64
+ const laneDir = path.join(runDir, 'lanes', options.lane);
65
+ const statePath = path.join(laneDir, 'state.json');
66
+
67
+ if (!fs.existsSync(statePath)) {
68
+ throw new Error(`Lane state not found at ${statePath}. Is the lane name correct?`);
69
+ }
70
+
71
+ const state = loadState<LaneState>(statePath);
72
+ if (!state) {
73
+ throw new Error(`Failed to load state from ${statePath}`);
74
+ }
75
+
76
+ if (!state.tasksFile || !fs.existsSync(state.tasksFile)) {
77
+ throw new Error(`Original tasks file not found: ${state.tasksFile}. Resume impossible without task definition.`);
78
+ }
79
+
80
+ logger.section(`🔁 Resuming Lane: ${options.lane}`);
81
+ logger.info(`Run: ${path.basename(runDir)}`);
82
+ logger.info(`Tasks: ${state.tasksFile}`);
83
+ logger.info(`Starting from task index: ${options.restart ? 0 : state.currentTaskIndex}`);
84
+
85
+ const runnerPath = require.resolve('../core/runner');
86
+ const runnerArgs = [
87
+ runnerPath,
88
+ state.tasksFile,
89
+ '--run-dir', laneDir,
90
+ '--start-index', options.restart ? '0' : String(state.currentTaskIndex),
91
+ ];
92
+
93
+ logger.info(`Spawning runner process...`);
27
94
 
28
- logger.info('This command will be fully implemented in the next phase');
29
- logger.info(`Lane: ${options.lane}`);
30
- logger.info(`Clean: ${options.clean}`);
31
- logger.info(`Restart: ${options.restart}`);
95
+ const child = spawn('node', runnerArgs, {
96
+ stdio: 'inherit',
97
+ env: process.env,
98
+ });
32
99
 
33
- logger.warn('\n⚠️ Implementation pending');
34
- logger.info('This will resume interrupted lanes');
100
+ return new Promise((resolve, reject) => {
101
+ child.on('exit', (code) => {
102
+ if (code === 0) {
103
+ logger.success(`Lane ${options.lane} completed successfully`);
104
+ resolve();
105
+ } else if (code === 2) {
106
+ logger.warn(`Lane ${options.lane} blocked on dependency change`);
107
+ resolve();
108
+ } else {
109
+ reject(new Error(`Lane ${options.lane} failed with exit code ${code}`));
110
+ }
111
+ });
112
+
113
+ child.on('error', (error) => {
114
+ reject(new Error(`Failed to start runner: ${error.message}`));
115
+ });
116
+ });
35
117
  }
36
118
 
37
119
  export = resume;
package/src/cli/run.ts CHANGED
@@ -6,12 +6,15 @@ import * as path from 'path';
6
6
  import * as fs from 'fs';
7
7
  import * as logger from '../utils/logger';
8
8
  import { orchestrate } from '../core/orchestrator';
9
- import { loadConfig } from '../utils/config';
9
+ import { getLogsDir, loadConfig } from '../utils/config';
10
+ import { runDoctor } from '../utils/doctor';
11
+ import { areCommandsInstalled, setupCommands } from './setup-commands';
10
12
 
11
13
  interface RunOptions {
12
14
  tasksDir?: string;
13
15
  dryRun: boolean;
14
16
  executor: string | null;
17
+ skipDoctor: boolean;
15
18
  }
16
19
 
17
20
  function parseArgs(args: string[]): RunOptions {
@@ -22,28 +25,81 @@ function parseArgs(args: string[]): RunOptions {
22
25
  tasksDir,
23
26
  dryRun: args.includes('--dry-run'),
24
27
  executor: executorIdx >= 0 ? args[executorIdx + 1] || null : null,
28
+ skipDoctor: args.includes('--skip-doctor') || args.includes('--no-doctor'),
25
29
  };
26
30
  }
27
31
 
28
32
  async function run(args: string[]): Promise<void> {
29
33
  const options = parseArgs(args);
30
34
 
35
+ // Auto-setup Cursor commands if missing or outdated
36
+ if (!areCommandsInstalled()) {
37
+ logger.info('Installing missing or outdated Cursor IDE commands...');
38
+ try {
39
+ setupCommands({ silent: true });
40
+ } catch (e) {
41
+ // Non-blocking
42
+ }
43
+ }
44
+
31
45
  if (!options.tasksDir) {
32
46
  console.log('\nUsage: cursorflow run <tasks-dir> [options]');
33
47
  throw new Error('Tasks directory required');
34
48
  }
35
49
 
36
- if (!fs.existsSync(options.tasksDir)) {
37
- throw new Error(`Tasks directory not found: ${options.tasksDir}`);
38
- }
39
-
40
50
  const config = loadConfig();
51
+ const logsDir = getLogsDir(config);
52
+
53
+ // Resolve tasks dir:
54
+ // - Prefer the exact path if it exists relative to cwd
55
+ // - Otherwise, fall back to projectRoot-relative path for better ergonomics
56
+ const tasksDir =
57
+ path.isAbsolute(options.tasksDir)
58
+ ? options.tasksDir
59
+ : (fs.existsSync(options.tasksDir)
60
+ ? path.resolve(process.cwd(), options.tasksDir)
61
+ : path.join(config.projectRoot, options.tasksDir));
62
+
63
+ if (!fs.existsSync(tasksDir)) {
64
+ throw new Error(`Tasks directory not found: ${tasksDir}`);
65
+ }
66
+
67
+ // Preflight checks (doctor)
68
+ if (!options.skipDoctor) {
69
+ const report = runDoctor({
70
+ cwd: process.cwd(),
71
+ tasksDir,
72
+ executor: options.executor || config.executor,
73
+ includeCursorAgentChecks: true,
74
+ });
75
+
76
+ if (!report.ok) {
77
+ logger.section('🛑 Pre-flight check failed');
78
+ for (const issue of report.issues) {
79
+ const header = `${issue.title} (${issue.id})`;
80
+ if (issue.severity === 'error') {
81
+ logger.error(header, '❌');
82
+ } else {
83
+ logger.warn(header, '⚠️');
84
+ }
85
+ console.log(` ${issue.message}`);
86
+ if (issue.details) console.log(` Details: ${issue.details}`);
87
+ if (issue.fixes?.length) {
88
+ console.log(' Fix:');
89
+ for (const fix of issue.fixes) console.log(` - ${fix}`);
90
+ }
91
+ console.log('');
92
+ }
93
+ throw new Error('Pre-flight checks failed. Run `cursorflow doctor` for details.');
94
+ }
95
+ }
41
96
 
42
97
  try {
43
- await orchestrate(options.tasksDir, {
98
+ await orchestrate(tasksDir, {
44
99
  executor: options.executor || config.executor,
45
100
  pollInterval: config.pollInterval * 1000,
46
- runDir: path.join(config.logsDir, 'runs', `run-${Date.now()}`),
101
+ runDir: path.join(logsDir, 'runs', `run-${Date.now()}`),
102
+ maxConcurrentLanes: config.maxConcurrentLanes,
47
103
  });
48
104
  } catch (error: any) {
49
105
  // Re-throw to be handled by the main entry point
@@ -183,6 +183,25 @@ export function uninstallCommands(options: SetupOptions = {}): { removed: number
183
183
  return { removed };
184
184
  }
185
185
 
186
+ /**
187
+ * Check if commands are already installed
188
+ */
189
+ export function areCommandsInstalled(): boolean {
190
+ const projectRoot = findProjectRoot();
191
+ const targetDir = path.join(projectRoot, '.cursor', 'commands', 'cursorflow');
192
+ const sourceDir = getCommandsSourceDir();
193
+
194
+ if (!fs.existsSync(targetDir) || !fs.existsSync(sourceDir)) {
195
+ return false;
196
+ }
197
+
198
+ const sourceFiles = fs.readdirSync(sourceDir).filter(f => f.endsWith('.md'));
199
+ const targetFiles = fs.readdirSync(targetDir).filter(f => f.endsWith('.md'));
200
+
201
+ // Basic check: do we have all the files from source in target?
202
+ return sourceFiles.every(f => targetFiles.includes(f));
203
+ }
204
+
186
205
  async function main(args: string[]): Promise<any> {
187
206
  const options = parseArgs(args);
188
207
 
@@ -0,0 +1,89 @@
1
+ /**
2
+ * CursorFlow signal command
3
+ *
4
+ * Send a direct message to a running lane
5
+ */
6
+
7
+ import * as path from 'path';
8
+ import * as fs from 'fs';
9
+ import * as logger from '../utils/logger';
10
+ import { loadConfig, getLogsDir } from '../utils/config';
11
+ import { appendLog, createConversationEntry } from '../utils/state';
12
+
13
+ interface SignalOptions {
14
+ lane: string | null;
15
+ message: string | null;
16
+ runDir: string | null;
17
+ }
18
+
19
+ function parseArgs(args: string[]): SignalOptions {
20
+ const runDirIdx = args.indexOf('--run-dir');
21
+
22
+ // First non-option is lane, second (or rest joined) is message
23
+ const nonOptions = args.filter(a => !a.startsWith('--'));
24
+
25
+ return {
26
+ lane: nonOptions[0] || null,
27
+ message: nonOptions.slice(1).join(' ') || null,
28
+ runDir: runDirIdx >= 0 ? args[runDirIdx + 1] || null : null,
29
+ };
30
+ }
31
+
32
+ function findLatestRunDir(logsDir: string): string | null {
33
+ const runsDir = path.join(logsDir, 'runs');
34
+ if (!fs.existsSync(runsDir)) return null;
35
+
36
+ const runs = fs.readdirSync(runsDir)
37
+ .filter(d => d.startsWith('run-'))
38
+ .sort()
39
+ .reverse();
40
+
41
+ return runs.length > 0 ? path.join(runsDir, runs[0]!) : null;
42
+ }
43
+
44
+ async function signal(args: string[]): Promise<void> {
45
+ const options = parseArgs(args);
46
+ const config = loadConfig();
47
+ const logsDir = getLogsDir(config);
48
+
49
+ if (!options.lane) {
50
+ throw new Error('Lane name required: cursorflow signal <lane> "<message>"');
51
+ }
52
+
53
+ if (!options.message) {
54
+ throw new Error('Message required: cursorflow signal <lane> "<message>"');
55
+ }
56
+
57
+ let runDir = options.runDir;
58
+ if (!runDir) {
59
+ runDir = findLatestRunDir(logsDir);
60
+ }
61
+
62
+ if (!runDir || !fs.existsSync(runDir)) {
63
+ throw new Error(`Run directory not found: ${runDir || 'latest'}`);
64
+ }
65
+
66
+ const convoPath = path.join(runDir, 'lanes', options.lane, 'conversation.jsonl');
67
+
68
+ if (!fs.existsSync(convoPath)) {
69
+ throw new Error(`Conversation log not found at ${convoPath}. Is the lane running?`);
70
+ }
71
+
72
+ logger.info(`Sending signal to lane: ${options.lane}`);
73
+ logger.info(`Message: "${options.message}"`);
74
+
75
+ // Append as a "commander" role message
76
+ // Note: We cast to 'system' or similar if 'commander' isn't in the enum,
77
+ // but let's use 'reviewer' or 'system' which agents usually respect,
78
+ // or update the type definition.
79
+ const entry = createConversationEntry('system', `[COMMANDER INTERVENTION]\n${options.message}`, {
80
+ task: 'DIRECT_SIGNAL'
81
+ });
82
+
83
+ appendLog(convoPath, entry);
84
+
85
+ logger.success('Signal sent successfully. The agent will see this message in its next turn or via file monitoring.');
86
+ }
87
+
88
+ export = signal;
89
+
@@ -10,11 +10,12 @@ import { spawn, ChildProcess } from 'child_process';
10
10
 
11
11
  import * as logger from '../utils/logger';
12
12
  import { loadState } from '../utils/state';
13
- import { LaneState } from '../utils/types';
13
+ import { LaneState, RunnerConfig } from '../utils/types';
14
14
 
15
15
  export interface LaneInfo {
16
16
  name: string;
17
17
  path: string;
18
+ dependsOn: string[];
18
19
  }
19
20
 
20
21
  export interface SpawnLaneResult {
@@ -76,7 +77,7 @@ export function waitChild(proc: ChildProcess): Promise<number> {
76
77
  }
77
78
 
78
79
  /**
79
- * List lane task files in directory
80
+ * List lane task files in directory and load their configs for dependencies
80
81
  */
81
82
  export function listLaneFiles(tasksDir: string): LaneInfo[] {
82
83
  if (!fs.existsSync(tasksDir)) {
@@ -87,10 +88,24 @@ export function listLaneFiles(tasksDir: string): LaneInfo[] {
87
88
  return files
88
89
  .filter(f => f.endsWith('.json'))
89
90
  .sort()
90
- .map(f => ({
91
- name: path.basename(f, '.json'),
92
- path: path.join(tasksDir, f),
93
- }));
91
+ .map(f => {
92
+ const filePath = path.join(tasksDir, f);
93
+ const name = path.basename(f, '.json');
94
+ let dependsOn: string[] = [];
95
+
96
+ try {
97
+ const config = JSON.parse(fs.readFileSync(filePath, 'utf8')) as RunnerConfig;
98
+ dependsOn = config.dependsOn || [];
99
+ } catch (e) {
100
+ logger.warn(`Failed to parse config for lane ${name}: ${e}`);
101
+ }
102
+
103
+ return {
104
+ name,
105
+ path: filePath,
106
+ dependsOn,
107
+ };
108
+ });
94
109
  }
95
110
 
96
111
  /**
@@ -105,7 +120,8 @@ export function printLaneStatus(lanes: LaneInfo[], laneRunDirs: Record<string, s
105
120
  const state = loadState<LaneState>(statePath);
106
121
 
107
122
  if (!state) {
108
- return { lane: lane.name, status: '(no state)', task: '-' };
123
+ const isWaiting = lane.dependsOn.length > 0;
124
+ return { lane: lane.name, status: isWaiting ? 'waiting' : 'pending', task: '-' };
109
125
  }
110
126
 
111
127
  const idx = (state.currentTaskIndex || 0) + 1;
@@ -123,12 +139,13 @@ export function printLaneStatus(lanes: LaneInfo[], laneRunDirs: Record<string, s
123
139
  }
124
140
 
125
141
  /**
126
- * Run orchestration
142
+ * Run orchestration with dependency management
127
143
  */
128
144
  export async function orchestrate(tasksDir: string, options: {
129
145
  runDir?: string;
130
146
  executor?: string;
131
147
  pollInterval?: number;
148
+ maxConcurrentLanes?: number;
132
149
  } = {}): Promise<{ lanes: LaneInfo[]; exitCodes: Record<string, number>; runRoot: string }> {
133
150
  const lanes = listLaneFiles(tasksDir);
134
151
 
@@ -142,6 +159,7 @@ export async function orchestrate(tasksDir: string, options: {
142
159
  const laneRunDirs: Record<string, string> = {};
143
160
  for (const lane of lanes) {
144
161
  laneRunDirs[lane.name] = path.join(runRoot, 'lanes', lane.name);
162
+ fs.mkdirSync(laneRunDirs[lane.name], { recursive: true });
145
163
  }
146
164
 
147
165
  logger.section('🧭 Starting Orchestration');
@@ -149,31 +167,88 @@ export async function orchestrate(tasksDir: string, options: {
149
167
  logger.info(`Run directory: ${runRoot}`);
150
168
  logger.info(`Lanes: ${lanes.length}`);
151
169
 
152
- // Spawn all lanes
153
- const running: { lane: string; child: ChildProcess; logPath: string }[] = [];
154
-
155
- for (const lane of lanes) {
156
- const { child, logPath } = spawnLane({
157
- laneName: lane.name,
158
- tasksFile: lane.path,
159
- laneRunDir: laneRunDirs[lane.name]!,
160
- executor: options.executor || 'cursor-agent',
161
- });
162
-
163
- running.push({ lane: lane.name, child, logPath });
164
- logger.info(`Lane started: ${lane.name}`);
165
- }
170
+ const maxConcurrent = options.maxConcurrentLanes || 10;
171
+ const running: Map<string, { child: ChildProcess; logPath: string }> = new Map();
172
+ const exitCodes: Record<string, number> = {};
173
+ const completedLanes = new Set<string>();
174
+ const failedLanes = new Set<string>();
166
175
 
167
176
  // Monitor lanes
168
177
  const monitorInterval = setInterval(() => {
169
178
  printLaneStatus(lanes, laneRunDirs);
170
179
  }, options.pollInterval || 60000);
171
180
 
172
- // Wait for all lanes
173
- const exitCodes: Record<string, number> = {};
174
-
175
- for (const r of running) {
176
- exitCodes[r.lane] = await waitChild(r.child);
181
+ while (completedLanes.size + failedLanes.size < lanes.length) {
182
+ // 1. Identify lanes ready to start
183
+ const readyToStart = lanes.filter(lane => {
184
+ // Not already running or completed
185
+ if (running.has(lane.name) || completedLanes.has(lane.name) || failedLanes.has(lane.name)) {
186
+ return false;
187
+ }
188
+
189
+ // Check dependencies
190
+ for (const dep of lane.dependsOn) {
191
+ if (failedLanes.has(dep)) {
192
+ // If a dependency failed, this lane fails too
193
+ logger.error(`Lane ${lane.name} failed because dependency ${dep} failed`);
194
+ failedLanes.add(lane.name);
195
+ exitCodes[lane.name] = 1;
196
+ return false;
197
+ }
198
+ if (!completedLanes.has(dep)) {
199
+ return false;
200
+ }
201
+ }
202
+ return true;
203
+ });
204
+
205
+ // 2. Spawn ready lanes up to maxConcurrent
206
+ for (const lane of readyToStart) {
207
+ if (running.size >= maxConcurrent) break;
208
+
209
+ logger.info(`Lane started: ${lane.name}`);
210
+ const spawnResult = spawnLane({
211
+ laneName: lane.name,
212
+ tasksFile: lane.path,
213
+ laneRunDir: laneRunDirs[lane.name]!,
214
+ executor: options.executor || 'cursor-agent',
215
+ });
216
+
217
+ running.set(lane.name, spawnResult);
218
+ }
219
+
220
+ // 3. Wait for any running lane to finish
221
+ if (running.size > 0) {
222
+ // We need to wait for at least one to finish
223
+ const promises = Array.from(running.entries()).map(async ([name, { child }]) => {
224
+ const code = await waitChild(child);
225
+ return { name, code };
226
+ });
227
+
228
+ const finished = await Promise.race(promises);
229
+
230
+ running.delete(finished.name);
231
+ exitCodes[finished.name] = finished.code;
232
+
233
+ if (finished.code === 0 || finished.code === 2) {
234
+ completedLanes.add(finished.name);
235
+ } else {
236
+ failedLanes.add(finished.name);
237
+ }
238
+
239
+ printLaneStatus(lanes, laneRunDirs);
240
+ } else {
241
+ // Nothing running and nothing ready (but not all finished)
242
+ // This could happen if there's a circular dependency or some logic error
243
+ if (readyToStart.length === 0 && completedLanes.size + failedLanes.size < lanes.length) {
244
+ const remaining = lanes.filter(l => !completedLanes.has(l.name) && !failedLanes.has(l.name));
245
+ logger.error(`Deadlock detected! Remaining lanes cannot start: ${remaining.map(l => l.name).join(', ')}`);
246
+ for (const l of remaining) {
247
+ failedLanes.add(l.name);
248
+ exitCodes[l.name] = 1;
249
+ }
250
+ }
251
+ }
177
252
  }
178
253
 
179
254
  clearInterval(monitorInterval);