@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
@@ -8,6 +8,8 @@
8
8
  * - Missing Git worktree support
9
9
  * - Missing base branch referenced by lane task files
10
10
  * - Missing/invalid tasks directory
11
+ * - Task validation (name, prompt, structure)
12
+ * - Circular dependency detection (DAG validation)
11
13
  * - Missing Cursor Agent setup (optional)
12
14
  */
13
15
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
@@ -44,6 +46,8 @@ var __importStar = (this && this.__importStar) || (function () {
44
46
  };
45
47
  })();
46
48
  Object.defineProperty(exports, "__esModule", { value: true });
49
+ exports.saveDoctorStatus = saveDoctorStatus;
50
+ exports.getDoctorStatus = getDoctorStatus;
47
51
  exports.runDoctor = runDoctor;
48
52
  const fs = __importStar(require("fs"));
49
53
  const path = __importStar(require("path"));
@@ -71,6 +75,41 @@ function hasOriginRemote(repoRoot) {
71
75
  const res = git.runGitResult(['remote', 'get-url', 'origin'], { cwd: repoRoot });
72
76
  return res.success && !!res.stdout;
73
77
  }
78
+ function checkGitUserConfig(repoRoot) {
79
+ const nameRes = git.runGitResult(['config', 'user.name'], { cwd: repoRoot });
80
+ const emailRes = git.runGitResult(['config', 'user.email'], { cwd: repoRoot });
81
+ return {
82
+ name: nameRes.success ? nameRes.stdout.trim() : undefined,
83
+ email: emailRes.success ? emailRes.stdout.trim() : undefined,
84
+ };
85
+ }
86
+ function checkGitPushPermission(repoRoot) {
87
+ // Use dry-run to check if we can push to origin.
88
+ // We try pushing current HEAD to a non-existent temporary branch on origin to avoid side effects.
89
+ const tempBranch = `cursorflow-doctor-test-${Date.now()}`;
90
+ const res = git.runGitResult(['push', '--dry-run', 'origin', `HEAD:refs/heads/${tempBranch}`], { cwd: repoRoot });
91
+ if (res.success)
92
+ return { ok: true };
93
+ return { ok: false, details: res.stderr || res.stdout || 'git push --dry-run failed' };
94
+ }
95
+ function checkRemoteConnectivity(repoRoot) {
96
+ const res = git.runGitResult(['fetch', '--dry-run', 'origin'], { cwd: repoRoot });
97
+ if (res.success)
98
+ return { ok: true };
99
+ return { ok: false, details: res.stderr || res.stdout || 'git fetch --dry-run failed' };
100
+ }
101
+ function getVersions() {
102
+ let gitVer = 'unknown';
103
+ try {
104
+ const res = git.runGitResult(['--version']);
105
+ gitVer = res.stdout.replace('git version ', '').trim();
106
+ }
107
+ catch { }
108
+ return {
109
+ node: process.version,
110
+ git: gitVer,
111
+ };
112
+ }
74
113
  function hasWorktreeSupport(repoRoot) {
75
114
  const res = git.runGitResult(['worktree', 'list'], { cwd: repoRoot });
76
115
  if (res.success)
@@ -94,7 +133,7 @@ function readLaneJsonFiles(tasksDir) {
94
133
  return files.map(p => {
95
134
  const raw = fs.readFileSync(p, 'utf8');
96
135
  const json = JSON.parse(raw);
97
- return { path: p, json };
136
+ return { path: p, json, fileName: path.basename(p, '.json') };
98
137
  });
99
138
  }
100
139
  function collectBaseBranchesFromLanes(lanes, defaultBaseBranch) {
@@ -106,12 +145,406 @@ function collectBaseBranchesFromLanes(lanes, defaultBaseBranch) {
106
145
  }
107
146
  return Array.from(set);
108
147
  }
148
+ /**
149
+ * Validate task structure within a lane
150
+ */
151
+ function validateTaskStructure(issues, laneFile, json) {
152
+ const laneName = path.basename(laneFile, '.json');
153
+ // Check if tasks array exists
154
+ if (!json.tasks) {
155
+ addIssue(issues, {
156
+ id: `tasks.${laneName}.missing_tasks`,
157
+ severity: 'error',
158
+ title: `Missing tasks array in ${laneName}`,
159
+ message: `Lane "${laneName}" does not have a "tasks" array.`,
160
+ fixes: ['Add a "tasks" array with at least one task object'],
161
+ });
162
+ return;
163
+ }
164
+ if (!Array.isArray(json.tasks)) {
165
+ addIssue(issues, {
166
+ id: `tasks.${laneName}.invalid_tasks`,
167
+ severity: 'error',
168
+ title: `Invalid tasks in ${laneName}`,
169
+ message: `Lane "${laneName}" has "tasks" but it's not an array.`,
170
+ fixes: ['Ensure "tasks" is an array of task objects'],
171
+ });
172
+ return;
173
+ }
174
+ if (json.tasks.length === 0) {
175
+ addIssue(issues, {
176
+ id: `tasks.${laneName}.empty_tasks`,
177
+ severity: 'error',
178
+ title: `No tasks in ${laneName}`,
179
+ message: `Lane "${laneName}" has an empty tasks array.`,
180
+ fixes: ['Add at least one task with "name" and "prompt" fields'],
181
+ });
182
+ return;
183
+ }
184
+ // Validate each task
185
+ const taskNamePattern = /^[a-zA-Z0-9_-]+$/;
186
+ const seenNames = new Set();
187
+ json.tasks.forEach((task, index) => {
188
+ const taskId = task.name || `task[${index}]`;
189
+ // Check name
190
+ if (!task.name) {
191
+ addIssue(issues, {
192
+ id: `tasks.${laneName}.${index}.missing_name`,
193
+ severity: 'error',
194
+ title: `Missing task name in ${laneName}`,
195
+ message: `Task at index ${index} in "${laneName}" is missing the "name" field.`,
196
+ fixes: ['Add a "name" field to the task (e.g., "implement", "test")'],
197
+ });
198
+ }
199
+ else if (typeof task.name !== 'string') {
200
+ addIssue(issues, {
201
+ id: `tasks.${laneName}.${index}.invalid_name_type`,
202
+ severity: 'error',
203
+ title: `Invalid task name type in ${laneName}`,
204
+ message: `Task at index ${index} in "${laneName}" has a non-string "name" field.`,
205
+ fixes: ['Ensure "name" is a string'],
206
+ });
207
+ }
208
+ else if (!taskNamePattern.test(task.name)) {
209
+ addIssue(issues, {
210
+ id: `tasks.${laneName}.${taskId}.invalid_name_format`,
211
+ severity: 'error',
212
+ title: `Invalid task name format in ${laneName}`,
213
+ message: `Task "${task.name}" in "${laneName}" has invalid characters. Only alphanumeric, "-", and "_" are allowed.`,
214
+ fixes: [`Rename task to use only alphanumeric characters, "-", or "_"`],
215
+ });
216
+ }
217
+ else if (seenNames.has(task.name)) {
218
+ addIssue(issues, {
219
+ id: `tasks.${laneName}.${taskId}.duplicate_name`,
220
+ severity: 'error',
221
+ title: `Duplicate task name in ${laneName}`,
222
+ message: `Task name "${task.name}" appears multiple times in "${laneName}".`,
223
+ fixes: ['Ensure each task has a unique name within the lane'],
224
+ });
225
+ }
226
+ else {
227
+ seenNames.add(task.name);
228
+ }
229
+ // Check prompt
230
+ if (!task.prompt) {
231
+ addIssue(issues, {
232
+ id: `tasks.${laneName}.${taskId}.missing_prompt`,
233
+ severity: 'error',
234
+ title: `Missing task prompt in ${laneName}`,
235
+ message: `Task "${taskId}" in "${laneName}" is missing the "prompt" field.`,
236
+ fixes: ['Add a "prompt" field with instructions for the AI'],
237
+ });
238
+ }
239
+ else if (typeof task.prompt !== 'string') {
240
+ addIssue(issues, {
241
+ id: `tasks.${laneName}.${taskId}.invalid_prompt_type`,
242
+ severity: 'error',
243
+ title: `Invalid task prompt type in ${laneName}`,
244
+ message: `Task "${taskId}" in "${laneName}" has a non-string "prompt" field.`,
245
+ fixes: ['Ensure "prompt" is a string'],
246
+ });
247
+ }
248
+ else if (task.prompt.trim().length < 10) {
249
+ addIssue(issues, {
250
+ id: `tasks.${laneName}.${taskId}.short_prompt`,
251
+ severity: 'warn',
252
+ title: `Short task prompt in ${laneName}`,
253
+ message: `Task "${taskId}" in "${laneName}" has a very short prompt (${task.prompt.trim().length} chars). Consider providing more detailed instructions.`,
254
+ fixes: ['Provide clearer, more detailed instructions in the prompt'],
255
+ });
256
+ }
257
+ // Check acceptanceCriteria if present
258
+ if (task.acceptanceCriteria !== undefined) {
259
+ if (!Array.isArray(task.acceptanceCriteria)) {
260
+ addIssue(issues, {
261
+ id: `tasks.${laneName}.${taskId}.invalid_criteria_type`,
262
+ severity: 'error',
263
+ title: `Invalid acceptanceCriteria in ${laneName}`,
264
+ message: `Task "${taskId}" in "${laneName}" has "acceptanceCriteria" but it's not an array.`,
265
+ fixes: ['Ensure "acceptanceCriteria" is an array of strings'],
266
+ });
267
+ }
268
+ else if (task.acceptanceCriteria.length === 0) {
269
+ addIssue(issues, {
270
+ id: `tasks.${laneName}.${taskId}.empty_criteria`,
271
+ severity: 'warn',
272
+ title: `Empty acceptanceCriteria in ${laneName}`,
273
+ message: `Task "${taskId}" in "${laneName}" has an empty "acceptanceCriteria" array.`,
274
+ fixes: ['Add acceptance criteria or remove the empty array'],
275
+ });
276
+ }
277
+ }
278
+ // Check model if present
279
+ if (task.model !== undefined && typeof task.model !== 'string') {
280
+ addIssue(issues, {
281
+ id: `tasks.${laneName}.${taskId}.invalid_model_type`,
282
+ severity: 'error',
283
+ title: `Invalid model type in ${laneName}`,
284
+ message: `Task "${taskId}" in "${laneName}" has a non-string "model" field.`,
285
+ fixes: ['Ensure "model" is a string (e.g., "sonnet-4.5")'],
286
+ });
287
+ }
288
+ });
289
+ }
290
+ /**
291
+ * Detect circular dependencies in the lane dependency graph (DAG validation)
292
+ */
293
+ function detectCircularDependencies(issues, lanes) {
294
+ // Build adjacency list
295
+ const graph = new Map();
296
+ const allLaneNames = new Set();
297
+ for (const lane of lanes) {
298
+ allLaneNames.add(lane.fileName);
299
+ const deps = lane.json.dependsOn || [];
300
+ graph.set(lane.fileName, Array.isArray(deps) ? deps : []);
301
+ }
302
+ // Check for unknown dependencies
303
+ for (const lane of lanes) {
304
+ const deps = lane.json.dependsOn || [];
305
+ if (!Array.isArray(deps))
306
+ continue;
307
+ for (const dep of deps) {
308
+ if (!allLaneNames.has(dep)) {
309
+ addIssue(issues, {
310
+ id: `tasks.${lane.fileName}.unknown_dependency`,
311
+ severity: 'error',
312
+ title: `Unknown dependency in ${lane.fileName}`,
313
+ message: `Lane "${lane.fileName}" depends on "${dep}" which does not exist.`,
314
+ fixes: [
315
+ `Verify the dependency name matches an existing lane file (without .json extension)`,
316
+ `Available lanes: ${Array.from(allLaneNames).join(', ')}`,
317
+ ],
318
+ });
319
+ }
320
+ }
321
+ }
322
+ // Detect cycles using DFS
323
+ const visited = new Set();
324
+ const recursionStack = new Set();
325
+ const cyclePath = [];
326
+ function hasCycle(node, path) {
327
+ if (recursionStack.has(node)) {
328
+ // Found a cycle
329
+ const cycleStart = path.indexOf(node);
330
+ cyclePath.push(...path.slice(cycleStart), node);
331
+ return true;
332
+ }
333
+ if (visited.has(node)) {
334
+ return false;
335
+ }
336
+ visited.add(node);
337
+ recursionStack.add(node);
338
+ const deps = graph.get(node) || [];
339
+ for (const dep of deps) {
340
+ if (hasCycle(dep, [...path, node])) {
341
+ return true;
342
+ }
343
+ }
344
+ recursionStack.delete(node);
345
+ return false;
346
+ }
347
+ for (const laneName of allLaneNames) {
348
+ cyclePath.length = 0;
349
+ visited.clear();
350
+ recursionStack.clear();
351
+ if (hasCycle(laneName, [])) {
352
+ addIssue(issues, {
353
+ id: 'tasks.circular_dependency',
354
+ severity: 'error',
355
+ title: 'Circular dependency detected',
356
+ message: `Circular dependency found: ${cyclePath.join(' → ')}`,
357
+ details: 'Lane dependencies must form a DAG (Directed Acyclic Graph). Circular dependencies will cause a deadlock.',
358
+ fixes: [
359
+ 'Review the "dependsOn" fields in your lane files',
360
+ 'Remove one of the dependencies to break the cycle',
361
+ ],
362
+ });
363
+ return; // Report only one cycle
364
+ }
365
+ }
366
+ }
367
+ function checkPackageManager() {
368
+ const { spawnSync } = require('child_process');
369
+ // Try pnpm first as it's the default in prompts
370
+ try {
371
+ const pnpmRes = spawnSync('pnpm', ['--version'], { encoding: 'utf8' });
372
+ if (pnpmRes.status === 0)
373
+ return { name: 'pnpm', ok: true };
374
+ }
375
+ catch { }
376
+ try {
377
+ const npmRes = spawnSync('npm', ['--version'], { encoding: 'utf8' });
378
+ if (npmRes.status === 0)
379
+ return { name: 'npm', ok: true };
380
+ }
381
+ catch { }
382
+ return { name: 'unknown', ok: false };
383
+ }
384
+ function checkDiskSpace(dir) {
385
+ const { execSync } = require('child_process');
386
+ try {
387
+ // df -B1 returns bytes. We look for the line corresponding to our directory.
388
+ const output = execSync(`df -B1 "${dir}"`, { encoding: 'utf8' });
389
+ const lines = output.trim().split('\n');
390
+ if (lines.length < 2)
391
+ return { ok: false, error: 'Could not parse df output' };
392
+ const parts = lines[1].trim().split(/\s+/);
393
+ // df output: Filesystem 1B-blocks Used Available Use% Mounted on
394
+ // Available is index 3
395
+ const available = parseInt(parts[3]);
396
+ if (isNaN(available))
397
+ return { ok: false, error: 'Could not parse available bytes' };
398
+ return { ok: true, freeBytes: available };
399
+ }
400
+ catch (e) {
401
+ return { ok: false, error: e.message };
402
+ }
403
+ }
404
+ /**
405
+ * Get all local branch names
406
+ */
407
+ function getAllLocalBranches(repoRoot) {
408
+ const res = git.runGitResult(['branch', '--list', '--format=%(refname:short)'], { cwd: repoRoot });
409
+ if (!res.success)
410
+ return [];
411
+ return res.stdout.split('\n').map(b => b.trim()).filter(b => b);
412
+ }
413
+ /**
414
+ * Get all remote branch names
415
+ */
416
+ function getAllRemoteBranches(repoRoot) {
417
+ const res = git.runGitResult(['branch', '-r', '--list', '--format=%(refname:short)'], { cwd: repoRoot });
418
+ if (!res.success)
419
+ return [];
420
+ return res.stdout.split('\n')
421
+ .map(b => b.trim().replace(/^origin\//, ''))
422
+ .filter(b => b && !b.includes('HEAD'));
423
+ }
424
+ /**
425
+ * Validate branch names for conflicts and issues
426
+ */
427
+ function validateBranchNames(issues, lanes, repoRoot) {
428
+ const localBranches = getAllLocalBranches(repoRoot);
429
+ const remoteBranches = getAllRemoteBranches(repoRoot);
430
+ const allExistingBranches = new Set([...localBranches, ...remoteBranches]);
431
+ // Collect branch prefixes from lanes
432
+ const branchPrefixes = [];
433
+ for (const lane of lanes) {
434
+ const branchPrefix = lane.json?.branchPrefix;
435
+ if (branchPrefix) {
436
+ branchPrefixes.push({ laneName: lane.fileName, prefix: branchPrefix });
437
+ }
438
+ }
439
+ // Check for branch prefix collisions between lanes
440
+ const prefixMap = new Map();
441
+ for (const { laneName, prefix } of branchPrefixes) {
442
+ const existing = prefixMap.get(prefix) || [];
443
+ existing.push(laneName);
444
+ prefixMap.set(prefix, existing);
445
+ }
446
+ for (const [prefix, laneNames] of prefixMap) {
447
+ if (laneNames.length > 1) {
448
+ addIssue(issues, {
449
+ id: 'branch.prefix_collision',
450
+ severity: 'error',
451
+ title: 'Branch prefix collision',
452
+ message: `Multiple lanes use the same branchPrefix "${prefix}": ${laneNames.join(', ')}`,
453
+ details: 'Each lane should have a unique branchPrefix to avoid conflicts.',
454
+ fixes: [
455
+ 'Update the branchPrefix in each lane JSON file to be unique',
456
+ 'Example: "featurename/lane-1-", "featurename/lane-2-"',
457
+ ],
458
+ });
459
+ }
460
+ }
461
+ // Check for existing branches that match lane prefixes
462
+ for (const { laneName, prefix } of branchPrefixes) {
463
+ const conflictingBranches = [];
464
+ for (const branch of allExistingBranches) {
465
+ if (branch.startsWith(prefix)) {
466
+ conflictingBranches.push(branch);
467
+ }
468
+ }
469
+ if (conflictingBranches.length > 0) {
470
+ addIssue(issues, {
471
+ id: `branch.existing_conflict.${laneName}`,
472
+ severity: 'warn',
473
+ title: `Existing branches may conflict with ${laneName}`,
474
+ message: `Found ${conflictingBranches.length} existing branch(es) matching prefix "${prefix}": ${conflictingBranches.slice(0, 3).join(', ')}${conflictingBranches.length > 3 ? '...' : ''}`,
475
+ details: 'These branches may cause issues if the lane tries to create a new branch with the same name.',
476
+ fixes: [
477
+ `Delete conflicting branches: git branch -D ${conflictingBranches[0]}`,
478
+ `Or change the branchPrefix in ${laneName}.json`,
479
+ 'Run: cursorflow clean branches --dry-run to see all CursorFlow branches',
480
+ ],
481
+ });
482
+ }
483
+ }
484
+ // Check for duplicate lane file names (which would cause branch issues)
485
+ const laneFileNames = lanes.map(l => l.fileName);
486
+ const duplicateNames = laneFileNames.filter((name, index) => laneFileNames.indexOf(name) !== index);
487
+ if (duplicateNames.length > 0) {
488
+ addIssue(issues, {
489
+ id: 'tasks.duplicate_lane_files',
490
+ severity: 'error',
491
+ title: 'Duplicate lane file names',
492
+ message: `Found duplicate lane names: ${[...new Set(duplicateNames)].join(', ')}`,
493
+ fixes: ['Ensure each lane file has a unique name'],
494
+ });
495
+ }
496
+ // Suggest unique branch naming convention
497
+ const hasNumericPrefix = branchPrefixes.some(({ prefix }) => /\/lane-\d+-$/.test(prefix));
498
+ if (!hasNumericPrefix && branchPrefixes.length > 1) {
499
+ addIssue(issues, {
500
+ id: 'branch.naming_suggestion',
501
+ severity: 'warn',
502
+ title: 'Consider using lane numbers in branch prefix',
503
+ message: 'Using consistent lane numbers in branch prefixes helps avoid conflicts.',
504
+ fixes: [
505
+ 'Use pattern: "feature-name/lane-{N}-" where N is the lane number',
506
+ 'Example: "auth-system/lane-1-", "auth-system/lane-2-"',
507
+ ],
508
+ });
509
+ }
510
+ }
511
+ /**
512
+ * Status file to track when doctor was last run successfully.
513
+ */
514
+ const DOCTOR_STATUS_FILE = '.cursorflow/doctor-status.json';
515
+ function saveDoctorStatus(repoRoot, report) {
516
+ const statusPath = path.join(repoRoot, DOCTOR_STATUS_FILE);
517
+ const statusDir = path.dirname(statusPath);
518
+ if (!fs.existsSync(statusDir)) {
519
+ fs.mkdirSync(statusDir, { recursive: true });
520
+ }
521
+ const status = {
522
+ lastRun: Date.now(),
523
+ ok: report.ok,
524
+ issueCount: report.issues.length,
525
+ nodeVersion: process.version,
526
+ };
527
+ fs.writeFileSync(statusPath, JSON.stringify(status, null, 2), 'utf8');
528
+ }
529
+ function getDoctorStatus(repoRoot) {
530
+ const statusPath = path.join(repoRoot, DOCTOR_STATUS_FILE);
531
+ if (!fs.existsSync(statusPath))
532
+ return null;
533
+ try {
534
+ return JSON.parse(fs.readFileSync(statusPath, 'utf8'));
535
+ }
536
+ catch {
537
+ return null;
538
+ }
539
+ }
109
540
  /**
110
541
  * Run doctor checks.
111
542
  *
112
543
  * If `tasksDir` is provided, additional preflight checks are performed:
113
544
  * - tasks directory existence and JSON validity
114
545
  * - baseBranch referenced by lanes exists locally
546
+ * - Task structure validation (name, prompt, etc.)
547
+ * - Circular dependency detection (DAG validation)
115
548
  */
116
549
  function runDoctor(options = {}) {
117
550
  const cwd = options.cwd || process.cwd();
@@ -120,6 +553,55 @@ function runDoctor(options = {}) {
120
553
  cwd,
121
554
  executor: options.executor,
122
555
  };
556
+ // 0) System and environment checks
557
+ const versions = getVersions();
558
+ const nodeMajor = parseInt(versions.node.slice(1).split('.')[0] || '0');
559
+ if (nodeMajor < 18) {
560
+ addIssue(issues, {
561
+ id: 'env.node_version',
562
+ severity: 'error',
563
+ title: 'Node.js version too old',
564
+ message: `CursorFlow requires Node.js >= 18.0.0. Current version: ${versions.node}`,
565
+ fixes: ['Upgrade Node.js to a supported version (e.g., using nvm or from nodejs.org)'],
566
+ });
567
+ }
568
+ const gitVerMatch = versions.git.match(/^(\d+)\.(\d+)/);
569
+ if (gitVerMatch) {
570
+ const major = parseInt(gitVerMatch[1]);
571
+ const minor = parseInt(gitVerMatch[2]);
572
+ if (major < 2 || (major === 2 && minor < 5)) {
573
+ addIssue(issues, {
574
+ id: 'env.git_version',
575
+ severity: 'error',
576
+ title: 'Git version too old',
577
+ message: `CursorFlow requires Git >= 2.5 for worktree support. Current version: ${versions.git}`,
578
+ fixes: ['Upgrade Git to a version >= 2.5'],
579
+ });
580
+ }
581
+ }
582
+ const pkgManager = checkPackageManager();
583
+ if (!pkgManager.ok) {
584
+ addIssue(issues, {
585
+ id: 'env.package_manager',
586
+ severity: 'warn',
587
+ title: 'No standard package manager found',
588
+ message: 'Neither pnpm nor npm was found in your PATH. CursorFlow tasks often rely on these for dependency management.',
589
+ fixes: ['Install pnpm (recommended): npm install -g pnpm', 'Or ensure npm is in your PATH'],
590
+ });
591
+ }
592
+ const diskSpace = checkDiskSpace(cwd);
593
+ if (diskSpace.ok && diskSpace.freeBytes !== undefined) {
594
+ const freeGB = diskSpace.freeBytes / (1024 * 1024 * 1024);
595
+ if (freeGB < 1) {
596
+ addIssue(issues, {
597
+ id: 'env.low_disk_space',
598
+ severity: 'warn',
599
+ title: 'Low disk space',
600
+ message: `Low disk space detected: ${freeGB.toFixed(2)} GB available. CursorFlow creates Git worktrees which can consume significant space.`,
601
+ fixes: ['Free up disk space before running large orchestration tasks'],
602
+ });
603
+ }
604
+ }
123
605
  // 1) Git repository checks
124
606
  if (!isInsideGitWorktree(cwd)) {
125
607
  addIssue(issues, {
@@ -158,6 +640,65 @@ function runDoctor(options = {}) {
158
640
  ],
159
641
  });
160
642
  }
643
+ else {
644
+ // Advanced check: remote connectivity
645
+ const connectivity = checkRemoteConnectivity(gitCwd);
646
+ if (!connectivity.ok) {
647
+ addIssue(issues, {
648
+ id: 'git.remote_connectivity',
649
+ severity: 'error',
650
+ title: "Cannot connect to 'origin'",
651
+ message: "Failed to communicate with the remote 'origin'. Check your internet connection or SSH/HTTPS credentials.",
652
+ details: connectivity.details,
653
+ fixes: [
654
+ 'git fetch origin',
655
+ 'Verify your SSH keys or credentials are configured correctly',
656
+ ],
657
+ });
658
+ }
659
+ // Advanced check: push permission
660
+ const pushPerm = checkGitPushPermission(gitCwd);
661
+ if (!pushPerm.ok) {
662
+ addIssue(issues, {
663
+ id: 'git.push_permission',
664
+ severity: 'warn',
665
+ title: 'Push permission check failed',
666
+ message: "CursorFlow might not be able to push branches to 'origin'. A dry-run push failed.",
667
+ details: pushPerm.details,
668
+ fixes: [
669
+ 'Verify you have write access to the repository on GitHub/GitLab',
670
+ 'Check if the branch naming policy on the remote permits `cursorflow/*` branches',
671
+ ],
672
+ });
673
+ }
674
+ // Advanced check: current branch upstream
675
+ const currentBranch = git.getCurrentBranch(gitCwd);
676
+ const upstreamRes = git.runGitResult(['rev-parse', '--abbrev-ref', `${currentBranch}@{u}`], { cwd: gitCwd });
677
+ if (!upstreamRes.success && currentBranch !== 'main' && currentBranch !== 'master') {
678
+ addIssue(issues, {
679
+ id: 'git.no_upstream',
680
+ severity: 'warn',
681
+ title: 'Current branch has no upstream',
682
+ message: `The current branch "${currentBranch}" is not tracking a remote branch.`,
683
+ fixes: [
684
+ `git push -u origin ${currentBranch}`,
685
+ ],
686
+ });
687
+ }
688
+ }
689
+ const gitUser = checkGitUserConfig(gitCwd);
690
+ if (!gitUser.name || !gitUser.email) {
691
+ addIssue(issues, {
692
+ id: 'git.user_config',
693
+ severity: 'error',
694
+ title: 'Git user not configured',
695
+ message: 'Git user name or email is not set. CursorFlow cannot create commits without these.',
696
+ fixes: [
697
+ `git config --global user.name "Your Name"`,
698
+ `git config --global user.email "you@example.com"`,
699
+ ],
700
+ });
701
+ }
161
702
  const wt = hasWorktreeSupport(gitCwd);
162
703
  if (!wt.ok) {
163
704
  addIssue(issues, {
@@ -172,6 +713,26 @@ function runDoctor(options = {}) {
172
713
  details: wt.details,
173
714
  });
174
715
  }
716
+ else {
717
+ // Advanced check: .gitignore check for worktrees
718
+ const gitignorePath = path.join(gitCwd, '.gitignore');
719
+ const worktreeDirName = '_cursorflow'; // Default directory name
720
+ if (fs.existsSync(gitignorePath)) {
721
+ const content = fs.readFileSync(gitignorePath, 'utf8');
722
+ if (!content.includes(worktreeDirName)) {
723
+ addIssue(issues, {
724
+ id: 'git.gitignore_missing_worktree',
725
+ severity: 'warn',
726
+ title: 'Worktree directory not ignored',
727
+ message: `The directory "${worktreeDirName}" is not in your .gitignore. This could lead to accidentally committing temporary worktrees or logs.`,
728
+ fixes: [
729
+ `Add "${worktreeDirName}/" to your .gitignore`,
730
+ 'Run `cursorflow init` to set up recommended ignores',
731
+ ],
732
+ });
733
+ }
734
+ }
735
+ }
175
736
  // 2) Tasks-dir checks (optional; used by `cursorflow run` preflight)
176
737
  if (options.tasksDir) {
177
738
  const tasksDirAbs = path.isAbsolute(options.tasksDir)
@@ -216,6 +777,7 @@ function runDoctor(options = {}) {
216
777
  });
217
778
  }
218
779
  else {
780
+ // Validate base branches
219
781
  const baseBranches = collectBaseBranchesFromLanes(lanes, 'main');
220
782
  for (const baseBranch of baseBranches) {
221
783
  if (!branchExists(gitCwd, baseBranch)) {
@@ -231,6 +793,14 @@ function runDoctor(options = {}) {
231
793
  });
232
794
  }
233
795
  }
796
+ // Validate task structure in each lane
797
+ for (const lane of lanes) {
798
+ validateTaskStructure(issues, lane.fileName, lane.json);
799
+ }
800
+ // Detect circular dependencies
801
+ detectCircularDependencies(issues, lanes);
802
+ // Validate branch names - check for conflicts
803
+ validateBranchNames(issues, lanes, gitCwd);
234
804
  }
235
805
  }
236
806
  }
@@ -262,6 +832,16 @@ function runDoctor(options = {}) {
262
832
  ],
263
833
  });
264
834
  }
835
+ // MCP/Permissions potential hang check
836
+ addIssue(issues, {
837
+ id: 'cursor_agent.mcp_priming',
838
+ severity: 'warn',
839
+ title: 'Agent may require interactive approval',
840
+ message: 'Non-interactive execution (with --print) can hang if MCP permissions or user approvals are required.',
841
+ fixes: [
842
+ 'Run once interactively to prime permissions: cursorflow doctor --test-agent',
843
+ ],
844
+ });
265
845
  }
266
846
  }
267
847
  // 4) IDE Integration checks