@litmers/cursorflow-orchestrator 0.1.8 → 0.1.12
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 +55 -0
- package/README.md +113 -319
- package/commands/cursorflow-clean.md +24 -135
- package/commands/cursorflow-doctor.md +74 -18
- package/commands/cursorflow-init.md +33 -50
- package/commands/cursorflow-models.md +51 -0
- package/commands/cursorflow-monitor.md +56 -118
- package/commands/cursorflow-prepare.md +410 -108
- package/commands/cursorflow-resume.md +51 -148
- package/commands/cursorflow-review.md +38 -202
- package/commands/cursorflow-run.md +208 -86
- package/commands/cursorflow-signal.md +38 -12
- package/dist/cli/clean.d.ts +3 -1
- package/dist/cli/clean.js +145 -8
- package/dist/cli/clean.js.map +1 -1
- package/dist/cli/doctor.js +14 -1
- package/dist/cli/doctor.js.map +1 -1
- package/dist/cli/index.js +32 -21
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/init.js +5 -4
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/models.d.ts +7 -0
- package/dist/cli/models.js +104 -0
- package/dist/cli/models.js.map +1 -0
- package/dist/cli/monitor.js +56 -1
- package/dist/cli/monitor.js.map +1 -1
- package/dist/cli/prepare.d.ts +7 -0
- package/dist/cli/prepare.js +748 -0
- package/dist/cli/prepare.js.map +1 -0
- package/dist/cli/resume.js +56 -0
- package/dist/cli/resume.js.map +1 -1
- package/dist/cli/run.js +30 -1
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/signal.js +18 -0
- package/dist/cli/signal.js.map +1 -1
- package/dist/core/runner.d.ts +9 -1
- package/dist/core/runner.js +139 -23
- package/dist/core/runner.js.map +1 -1
- package/dist/utils/cursor-agent.d.ts +4 -0
- package/dist/utils/cursor-agent.js +58 -10
- package/dist/utils/cursor-agent.js.map +1 -1
- package/dist/utils/doctor.d.ts +10 -0
- package/dist/utils/doctor.js +581 -1
- package/dist/utils/doctor.js.map +1 -1
- package/dist/utils/types.d.ts +11 -0
- package/examples/README.md +114 -59
- package/examples/demo-project/README.md +61 -79
- package/examples/demo-project/_cursorflow/tasks/demo-test/01-create-utils.json +17 -6
- package/examples/demo-project/_cursorflow/tasks/demo-test/02-add-tests.json +17 -6
- package/examples/demo-project/_cursorflow/tasks/demo-test/README.md +66 -25
- package/package.json +1 -1
- package/scripts/patches/test-cursor-agent.js +203 -0
- package/src/cli/clean.ts +156 -9
- package/src/cli/doctor.ts +18 -2
- package/src/cli/index.ts +33 -21
- package/src/cli/init.ts +6 -4
- package/src/cli/models.ts +83 -0
- package/src/cli/monitor.ts +60 -1
- package/src/cli/prepare.ts +844 -0
- package/src/cli/resume.ts +66 -0
- package/src/cli/run.ts +36 -2
- package/src/cli/signal.ts +22 -0
- package/src/core/runner.ts +164 -23
- package/src/utils/cursor-agent.ts +62 -10
- package/src/utils/doctor.ts +633 -5
- package/src/utils/types.ts +11 -0
package/src/utils/doctor.ts
CHANGED
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
* - Missing Git worktree support
|
|
8
8
|
* - Missing base branch referenced by lane task files
|
|
9
9
|
* - Missing/invalid tasks directory
|
|
10
|
+
* - Task validation (name, prompt, structure)
|
|
11
|
+
* - Circular dependency detection (DAG validation)
|
|
10
12
|
* - Missing Cursor Agent setup (optional)
|
|
11
13
|
*/
|
|
12
14
|
|
|
@@ -91,6 +93,43 @@ function hasOriginRemote(repoRoot: string): boolean {
|
|
|
91
93
|
return res.success && !!res.stdout;
|
|
92
94
|
}
|
|
93
95
|
|
|
96
|
+
function checkGitUserConfig(repoRoot: string): { name?: string; email?: string } {
|
|
97
|
+
const nameRes = git.runGitResult(['config', 'user.name'], { cwd: repoRoot });
|
|
98
|
+
const emailRes = git.runGitResult(['config', 'user.email'], { cwd: repoRoot });
|
|
99
|
+
return {
|
|
100
|
+
name: nameRes.success ? nameRes.stdout.trim() : undefined,
|
|
101
|
+
email: emailRes.success ? emailRes.stdout.trim() : undefined,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function checkGitPushPermission(repoRoot: string): { ok: boolean; details?: string } {
|
|
106
|
+
// Use dry-run to check if we can push to origin.
|
|
107
|
+
// We try pushing current HEAD to a non-existent temporary branch on origin to avoid side effects.
|
|
108
|
+
const tempBranch = `cursorflow-doctor-test-${Date.now()}`;
|
|
109
|
+
const res = git.runGitResult(['push', '--dry-run', 'origin', `HEAD:refs/heads/${tempBranch}`], { cwd: repoRoot });
|
|
110
|
+
if (res.success) return { ok: true };
|
|
111
|
+
return { ok: false, details: res.stderr || res.stdout || 'git push --dry-run failed' };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function checkRemoteConnectivity(repoRoot: string): { ok: boolean; details?: string } {
|
|
115
|
+
const res = git.runGitResult(['fetch', '--dry-run', 'origin'], { cwd: repoRoot });
|
|
116
|
+
if (res.success) return { ok: true };
|
|
117
|
+
return { ok: false, details: res.stderr || res.stdout || 'git fetch --dry-run failed' };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function getVersions(): { node: string; git: string } {
|
|
121
|
+
let gitVer = 'unknown';
|
|
122
|
+
try {
|
|
123
|
+
const res = git.runGitResult(['--version']);
|
|
124
|
+
gitVer = res.stdout.replace('git version ', '').trim();
|
|
125
|
+
} catch {}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
node: process.version,
|
|
129
|
+
git: gitVer,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
94
133
|
function hasWorktreeSupport(repoRoot: string): { ok: boolean; details?: string } {
|
|
95
134
|
const res = git.runGitResult(['worktree', 'list'], { cwd: repoRoot });
|
|
96
135
|
if (res.success) return { ok: true };
|
|
@@ -105,7 +144,7 @@ function branchExists(repoRoot: string, branchName: string): boolean {
|
|
|
105
144
|
return anyRes.success;
|
|
106
145
|
}
|
|
107
146
|
|
|
108
|
-
function readLaneJsonFiles(tasksDir: string): { path: string; json: any }[] {
|
|
147
|
+
function readLaneJsonFiles(tasksDir: string): { path: string; json: any; fileName: string }[] {
|
|
109
148
|
const files = fs
|
|
110
149
|
.readdirSync(tasksDir)
|
|
111
150
|
.filter(f => f.endsWith('.json'))
|
|
@@ -115,7 +154,7 @@ function readLaneJsonFiles(tasksDir: string): { path: string; json: any }[] {
|
|
|
115
154
|
return files.map(p => {
|
|
116
155
|
const raw = fs.readFileSync(p, 'utf8');
|
|
117
156
|
const json = JSON.parse(raw);
|
|
118
|
-
return { path: p, json };
|
|
157
|
+
return { path: p, json, fileName: path.basename(p, '.json') };
|
|
119
158
|
});
|
|
120
159
|
}
|
|
121
160
|
|
|
@@ -128,12 +167,447 @@ function collectBaseBranchesFromLanes(lanes: { path: string; json: any }[], defa
|
|
|
128
167
|
return Array.from(set);
|
|
129
168
|
}
|
|
130
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Validate task structure within a lane
|
|
172
|
+
*/
|
|
173
|
+
function validateTaskStructure(
|
|
174
|
+
issues: DoctorIssue[],
|
|
175
|
+
laneFile: string,
|
|
176
|
+
json: any
|
|
177
|
+
): void {
|
|
178
|
+
const laneName = path.basename(laneFile, '.json');
|
|
179
|
+
|
|
180
|
+
// Check if tasks array exists
|
|
181
|
+
if (!json.tasks) {
|
|
182
|
+
addIssue(issues, {
|
|
183
|
+
id: `tasks.${laneName}.missing_tasks`,
|
|
184
|
+
severity: 'error',
|
|
185
|
+
title: `Missing tasks array in ${laneName}`,
|
|
186
|
+
message: `Lane "${laneName}" does not have a "tasks" array.`,
|
|
187
|
+
fixes: ['Add a "tasks" array with at least one task object'],
|
|
188
|
+
});
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (!Array.isArray(json.tasks)) {
|
|
193
|
+
addIssue(issues, {
|
|
194
|
+
id: `tasks.${laneName}.invalid_tasks`,
|
|
195
|
+
severity: 'error',
|
|
196
|
+
title: `Invalid tasks in ${laneName}`,
|
|
197
|
+
message: `Lane "${laneName}" has "tasks" but it's not an array.`,
|
|
198
|
+
fixes: ['Ensure "tasks" is an array of task objects'],
|
|
199
|
+
});
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if (json.tasks.length === 0) {
|
|
204
|
+
addIssue(issues, {
|
|
205
|
+
id: `tasks.${laneName}.empty_tasks`,
|
|
206
|
+
severity: 'error',
|
|
207
|
+
title: `No tasks in ${laneName}`,
|
|
208
|
+
message: `Lane "${laneName}" has an empty tasks array.`,
|
|
209
|
+
fixes: ['Add at least one task with "name" and "prompt" fields'],
|
|
210
|
+
});
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Validate each task
|
|
215
|
+
const taskNamePattern = /^[a-zA-Z0-9_-]+$/;
|
|
216
|
+
const seenNames = new Set<string>();
|
|
217
|
+
|
|
218
|
+
json.tasks.forEach((task: any, index: number) => {
|
|
219
|
+
const taskId = task.name || `task[${index}]`;
|
|
220
|
+
|
|
221
|
+
// Check name
|
|
222
|
+
if (!task.name) {
|
|
223
|
+
addIssue(issues, {
|
|
224
|
+
id: `tasks.${laneName}.${index}.missing_name`,
|
|
225
|
+
severity: 'error',
|
|
226
|
+
title: `Missing task name in ${laneName}`,
|
|
227
|
+
message: `Task at index ${index} in "${laneName}" is missing the "name" field.`,
|
|
228
|
+
fixes: ['Add a "name" field to the task (e.g., "implement", "test")'],
|
|
229
|
+
});
|
|
230
|
+
} else if (typeof task.name !== 'string') {
|
|
231
|
+
addIssue(issues, {
|
|
232
|
+
id: `tasks.${laneName}.${index}.invalid_name_type`,
|
|
233
|
+
severity: 'error',
|
|
234
|
+
title: `Invalid task name type in ${laneName}`,
|
|
235
|
+
message: `Task at index ${index} in "${laneName}" has a non-string "name" field.`,
|
|
236
|
+
fixes: ['Ensure "name" is a string'],
|
|
237
|
+
});
|
|
238
|
+
} else if (!taskNamePattern.test(task.name)) {
|
|
239
|
+
addIssue(issues, {
|
|
240
|
+
id: `tasks.${laneName}.${taskId}.invalid_name_format`,
|
|
241
|
+
severity: 'error',
|
|
242
|
+
title: `Invalid task name format in ${laneName}`,
|
|
243
|
+
message: `Task "${task.name}" in "${laneName}" has invalid characters. Only alphanumeric, "-", and "_" are allowed.`,
|
|
244
|
+
fixes: [`Rename task to use only alphanumeric characters, "-", or "_"`],
|
|
245
|
+
});
|
|
246
|
+
} else if (seenNames.has(task.name)) {
|
|
247
|
+
addIssue(issues, {
|
|
248
|
+
id: `tasks.${laneName}.${taskId}.duplicate_name`,
|
|
249
|
+
severity: 'error',
|
|
250
|
+
title: `Duplicate task name in ${laneName}`,
|
|
251
|
+
message: `Task name "${task.name}" appears multiple times in "${laneName}".`,
|
|
252
|
+
fixes: ['Ensure each task has a unique name within the lane'],
|
|
253
|
+
});
|
|
254
|
+
} else {
|
|
255
|
+
seenNames.add(task.name);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Check prompt
|
|
259
|
+
if (!task.prompt) {
|
|
260
|
+
addIssue(issues, {
|
|
261
|
+
id: `tasks.${laneName}.${taskId}.missing_prompt`,
|
|
262
|
+
severity: 'error',
|
|
263
|
+
title: `Missing task prompt in ${laneName}`,
|
|
264
|
+
message: `Task "${taskId}" in "${laneName}" is missing the "prompt" field.`,
|
|
265
|
+
fixes: ['Add a "prompt" field with instructions for the AI'],
|
|
266
|
+
});
|
|
267
|
+
} else if (typeof task.prompt !== 'string') {
|
|
268
|
+
addIssue(issues, {
|
|
269
|
+
id: `tasks.${laneName}.${taskId}.invalid_prompt_type`,
|
|
270
|
+
severity: 'error',
|
|
271
|
+
title: `Invalid task prompt type in ${laneName}`,
|
|
272
|
+
message: `Task "${taskId}" in "${laneName}" has a non-string "prompt" field.`,
|
|
273
|
+
fixes: ['Ensure "prompt" is a string'],
|
|
274
|
+
});
|
|
275
|
+
} else if (task.prompt.trim().length < 10) {
|
|
276
|
+
addIssue(issues, {
|
|
277
|
+
id: `tasks.${laneName}.${taskId}.short_prompt`,
|
|
278
|
+
severity: 'warn',
|
|
279
|
+
title: `Short task prompt in ${laneName}`,
|
|
280
|
+
message: `Task "${taskId}" in "${laneName}" has a very short prompt (${task.prompt.trim().length} chars). Consider providing more detailed instructions.`,
|
|
281
|
+
fixes: ['Provide clearer, more detailed instructions in the prompt'],
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Check acceptanceCriteria if present
|
|
286
|
+
if (task.acceptanceCriteria !== undefined) {
|
|
287
|
+
if (!Array.isArray(task.acceptanceCriteria)) {
|
|
288
|
+
addIssue(issues, {
|
|
289
|
+
id: `tasks.${laneName}.${taskId}.invalid_criteria_type`,
|
|
290
|
+
severity: 'error',
|
|
291
|
+
title: `Invalid acceptanceCriteria in ${laneName}`,
|
|
292
|
+
message: `Task "${taskId}" in "${laneName}" has "acceptanceCriteria" but it's not an array.`,
|
|
293
|
+
fixes: ['Ensure "acceptanceCriteria" is an array of strings'],
|
|
294
|
+
});
|
|
295
|
+
} else if (task.acceptanceCriteria.length === 0) {
|
|
296
|
+
addIssue(issues, {
|
|
297
|
+
id: `tasks.${laneName}.${taskId}.empty_criteria`,
|
|
298
|
+
severity: 'warn',
|
|
299
|
+
title: `Empty acceptanceCriteria in ${laneName}`,
|
|
300
|
+
message: `Task "${taskId}" in "${laneName}" has an empty "acceptanceCriteria" array.`,
|
|
301
|
+
fixes: ['Add acceptance criteria or remove the empty array'],
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Check model if present
|
|
307
|
+
if (task.model !== undefined && typeof task.model !== 'string') {
|
|
308
|
+
addIssue(issues, {
|
|
309
|
+
id: `tasks.${laneName}.${taskId}.invalid_model_type`,
|
|
310
|
+
severity: 'error',
|
|
311
|
+
title: `Invalid model type in ${laneName}`,
|
|
312
|
+
message: `Task "${taskId}" in "${laneName}" has a non-string "model" field.`,
|
|
313
|
+
fixes: ['Ensure "model" is a string (e.g., "sonnet-4.5")'],
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Detect circular dependencies in the lane dependency graph (DAG validation)
|
|
321
|
+
*/
|
|
322
|
+
function detectCircularDependencies(
|
|
323
|
+
issues: DoctorIssue[],
|
|
324
|
+
lanes: { path: string; json: any; fileName: string }[]
|
|
325
|
+
): void {
|
|
326
|
+
// Build adjacency list
|
|
327
|
+
const graph = new Map<string, string[]>();
|
|
328
|
+
const allLaneNames = new Set<string>();
|
|
329
|
+
|
|
330
|
+
for (const lane of lanes) {
|
|
331
|
+
allLaneNames.add(lane.fileName);
|
|
332
|
+
const deps = lane.json.dependsOn || [];
|
|
333
|
+
graph.set(lane.fileName, Array.isArray(deps) ? deps : []);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Check for unknown dependencies
|
|
337
|
+
for (const lane of lanes) {
|
|
338
|
+
const deps = lane.json.dependsOn || [];
|
|
339
|
+
if (!Array.isArray(deps)) continue;
|
|
340
|
+
|
|
341
|
+
for (const dep of deps) {
|
|
342
|
+
if (!allLaneNames.has(dep)) {
|
|
343
|
+
addIssue(issues, {
|
|
344
|
+
id: `tasks.${lane.fileName}.unknown_dependency`,
|
|
345
|
+
severity: 'error',
|
|
346
|
+
title: `Unknown dependency in ${lane.fileName}`,
|
|
347
|
+
message: `Lane "${lane.fileName}" depends on "${dep}" which does not exist.`,
|
|
348
|
+
fixes: [
|
|
349
|
+
`Verify the dependency name matches an existing lane file (without .json extension)`,
|
|
350
|
+
`Available lanes: ${Array.from(allLaneNames).join(', ')}`,
|
|
351
|
+
],
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Detect cycles using DFS
|
|
358
|
+
const visited = new Set<string>();
|
|
359
|
+
const recursionStack = new Set<string>();
|
|
360
|
+
const cyclePath: string[] = [];
|
|
361
|
+
|
|
362
|
+
function hasCycle(node: string, path: string[]): boolean {
|
|
363
|
+
if (recursionStack.has(node)) {
|
|
364
|
+
// Found a cycle
|
|
365
|
+
const cycleStart = path.indexOf(node);
|
|
366
|
+
cyclePath.push(...path.slice(cycleStart), node);
|
|
367
|
+
return true;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (visited.has(node)) {
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
visited.add(node);
|
|
375
|
+
recursionStack.add(node);
|
|
376
|
+
|
|
377
|
+
const deps = graph.get(node) || [];
|
|
378
|
+
for (const dep of deps) {
|
|
379
|
+
if (hasCycle(dep, [...path, node])) {
|
|
380
|
+
return true;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
recursionStack.delete(node);
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
for (const laneName of allLaneNames) {
|
|
389
|
+
cyclePath.length = 0;
|
|
390
|
+
visited.clear();
|
|
391
|
+
recursionStack.clear();
|
|
392
|
+
|
|
393
|
+
if (hasCycle(laneName, [])) {
|
|
394
|
+
addIssue(issues, {
|
|
395
|
+
id: 'tasks.circular_dependency',
|
|
396
|
+
severity: 'error',
|
|
397
|
+
title: 'Circular dependency detected',
|
|
398
|
+
message: `Circular dependency found: ${cyclePath.join(' → ')}`,
|
|
399
|
+
details: 'Lane dependencies must form a DAG (Directed Acyclic Graph). Circular dependencies will cause a deadlock.',
|
|
400
|
+
fixes: [
|
|
401
|
+
'Review the "dependsOn" fields in your lane files',
|
|
402
|
+
'Remove one of the dependencies to break the cycle',
|
|
403
|
+
],
|
|
404
|
+
});
|
|
405
|
+
return; // Report only one cycle
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function checkPackageManager(): { name: string; ok: boolean } {
|
|
411
|
+
const { spawnSync } = require('child_process');
|
|
412
|
+
|
|
413
|
+
// Try pnpm first as it's the default in prompts
|
|
414
|
+
try {
|
|
415
|
+
const pnpmRes = spawnSync('pnpm', ['--version'], { encoding: 'utf8' });
|
|
416
|
+
if (pnpmRes.status === 0) return { name: 'pnpm', ok: true };
|
|
417
|
+
} catch {}
|
|
418
|
+
|
|
419
|
+
try {
|
|
420
|
+
const npmRes = spawnSync('npm', ['--version'], { encoding: 'utf8' });
|
|
421
|
+
if (npmRes.status === 0) return { name: 'npm', ok: true };
|
|
422
|
+
} catch {}
|
|
423
|
+
|
|
424
|
+
return { name: 'unknown', ok: false };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function checkDiskSpace(dir: string): { ok: boolean; freeBytes?: number; error?: string } {
|
|
428
|
+
const { execSync } = require('child_process');
|
|
429
|
+
try {
|
|
430
|
+
// df -B1 returns bytes. We look for the line corresponding to our directory.
|
|
431
|
+
const output = execSync(`df -B1 "${dir}"`, { encoding: 'utf8' });
|
|
432
|
+
const lines = output.trim().split('\n');
|
|
433
|
+
if (lines.length < 2) return { ok: false, error: 'Could not parse df output' };
|
|
434
|
+
|
|
435
|
+
const parts = lines[1]!.trim().split(/\s+/);
|
|
436
|
+
// df output: Filesystem 1B-blocks Used Available Use% Mounted on
|
|
437
|
+
// Available is index 3
|
|
438
|
+
const available = parseInt(parts[3]!);
|
|
439
|
+
if (isNaN(available)) return { ok: false, error: 'Could not parse available bytes' };
|
|
440
|
+
|
|
441
|
+
return { ok: true, freeBytes: available };
|
|
442
|
+
} catch (e: any) {
|
|
443
|
+
return { ok: false, error: e.message };
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Get all local branch names
|
|
449
|
+
*/
|
|
450
|
+
function getAllLocalBranches(repoRoot: string): string[] {
|
|
451
|
+
const res = git.runGitResult(['branch', '--list', '--format=%(refname:short)'], { cwd: repoRoot });
|
|
452
|
+
if (!res.success) return [];
|
|
453
|
+
return res.stdout.split('\n').map(b => b.trim()).filter(b => b);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Get all remote branch names
|
|
458
|
+
*/
|
|
459
|
+
function getAllRemoteBranches(repoRoot: string): string[] {
|
|
460
|
+
const res = git.runGitResult(['branch', '-r', '--list', '--format=%(refname:short)'], { cwd: repoRoot });
|
|
461
|
+
if (!res.success) return [];
|
|
462
|
+
return res.stdout.split('\n')
|
|
463
|
+
.map(b => b.trim().replace(/^origin\//, ''))
|
|
464
|
+
.filter(b => b && !b.includes('HEAD'));
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Validate branch names for conflicts and issues
|
|
469
|
+
*/
|
|
470
|
+
function validateBranchNames(
|
|
471
|
+
issues: DoctorIssue[],
|
|
472
|
+
lanes: { path: string; json: any; fileName: string }[],
|
|
473
|
+
repoRoot: string
|
|
474
|
+
): void {
|
|
475
|
+
const localBranches = getAllLocalBranches(repoRoot);
|
|
476
|
+
const remoteBranches = getAllRemoteBranches(repoRoot);
|
|
477
|
+
const allExistingBranches = new Set([...localBranches, ...remoteBranches]);
|
|
478
|
+
|
|
479
|
+
// Collect branch prefixes from lanes
|
|
480
|
+
const branchPrefixes: { laneName: string; prefix: string }[] = [];
|
|
481
|
+
|
|
482
|
+
for (const lane of lanes) {
|
|
483
|
+
const branchPrefix = lane.json?.branchPrefix;
|
|
484
|
+
if (branchPrefix) {
|
|
485
|
+
branchPrefixes.push({ laneName: lane.fileName, prefix: branchPrefix });
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Check for branch prefix collisions between lanes
|
|
490
|
+
const prefixMap = new Map<string, string[]>();
|
|
491
|
+
for (const { laneName, prefix } of branchPrefixes) {
|
|
492
|
+
const existing = prefixMap.get(prefix) || [];
|
|
493
|
+
existing.push(laneName);
|
|
494
|
+
prefixMap.set(prefix, existing);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
for (const [prefix, laneNames] of prefixMap) {
|
|
498
|
+
if (laneNames.length > 1) {
|
|
499
|
+
addIssue(issues, {
|
|
500
|
+
id: 'branch.prefix_collision',
|
|
501
|
+
severity: 'error',
|
|
502
|
+
title: 'Branch prefix collision',
|
|
503
|
+
message: `Multiple lanes use the same branchPrefix "${prefix}": ${laneNames.join(', ')}`,
|
|
504
|
+
details: 'Each lane should have a unique branchPrefix to avoid conflicts.',
|
|
505
|
+
fixes: [
|
|
506
|
+
'Update the branchPrefix in each lane JSON file to be unique',
|
|
507
|
+
'Example: "featurename/lane-1-", "featurename/lane-2-"',
|
|
508
|
+
],
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Check for existing branches that match lane prefixes
|
|
514
|
+
for (const { laneName, prefix } of branchPrefixes) {
|
|
515
|
+
const conflictingBranches: string[] = [];
|
|
516
|
+
|
|
517
|
+
for (const branch of allExistingBranches) {
|
|
518
|
+
if (branch.startsWith(prefix)) {
|
|
519
|
+
conflictingBranches.push(branch);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
if (conflictingBranches.length > 0) {
|
|
524
|
+
addIssue(issues, {
|
|
525
|
+
id: `branch.existing_conflict.${laneName}`,
|
|
526
|
+
severity: 'warn',
|
|
527
|
+
title: `Existing branches may conflict with ${laneName}`,
|
|
528
|
+
message: `Found ${conflictingBranches.length} existing branch(es) matching prefix "${prefix}": ${conflictingBranches.slice(0, 3).join(', ')}${conflictingBranches.length > 3 ? '...' : ''}`,
|
|
529
|
+
details: 'These branches may cause issues if the lane tries to create a new branch with the same name.',
|
|
530
|
+
fixes: [
|
|
531
|
+
`Delete conflicting branches: git branch -D ${conflictingBranches[0]}`,
|
|
532
|
+
`Or change the branchPrefix in ${laneName}.json`,
|
|
533
|
+
'Run: cursorflow clean branches --dry-run to see all CursorFlow branches',
|
|
534
|
+
],
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// Check for duplicate lane file names (which would cause branch issues)
|
|
540
|
+
const laneFileNames = lanes.map(l => l.fileName);
|
|
541
|
+
const duplicateNames = laneFileNames.filter((name, index) => laneFileNames.indexOf(name) !== index);
|
|
542
|
+
|
|
543
|
+
if (duplicateNames.length > 0) {
|
|
544
|
+
addIssue(issues, {
|
|
545
|
+
id: 'tasks.duplicate_lane_files',
|
|
546
|
+
severity: 'error',
|
|
547
|
+
title: 'Duplicate lane file names',
|
|
548
|
+
message: `Found duplicate lane names: ${[...new Set(duplicateNames)].join(', ')}`,
|
|
549
|
+
fixes: ['Ensure each lane file has a unique name'],
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Suggest unique branch naming convention
|
|
554
|
+
const hasNumericPrefix = branchPrefixes.some(({ prefix }) => /\/lane-\d+-$/.test(prefix));
|
|
555
|
+
if (!hasNumericPrefix && branchPrefixes.length > 1) {
|
|
556
|
+
addIssue(issues, {
|
|
557
|
+
id: 'branch.naming_suggestion',
|
|
558
|
+
severity: 'warn',
|
|
559
|
+
title: 'Consider using lane numbers in branch prefix',
|
|
560
|
+
message: 'Using consistent lane numbers in branch prefixes helps avoid conflicts.',
|
|
561
|
+
fixes: [
|
|
562
|
+
'Use pattern: "feature-name/lane-{N}-" where N is the lane number',
|
|
563
|
+
'Example: "auth-system/lane-1-", "auth-system/lane-2-"',
|
|
564
|
+
],
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Status file to track when doctor was last run successfully.
|
|
571
|
+
*/
|
|
572
|
+
const DOCTOR_STATUS_FILE = '.cursorflow/doctor-status.json';
|
|
573
|
+
|
|
574
|
+
export function saveDoctorStatus(repoRoot: string, report: DoctorReport): void {
|
|
575
|
+
const statusPath = path.join(repoRoot, DOCTOR_STATUS_FILE);
|
|
576
|
+
const statusDir = path.dirname(statusPath);
|
|
577
|
+
|
|
578
|
+
if (!fs.existsSync(statusDir)) {
|
|
579
|
+
fs.mkdirSync(statusDir, { recursive: true });
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const status = {
|
|
583
|
+
lastRun: Date.now(),
|
|
584
|
+
ok: report.ok,
|
|
585
|
+
issueCount: report.issues.length,
|
|
586
|
+
nodeVersion: process.version,
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
fs.writeFileSync(statusPath, JSON.stringify(status, null, 2), 'utf8');
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
export function getDoctorStatus(repoRoot: string): { lastRun: number; ok: boolean; issueCount: number } | null {
|
|
593
|
+
const statusPath = path.join(repoRoot, DOCTOR_STATUS_FILE);
|
|
594
|
+
if (!fs.existsSync(statusPath)) return null;
|
|
595
|
+
|
|
596
|
+
try {
|
|
597
|
+
return JSON.parse(fs.readFileSync(statusPath, 'utf8'));
|
|
598
|
+
} catch {
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
131
603
|
/**
|
|
132
604
|
* Run doctor checks.
|
|
133
605
|
*
|
|
134
606
|
* If `tasksDir` is provided, additional preflight checks are performed:
|
|
135
607
|
* - tasks directory existence and JSON validity
|
|
136
608
|
* - baseBranch referenced by lanes exists locally
|
|
609
|
+
* - Task structure validation (name, prompt, etc.)
|
|
610
|
+
* - Circular dependency detection (DAG validation)
|
|
137
611
|
*/
|
|
138
612
|
export function runDoctor(options: DoctorOptions = {}): DoctorReport {
|
|
139
613
|
const cwd = options.cwd || process.cwd();
|
|
@@ -144,6 +618,59 @@ export function runDoctor(options: DoctorOptions = {}): DoctorReport {
|
|
|
144
618
|
executor: options.executor,
|
|
145
619
|
};
|
|
146
620
|
|
|
621
|
+
// 0) System and environment checks
|
|
622
|
+
const versions = getVersions();
|
|
623
|
+
const nodeMajor = parseInt(versions.node.slice(1).split('.')[0] || '0');
|
|
624
|
+
if (nodeMajor < 18) {
|
|
625
|
+
addIssue(issues, {
|
|
626
|
+
id: 'env.node_version',
|
|
627
|
+
severity: 'error',
|
|
628
|
+
title: 'Node.js version too old',
|
|
629
|
+
message: `CursorFlow requires Node.js >= 18.0.0. Current version: ${versions.node}`,
|
|
630
|
+
fixes: ['Upgrade Node.js to a supported version (e.g., using nvm or from nodejs.org)'],
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const gitVerMatch = versions.git.match(/^(\d+)\.(\d+)/);
|
|
635
|
+
if (gitVerMatch) {
|
|
636
|
+
const major = parseInt(gitVerMatch[1]!);
|
|
637
|
+
const minor = parseInt(gitVerMatch[2]!);
|
|
638
|
+
if (major < 2 || (major === 2 && minor < 5)) {
|
|
639
|
+
addIssue(issues, {
|
|
640
|
+
id: 'env.git_version',
|
|
641
|
+
severity: 'error',
|
|
642
|
+
title: 'Git version too old',
|
|
643
|
+
message: `CursorFlow requires Git >= 2.5 for worktree support. Current version: ${versions.git}`,
|
|
644
|
+
fixes: ['Upgrade Git to a version >= 2.5'],
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const pkgManager = checkPackageManager();
|
|
650
|
+
if (!pkgManager.ok) {
|
|
651
|
+
addIssue(issues, {
|
|
652
|
+
id: 'env.package_manager',
|
|
653
|
+
severity: 'warn',
|
|
654
|
+
title: 'No standard package manager found',
|
|
655
|
+
message: 'Neither pnpm nor npm was found in your PATH. CursorFlow tasks often rely on these for dependency management.',
|
|
656
|
+
fixes: ['Install pnpm (recommended): npm install -g pnpm', 'Or ensure npm is in your PATH'],
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const diskSpace = checkDiskSpace(cwd);
|
|
661
|
+
if (diskSpace.ok && diskSpace.freeBytes !== undefined) {
|
|
662
|
+
const freeGB = diskSpace.freeBytes / (1024 * 1024 * 1024);
|
|
663
|
+
if (freeGB < 1) {
|
|
664
|
+
addIssue(issues, {
|
|
665
|
+
id: 'env.low_disk_space',
|
|
666
|
+
severity: 'warn',
|
|
667
|
+
title: 'Low disk space',
|
|
668
|
+
message: `Low disk space detected: ${freeGB.toFixed(2)} GB available. CursorFlow creates Git worktrees which can consume significant space.`,
|
|
669
|
+
fixes: ['Free up disk space before running large orchestration tasks'],
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
147
674
|
// 1) Git repository checks
|
|
148
675
|
if (!isInsideGitWorktree(cwd)) {
|
|
149
676
|
addIssue(issues, {
|
|
@@ -185,6 +712,67 @@ export function runDoctor(options: DoctorOptions = {}): DoctorReport {
|
|
|
185
712
|
'git remote -v # verify remotes',
|
|
186
713
|
],
|
|
187
714
|
});
|
|
715
|
+
} else {
|
|
716
|
+
// Advanced check: remote connectivity
|
|
717
|
+
const connectivity = checkRemoteConnectivity(gitCwd);
|
|
718
|
+
if (!connectivity.ok) {
|
|
719
|
+
addIssue(issues, {
|
|
720
|
+
id: 'git.remote_connectivity',
|
|
721
|
+
severity: 'error',
|
|
722
|
+
title: "Cannot connect to 'origin'",
|
|
723
|
+
message: "Failed to communicate with the remote 'origin'. Check your internet connection or SSH/HTTPS credentials.",
|
|
724
|
+
details: connectivity.details,
|
|
725
|
+
fixes: [
|
|
726
|
+
'git fetch origin',
|
|
727
|
+
'Verify your SSH keys or credentials are configured correctly',
|
|
728
|
+
],
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Advanced check: push permission
|
|
733
|
+
const pushPerm = checkGitPushPermission(gitCwd);
|
|
734
|
+
if (!pushPerm.ok) {
|
|
735
|
+
addIssue(issues, {
|
|
736
|
+
id: 'git.push_permission',
|
|
737
|
+
severity: 'warn',
|
|
738
|
+
title: 'Push permission check failed',
|
|
739
|
+
message: "CursorFlow might not be able to push branches to 'origin'. A dry-run push failed.",
|
|
740
|
+
details: pushPerm.details,
|
|
741
|
+
fixes: [
|
|
742
|
+
'Verify you have write access to the repository on GitHub/GitLab',
|
|
743
|
+
'Check if the branch naming policy on the remote permits `cursorflow/*` branches',
|
|
744
|
+
],
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Advanced check: current branch upstream
|
|
749
|
+
const currentBranch = git.getCurrentBranch(gitCwd);
|
|
750
|
+
const upstreamRes = git.runGitResult(['rev-parse', '--abbrev-ref', `${currentBranch}@{u}`], { cwd: gitCwd });
|
|
751
|
+
if (!upstreamRes.success && currentBranch !== 'main' && currentBranch !== 'master') {
|
|
752
|
+
addIssue(issues, {
|
|
753
|
+
id: 'git.no_upstream',
|
|
754
|
+
severity: 'warn',
|
|
755
|
+
title: 'Current branch has no upstream',
|
|
756
|
+
message: `The current branch "${currentBranch}" is not tracking a remote branch.`,
|
|
757
|
+
fixes: [
|
|
758
|
+
`git push -u origin ${currentBranch}`,
|
|
759
|
+
],
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const gitUser = checkGitUserConfig(gitCwd);
|
|
765
|
+
if (!gitUser.name || !gitUser.email) {
|
|
766
|
+
addIssue(issues, {
|
|
767
|
+
id: 'git.user_config',
|
|
768
|
+
severity: 'error',
|
|
769
|
+
title: 'Git user not configured',
|
|
770
|
+
message: 'Git user name or email is not set. CursorFlow cannot create commits without these.',
|
|
771
|
+
fixes: [
|
|
772
|
+
`git config --global user.name "Your Name"`,
|
|
773
|
+
`git config --global user.email "you@example.com"`,
|
|
774
|
+
],
|
|
775
|
+
});
|
|
188
776
|
}
|
|
189
777
|
|
|
190
778
|
const wt = hasWorktreeSupport(gitCwd);
|
|
@@ -200,6 +788,25 @@ export function runDoctor(options: DoctorOptions = {}): DoctorReport {
|
|
|
200
788
|
],
|
|
201
789
|
details: wt.details,
|
|
202
790
|
});
|
|
791
|
+
} else {
|
|
792
|
+
// Advanced check: .gitignore check for worktrees
|
|
793
|
+
const gitignorePath = path.join(gitCwd, '.gitignore');
|
|
794
|
+
const worktreeDirName = '_cursorflow'; // Default directory name
|
|
795
|
+
if (fs.existsSync(gitignorePath)) {
|
|
796
|
+
const content = fs.readFileSync(gitignorePath, 'utf8');
|
|
797
|
+
if (!content.includes(worktreeDirName)) {
|
|
798
|
+
addIssue(issues, {
|
|
799
|
+
id: 'git.gitignore_missing_worktree',
|
|
800
|
+
severity: 'warn',
|
|
801
|
+
title: 'Worktree directory not ignored',
|
|
802
|
+
message: `The directory "${worktreeDirName}" is not in your .gitignore. This could lead to accidentally committing temporary worktrees or logs.`,
|
|
803
|
+
fixes: [
|
|
804
|
+
`Add "${worktreeDirName}/" to your .gitignore`,
|
|
805
|
+
'Run `cursorflow init` to set up recommended ignores',
|
|
806
|
+
],
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
}
|
|
203
810
|
}
|
|
204
811
|
|
|
205
812
|
// 2) Tasks-dir checks (optional; used by `cursorflow run` preflight)
|
|
@@ -221,7 +828,7 @@ export function runDoctor(options: DoctorOptions = {}): DoctorReport {
|
|
|
221
828
|
],
|
|
222
829
|
});
|
|
223
830
|
} else {
|
|
224
|
-
let lanes: { path: string; json: any }[] = [];
|
|
831
|
+
let lanes: { path: string; json: any; fileName: string }[] = [];
|
|
225
832
|
try {
|
|
226
833
|
lanes = readLaneJsonFiles(tasksDirAbs);
|
|
227
834
|
} catch (error: any) {
|
|
@@ -245,6 +852,7 @@ export function runDoctor(options: DoctorOptions = {}): DoctorReport {
|
|
|
245
852
|
fixes: ['Ensure the tasks directory contains one or more lane JSON files'],
|
|
246
853
|
});
|
|
247
854
|
} else {
|
|
855
|
+
// Validate base branches
|
|
248
856
|
const baseBranches = collectBaseBranchesFromLanes(lanes, 'main');
|
|
249
857
|
for (const baseBranch of baseBranches) {
|
|
250
858
|
if (!branchExists(gitCwd, baseBranch)) {
|
|
@@ -260,6 +868,17 @@ export function runDoctor(options: DoctorOptions = {}): DoctorReport {
|
|
|
260
868
|
});
|
|
261
869
|
}
|
|
262
870
|
}
|
|
871
|
+
|
|
872
|
+
// Validate task structure in each lane
|
|
873
|
+
for (const lane of lanes) {
|
|
874
|
+
validateTaskStructure(issues, lane.fileName, lane.json);
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// Detect circular dependencies
|
|
878
|
+
detectCircularDependencies(issues, lanes);
|
|
879
|
+
|
|
880
|
+
// Validate branch names - check for conflicts
|
|
881
|
+
validateBranchNames(issues, lanes, gitCwd);
|
|
263
882
|
}
|
|
264
883
|
}
|
|
265
884
|
}
|
|
@@ -291,6 +910,17 @@ export function runDoctor(options: DoctorOptions = {}): DoctorReport {
|
|
|
291
910
|
],
|
|
292
911
|
});
|
|
293
912
|
}
|
|
913
|
+
|
|
914
|
+
// MCP/Permissions potential hang check
|
|
915
|
+
addIssue(issues, {
|
|
916
|
+
id: 'cursor_agent.mcp_priming',
|
|
917
|
+
severity: 'warn',
|
|
918
|
+
title: 'Agent may require interactive approval',
|
|
919
|
+
message: 'Non-interactive execution (with --print) can hang if MCP permissions or user approvals are required.',
|
|
920
|
+
fixes: [
|
|
921
|
+
'Run once interactively to prime permissions: cursorflow doctor --test-agent',
|
|
922
|
+
],
|
|
923
|
+
});
|
|
294
924
|
}
|
|
295
925
|
}
|
|
296
926
|
|
|
@@ -308,5 +938,3 @@ export function runDoctor(options: DoctorOptions = {}): DoctorReport {
|
|
|
308
938
|
const ok = issues.every(i => i.severity !== 'error');
|
|
309
939
|
return { ok, issues, context };
|
|
310
940
|
}
|
|
311
|
-
|
|
312
|
-
|