@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
@@ -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,413 @@ 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 { spawnSync } = require('child_process');
386
+ try {
387
+ // Validate and normalize the directory path to prevent command injection
388
+ const safePath = path.resolve(dir);
389
+ // Use spawnSync instead of execSync to avoid shell interpolation vulnerabilities
390
+ // df -B1 returns bytes. We look for the line corresponding to our directory.
391
+ const result = spawnSync('df', ['-B1', safePath], { encoding: 'utf8' });
392
+ if (result.status !== 0) {
393
+ return { ok: false, error: result.stderr || 'df command failed' };
394
+ }
395
+ const output = result.stdout;
396
+ const lines = output.trim().split('\n');
397
+ if (lines.length < 2)
398
+ return { ok: false, error: 'Could not parse df output' };
399
+ const parts = lines[1].trim().split(/\s+/);
400
+ // df output: Filesystem 1B-blocks Used Available Use% Mounted on
401
+ // Available is index 3
402
+ const available = parseInt(parts[3]);
403
+ if (isNaN(available))
404
+ return { ok: false, error: 'Could not parse available bytes' };
405
+ return { ok: true, freeBytes: available };
406
+ }
407
+ catch (e) {
408
+ return { ok: false, error: e.message };
409
+ }
410
+ }
411
+ /**
412
+ * Get all local branch names
413
+ */
414
+ function getAllLocalBranches(repoRoot) {
415
+ const res = git.runGitResult(['branch', '--list', '--format=%(refname:short)'], { cwd: repoRoot });
416
+ if (!res.success)
417
+ return [];
418
+ return res.stdout.split('\n').map(b => b.trim()).filter(b => b);
419
+ }
420
+ /**
421
+ * Get all remote branch names
422
+ */
423
+ function getAllRemoteBranches(repoRoot) {
424
+ const res = git.runGitResult(['branch', '-r', '--list', '--format=%(refname:short)'], { cwd: repoRoot });
425
+ if (!res.success)
426
+ return [];
427
+ return res.stdout.split('\n')
428
+ .map(b => b.trim().replace(/^origin\//, ''))
429
+ .filter(b => b && !b.includes('HEAD'));
430
+ }
431
+ /**
432
+ * Validate branch names for conflicts and issues
433
+ */
434
+ function validateBranchNames(issues, lanes, repoRoot) {
435
+ const localBranches = getAllLocalBranches(repoRoot);
436
+ const remoteBranches = getAllRemoteBranches(repoRoot);
437
+ const allExistingBranches = new Set([...localBranches, ...remoteBranches]);
438
+ // Collect branch prefixes from lanes
439
+ const branchPrefixes = [];
440
+ for (const lane of lanes) {
441
+ const branchPrefix = lane.json?.branchPrefix;
442
+ if (branchPrefix) {
443
+ branchPrefixes.push({ laneName: lane.fileName, prefix: branchPrefix });
444
+ }
445
+ }
446
+ // Check for branch prefix collisions between lanes
447
+ const prefixMap = new Map();
448
+ for (const { laneName, prefix } of branchPrefixes) {
449
+ const existing = prefixMap.get(prefix) || [];
450
+ existing.push(laneName);
451
+ prefixMap.set(prefix, existing);
452
+ }
453
+ for (const [prefix, laneNames] of prefixMap) {
454
+ if (laneNames.length > 1) {
455
+ addIssue(issues, {
456
+ id: 'branch.prefix_collision',
457
+ severity: 'error',
458
+ title: 'Branch prefix collision',
459
+ message: `Multiple lanes use the same branchPrefix "${prefix}": ${laneNames.join(', ')}`,
460
+ details: 'Each lane should have a unique branchPrefix to avoid conflicts.',
461
+ fixes: [
462
+ 'Update the branchPrefix in each lane JSON file to be unique',
463
+ 'Example: "featurename/lane-1-", "featurename/lane-2-"',
464
+ ],
465
+ });
466
+ }
467
+ }
468
+ // Check for existing branches that match lane prefixes
469
+ for (const { laneName, prefix } of branchPrefixes) {
470
+ const conflictingBranches = [];
471
+ for (const branch of allExistingBranches) {
472
+ if (branch.startsWith(prefix)) {
473
+ conflictingBranches.push(branch);
474
+ }
475
+ }
476
+ if (conflictingBranches.length > 0) {
477
+ addIssue(issues, {
478
+ id: `branch.existing_conflict.${laneName}`,
479
+ severity: 'warn',
480
+ title: `Existing branches may conflict with ${laneName}`,
481
+ message: `Found ${conflictingBranches.length} existing branch(es) matching prefix "${prefix}": ${conflictingBranches.slice(0, 3).join(', ')}${conflictingBranches.length > 3 ? '...' : ''}`,
482
+ details: 'These branches may cause issues if the lane tries to create a new branch with the same name.',
483
+ fixes: [
484
+ `Delete conflicting branches: git branch -D ${conflictingBranches[0]}`,
485
+ `Or change the branchPrefix in ${laneName}.json`,
486
+ 'Run: cursorflow clean branches --dry-run to see all CursorFlow branches',
487
+ ],
488
+ });
489
+ }
490
+ }
491
+ // Check for duplicate lane file names (which would cause branch issues)
492
+ const laneFileNames = lanes.map(l => l.fileName);
493
+ const duplicateNames = laneFileNames.filter((name, index) => laneFileNames.indexOf(name) !== index);
494
+ if (duplicateNames.length > 0) {
495
+ addIssue(issues, {
496
+ id: 'tasks.duplicate_lane_files',
497
+ severity: 'error',
498
+ title: 'Duplicate lane file names',
499
+ message: `Found duplicate lane names: ${[...new Set(duplicateNames)].join(', ')}`,
500
+ fixes: ['Ensure each lane file has a unique name'],
501
+ });
502
+ }
503
+ // Suggest unique branch naming convention
504
+ const hasNumericPrefix = branchPrefixes.some(({ prefix }) => /\/lane-\d+-$/.test(prefix));
505
+ if (!hasNumericPrefix && branchPrefixes.length > 1) {
506
+ addIssue(issues, {
507
+ id: 'branch.naming_suggestion',
508
+ severity: 'warn',
509
+ title: 'Consider using lane numbers in branch prefix',
510
+ message: 'Using consistent lane numbers in branch prefixes helps avoid conflicts.',
511
+ fixes: [
512
+ 'Use pattern: "feature-name/lane-{N}-" where N is the lane number',
513
+ 'Example: "auth-system/lane-1-", "auth-system/lane-2-"',
514
+ ],
515
+ });
516
+ }
517
+ }
518
+ /**
519
+ * Status file to track when doctor was last run successfully.
520
+ */
521
+ const DOCTOR_STATUS_FILE = '.cursorflow/doctor-status.json';
522
+ function saveDoctorStatus(repoRoot, report) {
523
+ const statusPath = path.join(repoRoot, DOCTOR_STATUS_FILE);
524
+ const statusDir = path.dirname(statusPath);
525
+ if (!fs.existsSync(statusDir)) {
526
+ fs.mkdirSync(statusDir, { recursive: true });
527
+ }
528
+ const status = {
529
+ lastRun: Date.now(),
530
+ ok: report.ok,
531
+ issueCount: report.issues.length,
532
+ nodeVersion: process.version,
533
+ };
534
+ fs.writeFileSync(statusPath, JSON.stringify(status, null, 2), 'utf8');
535
+ }
536
+ function getDoctorStatus(repoRoot) {
537
+ const statusPath = path.join(repoRoot, DOCTOR_STATUS_FILE);
538
+ if (!fs.existsSync(statusPath))
539
+ return null;
540
+ try {
541
+ return JSON.parse(fs.readFileSync(statusPath, 'utf8'));
542
+ }
543
+ catch {
544
+ return null;
545
+ }
546
+ }
109
547
  /**
110
548
  * Run doctor checks.
111
549
  *
112
550
  * If `tasksDir` is provided, additional preflight checks are performed:
113
551
  * - tasks directory existence and JSON validity
114
552
  * - baseBranch referenced by lanes exists locally
553
+ * - Task structure validation (name, prompt, etc.)
554
+ * - Circular dependency detection (DAG validation)
115
555
  */
116
556
  function runDoctor(options = {}) {
117
557
  const cwd = options.cwd || process.cwd();
@@ -120,6 +560,55 @@ function runDoctor(options = {}) {
120
560
  cwd,
121
561
  executor: options.executor,
122
562
  };
563
+ // 0) System and environment checks
564
+ const versions = getVersions();
565
+ const nodeMajor = parseInt(versions.node.slice(1).split('.')[0] || '0');
566
+ if (nodeMajor < 18) {
567
+ addIssue(issues, {
568
+ id: 'env.node_version',
569
+ severity: 'error',
570
+ title: 'Node.js version too old',
571
+ message: `CursorFlow requires Node.js >= 18.0.0. Current version: ${versions.node}`,
572
+ fixes: ['Upgrade Node.js to a supported version (e.g., using nvm or from nodejs.org)'],
573
+ });
574
+ }
575
+ const gitVerMatch = versions.git.match(/^(\d+)\.(\d+)/);
576
+ if (gitVerMatch) {
577
+ const major = parseInt(gitVerMatch[1]);
578
+ const minor = parseInt(gitVerMatch[2]);
579
+ if (major < 2 || (major === 2 && minor < 5)) {
580
+ addIssue(issues, {
581
+ id: 'env.git_version',
582
+ severity: 'error',
583
+ title: 'Git version too old',
584
+ message: `CursorFlow requires Git >= 2.5 for worktree support. Current version: ${versions.git}`,
585
+ fixes: ['Upgrade Git to a version >= 2.5'],
586
+ });
587
+ }
588
+ }
589
+ const pkgManager = checkPackageManager();
590
+ if (!pkgManager.ok) {
591
+ addIssue(issues, {
592
+ id: 'env.package_manager',
593
+ severity: 'warn',
594
+ title: 'No standard package manager found',
595
+ message: 'Neither pnpm nor npm was found in your PATH. CursorFlow tasks often rely on these for dependency management.',
596
+ fixes: ['Install pnpm (recommended): npm install -g pnpm', 'Or ensure npm is in your PATH'],
597
+ });
598
+ }
599
+ const diskSpace = checkDiskSpace(cwd);
600
+ if (diskSpace.ok && diskSpace.freeBytes !== undefined) {
601
+ const freeGB = diskSpace.freeBytes / (1024 * 1024 * 1024);
602
+ if (freeGB < 1) {
603
+ addIssue(issues, {
604
+ id: 'env.low_disk_space',
605
+ severity: 'warn',
606
+ title: 'Low disk space',
607
+ message: `Low disk space detected: ${freeGB.toFixed(2)} GB available. CursorFlow creates Git worktrees which can consume significant space.`,
608
+ fixes: ['Free up disk space before running large orchestration tasks'],
609
+ });
610
+ }
611
+ }
123
612
  // 1) Git repository checks
124
613
  if (!isInsideGitWorktree(cwd)) {
125
614
  addIssue(issues, {
@@ -158,6 +647,65 @@ function runDoctor(options = {}) {
158
647
  ],
159
648
  });
160
649
  }
650
+ else {
651
+ // Advanced check: remote connectivity
652
+ const connectivity = checkRemoteConnectivity(gitCwd);
653
+ if (!connectivity.ok) {
654
+ addIssue(issues, {
655
+ id: 'git.remote_connectivity',
656
+ severity: 'error',
657
+ title: "Cannot connect to 'origin'",
658
+ message: "Failed to communicate with the remote 'origin'. Check your internet connection or SSH/HTTPS credentials.",
659
+ details: connectivity.details,
660
+ fixes: [
661
+ 'git fetch origin',
662
+ 'Verify your SSH keys or credentials are configured correctly',
663
+ ],
664
+ });
665
+ }
666
+ // Advanced check: push permission
667
+ const pushPerm = checkGitPushPermission(gitCwd);
668
+ if (!pushPerm.ok) {
669
+ addIssue(issues, {
670
+ id: 'git.push_permission',
671
+ severity: 'warn',
672
+ title: 'Push permission check failed',
673
+ message: "CursorFlow might not be able to push branches to 'origin'. A dry-run push failed.",
674
+ details: pushPerm.details,
675
+ fixes: [
676
+ 'Verify you have write access to the repository on GitHub/GitLab',
677
+ 'Check if the branch naming policy on the remote permits `cursorflow/*` branches',
678
+ ],
679
+ });
680
+ }
681
+ // Advanced check: current branch upstream
682
+ const currentBranch = git.getCurrentBranch(gitCwd);
683
+ const upstreamRes = git.runGitResult(['rev-parse', '--abbrev-ref', `${currentBranch}@{u}`], { cwd: gitCwd });
684
+ if (!upstreamRes.success && currentBranch !== 'main' && currentBranch !== 'master') {
685
+ addIssue(issues, {
686
+ id: 'git.no_upstream',
687
+ severity: 'warn',
688
+ title: 'Current branch has no upstream',
689
+ message: `The current branch "${currentBranch}" is not tracking a remote branch.`,
690
+ fixes: [
691
+ `git push -u origin ${currentBranch}`,
692
+ ],
693
+ });
694
+ }
695
+ }
696
+ const gitUser = checkGitUserConfig(gitCwd);
697
+ if (!gitUser.name || !gitUser.email) {
698
+ addIssue(issues, {
699
+ id: 'git.user_config',
700
+ severity: 'error',
701
+ title: 'Git user not configured',
702
+ message: 'Git user name or email is not set. CursorFlow cannot create commits without these.',
703
+ fixes: [
704
+ `git config --global user.name "Your Name"`,
705
+ `git config --global user.email "you@example.com"`,
706
+ ],
707
+ });
708
+ }
161
709
  const wt = hasWorktreeSupport(gitCwd);
162
710
  if (!wt.ok) {
163
711
  addIssue(issues, {
@@ -172,6 +720,26 @@ function runDoctor(options = {}) {
172
720
  details: wt.details,
173
721
  });
174
722
  }
723
+ else {
724
+ // Advanced check: .gitignore check for worktrees
725
+ const gitignorePath = path.join(gitCwd, '.gitignore');
726
+ const worktreeDirName = '_cursorflow'; // Default directory name
727
+ if (fs.existsSync(gitignorePath)) {
728
+ const content = fs.readFileSync(gitignorePath, 'utf8');
729
+ if (!content.includes(worktreeDirName)) {
730
+ addIssue(issues, {
731
+ id: 'git.gitignore_missing_worktree',
732
+ severity: 'warn',
733
+ title: 'Worktree directory not ignored',
734
+ message: `The directory "${worktreeDirName}" is not in your .gitignore. This could lead to accidentally committing temporary worktrees or logs.`,
735
+ fixes: [
736
+ `Add "${worktreeDirName}/" to your .gitignore`,
737
+ 'Run `cursorflow init` to set up recommended ignores',
738
+ ],
739
+ });
740
+ }
741
+ }
742
+ }
175
743
  // 2) Tasks-dir checks (optional; used by `cursorflow run` preflight)
176
744
  if (options.tasksDir) {
177
745
  const tasksDirAbs = path.isAbsolute(options.tasksDir)
@@ -216,6 +784,7 @@ function runDoctor(options = {}) {
216
784
  });
217
785
  }
218
786
  else {
787
+ // Validate base branches
219
788
  const baseBranches = collectBaseBranchesFromLanes(lanes, 'main');
220
789
  for (const baseBranch of baseBranches) {
221
790
  if (!branchExists(gitCwd, baseBranch)) {
@@ -231,6 +800,14 @@ function runDoctor(options = {}) {
231
800
  });
232
801
  }
233
802
  }
803
+ // Validate task structure in each lane
804
+ for (const lane of lanes) {
805
+ validateTaskStructure(issues, lane.fileName, lane.json);
806
+ }
807
+ // Detect circular dependencies
808
+ detectCircularDependencies(issues, lanes);
809
+ // Validate branch names - check for conflicts
810
+ validateBranchNames(issues, lanes, gitCwd);
234
811
  }
235
812
  }
236
813
  }
@@ -262,6 +839,16 @@ function runDoctor(options = {}) {
262
839
  ],
263
840
  });
264
841
  }
842
+ // MCP/Permissions potential hang check
843
+ addIssue(issues, {
844
+ id: 'cursor_agent.mcp_priming',
845
+ severity: 'warn',
846
+ title: 'Agent may require interactive approval',
847
+ message: 'Non-interactive execution (with --print) can hang if MCP permissions or user approvals are required.',
848
+ fixes: [
849
+ 'Run once interactively to prime permissions: cursorflow doctor --test-agent',
850
+ ],
851
+ });
265
852
  }
266
853
  }
267
854
  // 4) IDE Integration checks