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