@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.
Files changed (66) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +113 -319
  3. package/commands/cursorflow-clean.md +24 -135
  4. package/commands/cursorflow-doctor.md +74 -18
  5. package/commands/cursorflow-init.md +33 -50
  6. package/commands/cursorflow-models.md +51 -0
  7. package/commands/cursorflow-monitor.md +56 -118
  8. package/commands/cursorflow-prepare.md +410 -108
  9. package/commands/cursorflow-resume.md +51 -148
  10. package/commands/cursorflow-review.md +38 -202
  11. package/commands/cursorflow-run.md +208 -86
  12. package/commands/cursorflow-signal.md +38 -12
  13. package/dist/cli/clean.d.ts +3 -1
  14. package/dist/cli/clean.js +145 -8
  15. package/dist/cli/clean.js.map +1 -1
  16. package/dist/cli/doctor.js +14 -1
  17. package/dist/cli/doctor.js.map +1 -1
  18. package/dist/cli/index.js +32 -21
  19. package/dist/cli/index.js.map +1 -1
  20. package/dist/cli/init.js +5 -4
  21. package/dist/cli/init.js.map +1 -1
  22. package/dist/cli/models.d.ts +7 -0
  23. package/dist/cli/models.js +104 -0
  24. package/dist/cli/models.js.map +1 -0
  25. package/dist/cli/monitor.js +56 -1
  26. package/dist/cli/monitor.js.map +1 -1
  27. package/dist/cli/prepare.d.ts +7 -0
  28. package/dist/cli/prepare.js +748 -0
  29. package/dist/cli/prepare.js.map +1 -0
  30. package/dist/cli/resume.js +56 -0
  31. package/dist/cli/resume.js.map +1 -1
  32. package/dist/cli/run.js +30 -1
  33. package/dist/cli/run.js.map +1 -1
  34. package/dist/cli/signal.js +18 -0
  35. package/dist/cli/signal.js.map +1 -1
  36. package/dist/core/runner.d.ts +9 -1
  37. package/dist/core/runner.js +139 -23
  38. package/dist/core/runner.js.map +1 -1
  39. package/dist/utils/cursor-agent.d.ts +4 -0
  40. package/dist/utils/cursor-agent.js +58 -10
  41. package/dist/utils/cursor-agent.js.map +1 -1
  42. package/dist/utils/doctor.d.ts +10 -0
  43. package/dist/utils/doctor.js +581 -1
  44. package/dist/utils/doctor.js.map +1 -1
  45. package/dist/utils/types.d.ts +11 -0
  46. package/examples/README.md +114 -59
  47. package/examples/demo-project/README.md +61 -79
  48. package/examples/demo-project/_cursorflow/tasks/demo-test/01-create-utils.json +17 -6
  49. package/examples/demo-project/_cursorflow/tasks/demo-test/02-add-tests.json +17 -6
  50. package/examples/demo-project/_cursorflow/tasks/demo-test/README.md +66 -25
  51. package/package.json +1 -1
  52. package/scripts/patches/test-cursor-agent.js +203 -0
  53. package/src/cli/clean.ts +156 -9
  54. package/src/cli/doctor.ts +18 -2
  55. package/src/cli/index.ts +33 -21
  56. package/src/cli/init.ts +6 -4
  57. package/src/cli/models.ts +83 -0
  58. package/src/cli/monitor.ts +60 -1
  59. package/src/cli/prepare.ts +844 -0
  60. package/src/cli/resume.ts +66 -0
  61. package/src/cli/run.ts +36 -2
  62. package/src/cli/signal.ts +22 -0
  63. package/src/core/runner.ts +164 -23
  64. package/src/utils/cursor-agent.ts +62 -10
  65. package/src/utils/doctor.ts +633 -5
  66. package/src/utils/types.ts +11 -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,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
-