@litmers/cursorflow-orchestrator 0.1.9 → 0.1.13

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 (60) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +90 -72
  3. package/commands/cursorflow-clean.md +24 -135
  4. package/commands/cursorflow-doctor.md +66 -38
  5. package/commands/cursorflow-init.md +33 -50
  6. package/commands/cursorflow-models.md +51 -0
  7. package/commands/cursorflow-monitor.md +52 -72
  8. package/commands/cursorflow-prepare.md +426 -147
  9. package/commands/cursorflow-resume.md +51 -159
  10. package/commands/cursorflow-review.md +38 -202
  11. package/commands/cursorflow-run.md +197 -84
  12. package/commands/cursorflow-signal.md +27 -72
  13. package/dist/cli/clean.js +23 -0
  14. package/dist/cli/clean.js.map +1 -1
  15. package/dist/cli/doctor.js +14 -1
  16. package/dist/cli/doctor.js.map +1 -1
  17. package/dist/cli/index.js +14 -3
  18. package/dist/cli/index.js.map +1 -1
  19. package/dist/cli/init.js +5 -4
  20. package/dist/cli/init.js.map +1 -1
  21. package/dist/cli/models.d.ts +7 -0
  22. package/dist/cli/models.js +104 -0
  23. package/dist/cli/models.js.map +1 -0
  24. package/dist/cli/monitor.js +17 -0
  25. package/dist/cli/monitor.js.map +1 -1
  26. package/dist/cli/prepare.d.ts +7 -0
  27. package/dist/cli/prepare.js +748 -0
  28. package/dist/cli/prepare.js.map +1 -0
  29. package/dist/cli/resume.js +56 -0
  30. package/dist/cli/resume.js.map +1 -1
  31. package/dist/cli/run.js +30 -1
  32. package/dist/cli/run.js.map +1 -1
  33. package/dist/cli/signal.js +18 -0
  34. package/dist/cli/signal.js.map +1 -1
  35. package/dist/utils/cursor-agent.d.ts +4 -0
  36. package/dist/utils/cursor-agent.js +58 -10
  37. package/dist/utils/cursor-agent.js.map +1 -1
  38. package/dist/utils/doctor.d.ts +10 -0
  39. package/dist/utils/doctor.js +588 -1
  40. package/dist/utils/doctor.js.map +1 -1
  41. package/dist/utils/types.d.ts +2 -0
  42. package/examples/README.md +114 -59
  43. package/examples/demo-project/README.md +61 -79
  44. package/examples/demo-project/_cursorflow/tasks/demo-test/01-create-utils.json +17 -6
  45. package/examples/demo-project/_cursorflow/tasks/demo-test/02-add-tests.json +17 -6
  46. package/examples/demo-project/_cursorflow/tasks/demo-test/README.md +66 -25
  47. package/package.json +1 -1
  48. package/src/cli/clean.ts +27 -0
  49. package/src/cli/doctor.ts +18 -2
  50. package/src/cli/index.ts +15 -3
  51. package/src/cli/init.ts +6 -4
  52. package/src/cli/models.ts +83 -0
  53. package/src/cli/monitor.ts +20 -0
  54. package/src/cli/prepare.ts +844 -0
  55. package/src/cli/resume.ts +66 -0
  56. package/src/cli/run.ts +36 -2
  57. package/src/cli/signal.ts +22 -0
  58. package/src/utils/cursor-agent.ts +62 -10
  59. package/src/utils/doctor.ts +643 -5
  60. package/src/utils/types.ts +2 -0
@@ -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,457 @@ 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 { spawnSync } = require('child_process');
429
+ try {
430
+ // Validate and normalize the directory path to prevent command injection
431
+ const safePath = path.resolve(dir);
432
+
433
+ // Use spawnSync instead of execSync to avoid shell interpolation vulnerabilities
434
+ // df -B1 returns bytes. We look for the line corresponding to our directory.
435
+ const result = spawnSync('df', ['-B1', safePath], { encoding: 'utf8' });
436
+
437
+ if (result.status !== 0) {
438
+ return { ok: false, error: result.stderr || 'df command failed' };
439
+ }
440
+
441
+ const output = result.stdout;
442
+ const lines = output.trim().split('\n');
443
+ if (lines.length < 2) return { ok: false, error: 'Could not parse df output' };
444
+
445
+ const parts = lines[1]!.trim().split(/\s+/);
446
+ // df output: Filesystem 1B-blocks Used Available Use% Mounted on
447
+ // Available is index 3
448
+ const available = parseInt(parts[3]!);
449
+ if (isNaN(available)) return { ok: false, error: 'Could not parse available bytes' };
450
+
451
+ return { ok: true, freeBytes: available };
452
+ } catch (e: any) {
453
+ return { ok: false, error: e.message };
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Get all local branch names
459
+ */
460
+ function getAllLocalBranches(repoRoot: string): string[] {
461
+ const res = git.runGitResult(['branch', '--list', '--format=%(refname:short)'], { cwd: repoRoot });
462
+ if (!res.success) return [];
463
+ return res.stdout.split('\n').map(b => b.trim()).filter(b => b);
464
+ }
465
+
466
+ /**
467
+ * Get all remote branch names
468
+ */
469
+ function getAllRemoteBranches(repoRoot: string): string[] {
470
+ const res = git.runGitResult(['branch', '-r', '--list', '--format=%(refname:short)'], { cwd: repoRoot });
471
+ if (!res.success) return [];
472
+ return res.stdout.split('\n')
473
+ .map(b => b.trim().replace(/^origin\//, ''))
474
+ .filter(b => b && !b.includes('HEAD'));
475
+ }
476
+
477
+ /**
478
+ * Validate branch names for conflicts and issues
479
+ */
480
+ function validateBranchNames(
481
+ issues: DoctorIssue[],
482
+ lanes: { path: string; json: any; fileName: string }[],
483
+ repoRoot: string
484
+ ): void {
485
+ const localBranches = getAllLocalBranches(repoRoot);
486
+ const remoteBranches = getAllRemoteBranches(repoRoot);
487
+ const allExistingBranches = new Set([...localBranches, ...remoteBranches]);
488
+
489
+ // Collect branch prefixes from lanes
490
+ const branchPrefixes: { laneName: string; prefix: string }[] = [];
491
+
492
+ for (const lane of lanes) {
493
+ const branchPrefix = lane.json?.branchPrefix;
494
+ if (branchPrefix) {
495
+ branchPrefixes.push({ laneName: lane.fileName, prefix: branchPrefix });
496
+ }
497
+ }
498
+
499
+ // Check for branch prefix collisions between lanes
500
+ const prefixMap = new Map<string, string[]>();
501
+ for (const { laneName, prefix } of branchPrefixes) {
502
+ const existing = prefixMap.get(prefix) || [];
503
+ existing.push(laneName);
504
+ prefixMap.set(prefix, existing);
505
+ }
506
+
507
+ for (const [prefix, laneNames] of prefixMap) {
508
+ if (laneNames.length > 1) {
509
+ addIssue(issues, {
510
+ id: 'branch.prefix_collision',
511
+ severity: 'error',
512
+ title: 'Branch prefix collision',
513
+ message: `Multiple lanes use the same branchPrefix "${prefix}": ${laneNames.join(', ')}`,
514
+ details: 'Each lane should have a unique branchPrefix to avoid conflicts.',
515
+ fixes: [
516
+ 'Update the branchPrefix in each lane JSON file to be unique',
517
+ 'Example: "featurename/lane-1-", "featurename/lane-2-"',
518
+ ],
519
+ });
520
+ }
521
+ }
522
+
523
+ // Check for existing branches that match lane prefixes
524
+ for (const { laneName, prefix } of branchPrefixes) {
525
+ const conflictingBranches: string[] = [];
526
+
527
+ for (const branch of allExistingBranches) {
528
+ if (branch.startsWith(prefix)) {
529
+ conflictingBranches.push(branch);
530
+ }
531
+ }
532
+
533
+ if (conflictingBranches.length > 0) {
534
+ addIssue(issues, {
535
+ id: `branch.existing_conflict.${laneName}`,
536
+ severity: 'warn',
537
+ title: `Existing branches may conflict with ${laneName}`,
538
+ message: `Found ${conflictingBranches.length} existing branch(es) matching prefix "${prefix}": ${conflictingBranches.slice(0, 3).join(', ')}${conflictingBranches.length > 3 ? '...' : ''}`,
539
+ details: 'These branches may cause issues if the lane tries to create a new branch with the same name.',
540
+ fixes: [
541
+ `Delete conflicting branches: git branch -D ${conflictingBranches[0]}`,
542
+ `Or change the branchPrefix in ${laneName}.json`,
543
+ 'Run: cursorflow clean branches --dry-run to see all CursorFlow branches',
544
+ ],
545
+ });
546
+ }
547
+ }
548
+
549
+ // Check for duplicate lane file names (which would cause branch issues)
550
+ const laneFileNames = lanes.map(l => l.fileName);
551
+ const duplicateNames = laneFileNames.filter((name, index) => laneFileNames.indexOf(name) !== index);
552
+
553
+ if (duplicateNames.length > 0) {
554
+ addIssue(issues, {
555
+ id: 'tasks.duplicate_lane_files',
556
+ severity: 'error',
557
+ title: 'Duplicate lane file names',
558
+ message: `Found duplicate lane names: ${[...new Set(duplicateNames)].join(', ')}`,
559
+ fixes: ['Ensure each lane file has a unique name'],
560
+ });
561
+ }
562
+
563
+ // Suggest unique branch naming convention
564
+ const hasNumericPrefix = branchPrefixes.some(({ prefix }) => /\/lane-\d+-$/.test(prefix));
565
+ if (!hasNumericPrefix && branchPrefixes.length > 1) {
566
+ addIssue(issues, {
567
+ id: 'branch.naming_suggestion',
568
+ severity: 'warn',
569
+ title: 'Consider using lane numbers in branch prefix',
570
+ message: 'Using consistent lane numbers in branch prefixes helps avoid conflicts.',
571
+ fixes: [
572
+ 'Use pattern: "feature-name/lane-{N}-" where N is the lane number',
573
+ 'Example: "auth-system/lane-1-", "auth-system/lane-2-"',
574
+ ],
575
+ });
576
+ }
577
+ }
578
+
579
+ /**
580
+ * Status file to track when doctor was last run successfully.
581
+ */
582
+ const DOCTOR_STATUS_FILE = '.cursorflow/doctor-status.json';
583
+
584
+ export function saveDoctorStatus(repoRoot: string, report: DoctorReport): void {
585
+ const statusPath = path.join(repoRoot, DOCTOR_STATUS_FILE);
586
+ const statusDir = path.dirname(statusPath);
587
+
588
+ if (!fs.existsSync(statusDir)) {
589
+ fs.mkdirSync(statusDir, { recursive: true });
590
+ }
591
+
592
+ const status = {
593
+ lastRun: Date.now(),
594
+ ok: report.ok,
595
+ issueCount: report.issues.length,
596
+ nodeVersion: process.version,
597
+ };
598
+
599
+ fs.writeFileSync(statusPath, JSON.stringify(status, null, 2), 'utf8');
600
+ }
601
+
602
+ export function getDoctorStatus(repoRoot: string): { lastRun: number; ok: boolean; issueCount: number } | null {
603
+ const statusPath = path.join(repoRoot, DOCTOR_STATUS_FILE);
604
+ if (!fs.existsSync(statusPath)) return null;
605
+
606
+ try {
607
+ return JSON.parse(fs.readFileSync(statusPath, 'utf8'));
608
+ } catch {
609
+ return null;
610
+ }
611
+ }
612
+
131
613
  /**
132
614
  * Run doctor checks.
133
615
  *
134
616
  * If `tasksDir` is provided, additional preflight checks are performed:
135
617
  * - tasks directory existence and JSON validity
136
618
  * - baseBranch referenced by lanes exists locally
619
+ * - Task structure validation (name, prompt, etc.)
620
+ * - Circular dependency detection (DAG validation)
137
621
  */
138
622
  export function runDoctor(options: DoctorOptions = {}): DoctorReport {
139
623
  const cwd = options.cwd || process.cwd();
@@ -144,6 +628,59 @@ export function runDoctor(options: DoctorOptions = {}): DoctorReport {
144
628
  executor: options.executor,
145
629
  };
146
630
 
631
+ // 0) System and environment checks
632
+ const versions = getVersions();
633
+ const nodeMajor = parseInt(versions.node.slice(1).split('.')[0] || '0');
634
+ if (nodeMajor < 18) {
635
+ addIssue(issues, {
636
+ id: 'env.node_version',
637
+ severity: 'error',
638
+ title: 'Node.js version too old',
639
+ message: `CursorFlow requires Node.js >= 18.0.0. Current version: ${versions.node}`,
640
+ fixes: ['Upgrade Node.js to a supported version (e.g., using nvm or from nodejs.org)'],
641
+ });
642
+ }
643
+
644
+ const gitVerMatch = versions.git.match(/^(\d+)\.(\d+)/);
645
+ if (gitVerMatch) {
646
+ const major = parseInt(gitVerMatch[1]!);
647
+ const minor = parseInt(gitVerMatch[2]!);
648
+ if (major < 2 || (major === 2 && minor < 5)) {
649
+ addIssue(issues, {
650
+ id: 'env.git_version',
651
+ severity: 'error',
652
+ title: 'Git version too old',
653
+ message: `CursorFlow requires Git >= 2.5 for worktree support. Current version: ${versions.git}`,
654
+ fixes: ['Upgrade Git to a version >= 2.5'],
655
+ });
656
+ }
657
+ }
658
+
659
+ const pkgManager = checkPackageManager();
660
+ if (!pkgManager.ok) {
661
+ addIssue(issues, {
662
+ id: 'env.package_manager',
663
+ severity: 'warn',
664
+ title: 'No standard package manager found',
665
+ message: 'Neither pnpm nor npm was found in your PATH. CursorFlow tasks often rely on these for dependency management.',
666
+ fixes: ['Install pnpm (recommended): npm install -g pnpm', 'Or ensure npm is in your PATH'],
667
+ });
668
+ }
669
+
670
+ const diskSpace = checkDiskSpace(cwd);
671
+ if (diskSpace.ok && diskSpace.freeBytes !== undefined) {
672
+ const freeGB = diskSpace.freeBytes / (1024 * 1024 * 1024);
673
+ if (freeGB < 1) {
674
+ addIssue(issues, {
675
+ id: 'env.low_disk_space',
676
+ severity: 'warn',
677
+ title: 'Low disk space',
678
+ message: `Low disk space detected: ${freeGB.toFixed(2)} GB available. CursorFlow creates Git worktrees which can consume significant space.`,
679
+ fixes: ['Free up disk space before running large orchestration tasks'],
680
+ });
681
+ }
682
+ }
683
+
147
684
  // 1) Git repository checks
148
685
  if (!isInsideGitWorktree(cwd)) {
149
686
  addIssue(issues, {
@@ -185,6 +722,67 @@ export function runDoctor(options: DoctorOptions = {}): DoctorReport {
185
722
  'git remote -v # verify remotes',
186
723
  ],
187
724
  });
725
+ } else {
726
+ // Advanced check: remote connectivity
727
+ const connectivity = checkRemoteConnectivity(gitCwd);
728
+ if (!connectivity.ok) {
729
+ addIssue(issues, {
730
+ id: 'git.remote_connectivity',
731
+ severity: 'error',
732
+ title: "Cannot connect to 'origin'",
733
+ message: "Failed to communicate with the remote 'origin'. Check your internet connection or SSH/HTTPS credentials.",
734
+ details: connectivity.details,
735
+ fixes: [
736
+ 'git fetch origin',
737
+ 'Verify your SSH keys or credentials are configured correctly',
738
+ ],
739
+ });
740
+ }
741
+
742
+ // Advanced check: push permission
743
+ const pushPerm = checkGitPushPermission(gitCwd);
744
+ if (!pushPerm.ok) {
745
+ addIssue(issues, {
746
+ id: 'git.push_permission',
747
+ severity: 'warn',
748
+ title: 'Push permission check failed',
749
+ message: "CursorFlow might not be able to push branches to 'origin'. A dry-run push failed.",
750
+ details: pushPerm.details,
751
+ fixes: [
752
+ 'Verify you have write access to the repository on GitHub/GitLab',
753
+ 'Check if the branch naming policy on the remote permits `cursorflow/*` branches',
754
+ ],
755
+ });
756
+ }
757
+
758
+ // Advanced check: current branch upstream
759
+ const currentBranch = git.getCurrentBranch(gitCwd);
760
+ const upstreamRes = git.runGitResult(['rev-parse', '--abbrev-ref', `${currentBranch}@{u}`], { cwd: gitCwd });
761
+ if (!upstreamRes.success && currentBranch !== 'main' && currentBranch !== 'master') {
762
+ addIssue(issues, {
763
+ id: 'git.no_upstream',
764
+ severity: 'warn',
765
+ title: 'Current branch has no upstream',
766
+ message: `The current branch "${currentBranch}" is not tracking a remote branch.`,
767
+ fixes: [
768
+ `git push -u origin ${currentBranch}`,
769
+ ],
770
+ });
771
+ }
772
+ }
773
+
774
+ const gitUser = checkGitUserConfig(gitCwd);
775
+ if (!gitUser.name || !gitUser.email) {
776
+ addIssue(issues, {
777
+ id: 'git.user_config',
778
+ severity: 'error',
779
+ title: 'Git user not configured',
780
+ message: 'Git user name or email is not set. CursorFlow cannot create commits without these.',
781
+ fixes: [
782
+ `git config --global user.name "Your Name"`,
783
+ `git config --global user.email "you@example.com"`,
784
+ ],
785
+ });
188
786
  }
189
787
 
190
788
  const wt = hasWorktreeSupport(gitCwd);
@@ -200,6 +798,25 @@ export function runDoctor(options: DoctorOptions = {}): DoctorReport {
200
798
  ],
201
799
  details: wt.details,
202
800
  });
801
+ } else {
802
+ // Advanced check: .gitignore check for worktrees
803
+ const gitignorePath = path.join(gitCwd, '.gitignore');
804
+ const worktreeDirName = '_cursorflow'; // Default directory name
805
+ if (fs.existsSync(gitignorePath)) {
806
+ const content = fs.readFileSync(gitignorePath, 'utf8');
807
+ if (!content.includes(worktreeDirName)) {
808
+ addIssue(issues, {
809
+ id: 'git.gitignore_missing_worktree',
810
+ severity: 'warn',
811
+ title: 'Worktree directory not ignored',
812
+ message: `The directory "${worktreeDirName}" is not in your .gitignore. This could lead to accidentally committing temporary worktrees or logs.`,
813
+ fixes: [
814
+ `Add "${worktreeDirName}/" to your .gitignore`,
815
+ 'Run `cursorflow init` to set up recommended ignores',
816
+ ],
817
+ });
818
+ }
819
+ }
203
820
  }
204
821
 
205
822
  // 2) Tasks-dir checks (optional; used by `cursorflow run` preflight)
@@ -221,7 +838,7 @@ export function runDoctor(options: DoctorOptions = {}): DoctorReport {
221
838
  ],
222
839
  });
223
840
  } else {
224
- let lanes: { path: string; json: any }[] = [];
841
+ let lanes: { path: string; json: any; fileName: string }[] = [];
225
842
  try {
226
843
  lanes = readLaneJsonFiles(tasksDirAbs);
227
844
  } catch (error: any) {
@@ -245,6 +862,7 @@ export function runDoctor(options: DoctorOptions = {}): DoctorReport {
245
862
  fixes: ['Ensure the tasks directory contains one or more lane JSON files'],
246
863
  });
247
864
  } else {
865
+ // Validate base branches
248
866
  const baseBranches = collectBaseBranchesFromLanes(lanes, 'main');
249
867
  for (const baseBranch of baseBranches) {
250
868
  if (!branchExists(gitCwd, baseBranch)) {
@@ -260,6 +878,17 @@ export function runDoctor(options: DoctorOptions = {}): DoctorReport {
260
878
  });
261
879
  }
262
880
  }
881
+
882
+ // Validate task structure in each lane
883
+ for (const lane of lanes) {
884
+ validateTaskStructure(issues, lane.fileName, lane.json);
885
+ }
886
+
887
+ // Detect circular dependencies
888
+ detectCircularDependencies(issues, lanes);
889
+
890
+ // Validate branch names - check for conflicts
891
+ validateBranchNames(issues, lanes, gitCwd);
263
892
  }
264
893
  }
265
894
  }
@@ -291,6 +920,17 @@ export function runDoctor(options: DoctorOptions = {}): DoctorReport {
291
920
  ],
292
921
  });
293
922
  }
923
+
924
+ // MCP/Permissions potential hang check
925
+ addIssue(issues, {
926
+ id: 'cursor_agent.mcp_priming',
927
+ severity: 'warn',
928
+ title: 'Agent may require interactive approval',
929
+ message: 'Non-interactive execution (with --print) can hang if MCP permissions or user approvals are required.',
930
+ fixes: [
931
+ 'Run once interactively to prime permissions: cursorflow doctor --test-agent',
932
+ ],
933
+ });
294
934
  }
295
935
  }
296
936
 
@@ -308,5 +948,3 @@ export function runDoctor(options: DoctorOptions = {}): DoctorReport {
308
948
  const ok = issues.every(i => i.severity !== 'error');
309
949
  return { ok, issues, context };
310
950
  }
311
-
312
-