ralphctl 0.1.0 → 0.1.2

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 (130) hide show
  1. package/README.md +58 -24
  2. package/dist/add-HGJCLWED.mjs +14 -0
  3. package/dist/add-MRGCS3US.mjs +14 -0
  4. package/dist/chunk-6PYTKGB5.mjs +316 -0
  5. package/dist/chunk-7TG3EAQ2.mjs +20 -0
  6. package/dist/chunk-EKMZZRWI.mjs +521 -0
  7. package/dist/chunk-JON4GCLR.mjs +59 -0
  8. package/dist/chunk-LOR7QBXX.mjs +3683 -0
  9. package/dist/chunk-MNMQC36F.mjs +556 -0
  10. package/dist/chunk-MRKOFVTM.mjs +537 -0
  11. package/dist/chunk-NTWO2LXB.mjs +52 -0
  12. package/dist/chunk-QBXHAXHI.mjs +562 -0
  13. package/dist/chunk-WGHJI3OI.mjs +214 -0
  14. package/dist/cli.mjs +4245 -0
  15. package/dist/create-MG7E7PLQ.mjs +10 -0
  16. package/dist/handle-UG5M2OON.mjs +22 -0
  17. package/dist/multiline-OHSNFCRG.mjs +40 -0
  18. package/dist/project-NT3L4FTB.mjs +28 -0
  19. package/dist/resolver-WSFWKACM.mjs +153 -0
  20. package/dist/sprint-4VHDLGFN.mjs +37 -0
  21. package/dist/wizard-LRELAN2J.mjs +196 -0
  22. package/package.json +19 -28
  23. package/CHANGELOG.md +0 -94
  24. package/bin/ralphctl +0 -13
  25. package/src/ai/executor.ts +0 -973
  26. package/src/ai/lifecycle.ts +0 -45
  27. package/src/ai/parser.ts +0 -40
  28. package/src/ai/permissions.ts +0 -207
  29. package/src/ai/process-manager.ts +0 -248
  30. package/src/ai/prompts/index.ts +0 -89
  31. package/src/ai/rate-limiter.ts +0 -89
  32. package/src/ai/runner.ts +0 -478
  33. package/src/ai/session.ts +0 -319
  34. package/src/ai/task-context.ts +0 -270
  35. package/src/cli-metadata.ts +0 -7
  36. package/src/cli.ts +0 -65
  37. package/src/commands/completion/index.ts +0 -33
  38. package/src/commands/config/config.ts +0 -58
  39. package/src/commands/config/index.ts +0 -33
  40. package/src/commands/dashboard/dashboard.ts +0 -5
  41. package/src/commands/dashboard/index.ts +0 -6
  42. package/src/commands/doctor/doctor.ts +0 -271
  43. package/src/commands/doctor/index.ts +0 -25
  44. package/src/commands/progress/index.ts +0 -25
  45. package/src/commands/progress/log.ts +0 -64
  46. package/src/commands/progress/show.ts +0 -14
  47. package/src/commands/project/add.ts +0 -336
  48. package/src/commands/project/index.ts +0 -104
  49. package/src/commands/project/list.ts +0 -31
  50. package/src/commands/project/remove.ts +0 -43
  51. package/src/commands/project/repo.ts +0 -118
  52. package/src/commands/project/show.ts +0 -49
  53. package/src/commands/sprint/close.ts +0 -180
  54. package/src/commands/sprint/context.ts +0 -109
  55. package/src/commands/sprint/create.ts +0 -60
  56. package/src/commands/sprint/current.ts +0 -75
  57. package/src/commands/sprint/delete.ts +0 -72
  58. package/src/commands/sprint/health.ts +0 -229
  59. package/src/commands/sprint/ideate.ts +0 -496
  60. package/src/commands/sprint/index.ts +0 -226
  61. package/src/commands/sprint/list.ts +0 -86
  62. package/src/commands/sprint/plan-utils.ts +0 -207
  63. package/src/commands/sprint/plan.ts +0 -549
  64. package/src/commands/sprint/refine.ts +0 -359
  65. package/src/commands/sprint/requirements.ts +0 -58
  66. package/src/commands/sprint/show.ts +0 -140
  67. package/src/commands/sprint/start.ts +0 -119
  68. package/src/commands/sprint/switch.ts +0 -20
  69. package/src/commands/task/add.ts +0 -316
  70. package/src/commands/task/import.ts +0 -150
  71. package/src/commands/task/index.ts +0 -123
  72. package/src/commands/task/list.ts +0 -145
  73. package/src/commands/task/next.ts +0 -45
  74. package/src/commands/task/remove.ts +0 -47
  75. package/src/commands/task/reorder.ts +0 -45
  76. package/src/commands/task/show.ts +0 -111
  77. package/src/commands/task/status.ts +0 -99
  78. package/src/commands/ticket/add.ts +0 -265
  79. package/src/commands/ticket/edit.ts +0 -166
  80. package/src/commands/ticket/index.ts +0 -114
  81. package/src/commands/ticket/list.ts +0 -128
  82. package/src/commands/ticket/refine-utils.ts +0 -89
  83. package/src/commands/ticket/refine.ts +0 -268
  84. package/src/commands/ticket/remove.ts +0 -48
  85. package/src/commands/ticket/show.ts +0 -74
  86. package/src/completion/handle.ts +0 -30
  87. package/src/completion/resolver.ts +0 -241
  88. package/src/interactive/dashboard.ts +0 -268
  89. package/src/interactive/escapable.ts +0 -81
  90. package/src/interactive/file-browser.ts +0 -153
  91. package/src/interactive/index.ts +0 -429
  92. package/src/interactive/menu.ts +0 -403
  93. package/src/interactive/selectors.ts +0 -273
  94. package/src/interactive/wizard.ts +0 -221
  95. package/src/providers/claude.ts +0 -53
  96. package/src/providers/copilot.ts +0 -86
  97. package/src/providers/index.ts +0 -43
  98. package/src/providers/types.ts +0 -85
  99. package/src/schemas/index.ts +0 -130
  100. package/src/store/config.ts +0 -74
  101. package/src/store/progress.ts +0 -230
  102. package/src/store/project.ts +0 -276
  103. package/src/store/sprint.ts +0 -229
  104. package/src/store/task.ts +0 -443
  105. package/src/store/ticket.ts +0 -178
  106. package/src/theme/index.ts +0 -215
  107. package/src/theme/ui.ts +0 -872
  108. package/src/utils/detect-scripts.ts +0 -247
  109. package/src/utils/editor-input.ts +0 -41
  110. package/src/utils/editor.ts +0 -37
  111. package/src/utils/exit-codes.ts +0 -27
  112. package/src/utils/file-lock.ts +0 -135
  113. package/src/utils/git.ts +0 -185
  114. package/src/utils/ids.ts +0 -37
  115. package/src/utils/issue-fetch.ts +0 -244
  116. package/src/utils/json-extract.ts +0 -62
  117. package/src/utils/multiline.ts +0 -61
  118. package/src/utils/path-selector.ts +0 -236
  119. package/src/utils/paths.ts +0 -108
  120. package/src/utils/provider.ts +0 -34
  121. package/src/utils/requirements-export.ts +0 -63
  122. package/src/utils/storage.ts +0 -107
  123. package/tsconfig.json +0 -25
  124. /package/{src/ai → dist}/prompts/ideate-auto.md +0 -0
  125. /package/{src/ai → dist}/prompts/ideate.md +0 -0
  126. /package/{src/ai → dist}/prompts/plan-auto.md +0 -0
  127. /package/{src/ai → dist}/prompts/plan-common.md +0 -0
  128. /package/{src/ai → dist}/prompts/plan-interactive.md +0 -0
  129. /package/{src/ai → dist}/prompts/task-execution.md +0 -0
  130. /package/{src/ai → dist}/prompts/ticket-refine.md +0 -0
@@ -1,973 +0,0 @@
1
- import { confirm } from '@inquirer/prompts';
2
- import { readFile, unlink } from 'node:fs/promises';
3
- import { highlight, info, muted, success, warning } from '@src/theme/index.ts';
4
- import { ProcessManager } from '@src/ai/process-manager.ts';
5
- import {
6
- getNextTask,
7
- getReadyTasks,
8
- getRemainingTasks,
9
- getTasks,
10
- isTaskBlocked,
11
- updateTask,
12
- updateTaskStatus,
13
- } from '@src/store/task.ts';
14
- import { getProgress, logProgress, summarizeProgressForContext } from '@src/store/progress.ts';
15
- import { getProgressFilePath, getSprintDir } from '@src/utils/paths.ts';
16
- import { buildTaskExecutionPrompt } from '@src/ai/prompts/index.ts';
17
- import type { Task } from '@src/schemas/index.ts';
18
- import { createSpinner, formatTaskStatus } from '@src/theme/ui.ts';
19
- import { type ExecutionResult, parseExecutionResult } from '@src/ai/parser.ts';
20
- import type { SpawnResult } from '@src/ai/session.ts';
21
- import { SpawnError, spawnInteractive, spawnWithRetry } from '@src/ai/session.ts';
22
- import { RateLimitCoordinator } from '@src/ai/rate-limiter.ts';
23
- import { EXIT_ALL_BLOCKED, EXIT_ERROR, EXIT_NO_TASKS, EXIT_SUCCESS } from '@src/utils/exit-codes.ts';
24
- import { getSprint } from '@src/store/sprint.ts';
25
- import {
26
- buildFullTaskContext,
27
- formatTask,
28
- getContextFileName,
29
- getEffectiveCheckScript,
30
- getProjectForTask,
31
- getRecentGitHistory,
32
- runPermissionCheck,
33
- type CheckResults,
34
- type CheckStatus,
35
- type TaskContext,
36
- writeTaskContextFile,
37
- } from '@src/ai/task-context.ts';
38
- import { runLifecycleHook } from '@src/ai/lifecycle.ts';
39
- import { type ProviderAdapter } from '@src/providers/types.ts';
40
- import { getActiveProvider } from '@src/providers/index.ts';
41
- import { verifySprintBranch } from '@src/ai/runner.ts';
42
-
43
- // ============================================================================
44
- // TYPES
45
- // ============================================================================
46
-
47
- export interface ExecutorOptions {
48
- /** Step through tasks with approval between each */
49
- step: boolean;
50
- /** Limit number of tasks to execute */
51
- count: number | null;
52
- /** Interactive AI session (collaborate with provider) */
53
- session: boolean;
54
- /** Skip auto-commit after task completion */
55
- noCommit: boolean;
56
- /** Max parallel tasks (undefined = auto based on unique repos) */
57
- concurrency?: number;
58
- /** Max rate-limit retries per task */
59
- maxRetries?: number;
60
- /** Stop launching new tasks on first failure */
61
- failFast?: boolean;
62
- /** Skip precondition checks (e.g., unplanned tickets) */
63
- force?: boolean;
64
- /** Force re-run of check scripts even if they already ran this sprint */
65
- refreshCheck?: boolean;
66
- /** Auto-generate sprint branch (ralphctl/<sprint-id>) */
67
- branch?: boolean;
68
- /** Custom branch name for sprint execution */
69
- branchName?: string;
70
- }
71
-
72
- /** Reason why execution stopped */
73
- export type StopReason =
74
- | 'all_completed' // All tasks done
75
- | 'count_reached' // Reached task count limit
76
- | 'task_blocked' // A task could not be completed
77
- | 'user_paused' // User chose not to continue in interactive mode
78
- | 'no_tasks' // No tasks available
79
- | 'all_blocked'; // All remaining tasks blocked by dependencies
80
-
81
- export interface ExecutionSummary {
82
- /** Number of tasks completed in this run */
83
- completed: number;
84
- /** Number of remaining tasks */
85
- remaining: number;
86
- /** Why execution stopped */
87
- stopReason: StopReason;
88
- /** Task that caused pause (if stopReason is task_blocked) */
89
- blockedTask: Task | null;
90
- /** Reason for block (if any) */
91
- blockedReason: string | null;
92
- /** Exit code for CLI */
93
- exitCode: number;
94
- }
95
-
96
- // ============================================================================
97
- // TASK EXECUTION
98
- // ============================================================================
99
-
100
- /** Extended result that includes session ID for resume capability */
101
- interface TaskExecutionResult extends ExecutionResult {
102
- sessionId: string | null;
103
- }
104
-
105
- async function executeTask(
106
- ctx: TaskContext,
107
- options: ExecutorOptions,
108
- sprintId: string,
109
- resumeSessionId?: string,
110
- provider?: ProviderAdapter,
111
- checkStatus?: CheckStatus
112
- ): Promise<TaskExecutionResult> {
113
- const p = provider ?? (await getActiveProvider());
114
- const label = p.displayName;
115
- const projectPath = ctx.task.projectPath;
116
- const sprintDir = getSprintDir(sprintId);
117
-
118
- if (options.session) {
119
- const contextFileName = getContextFileName(sprintId, ctx.task.id);
120
- const gitHistory = getRecentGitHistory(projectPath, 20);
121
- const checkScript = getEffectiveCheckScript(ctx.project, projectPath);
122
- const allProgress = await getProgress(sprintId);
123
- const progressSummary = summarizeProgressForContext(allProgress, projectPath, 3);
124
- const fullTaskContent = buildFullTaskContext(ctx, progressSummary || null, gitHistory, checkScript, checkStatus);
125
- const progressFilePath = getProgressFilePath(sprintId);
126
- const instructions = buildTaskExecutionPrompt(progressFilePath, options.noCommit, contextFileName);
127
- const contextFile = await writeTaskContextFile(projectPath, fullTaskContent, instructions, sprintId, ctx.task.id);
128
-
129
- try {
130
- const result = spawnInteractive(
131
- `Read ${contextFileName} and follow the instructions`,
132
- {
133
- cwd: projectPath,
134
- args: ['--add-dir', sprintDir],
135
- },
136
- p
137
- );
138
-
139
- if (result.error) {
140
- return { success: false, output: '', blockedReason: result.error, sessionId: null };
141
- }
142
-
143
- if (result.code === 0) {
144
- return { success: true, output: '', verified: true, sessionId: null };
145
- }
146
- return {
147
- success: false,
148
- output: '',
149
- blockedReason: `${label} exited with code ${String(result.code)}`,
150
- sessionId: null,
151
- };
152
- } finally {
153
- try {
154
- await unlink(contextFile);
155
- } catch {
156
- // Ignore cleanup errors
157
- }
158
- }
159
- }
160
-
161
- // Headless mode
162
- let spawnResult: SpawnResult;
163
-
164
- if (resumeSessionId) {
165
- // Resume a previous session — send a short continuation prompt
166
- const spinner = createSpinner(`Resuming ${label} session for: ${ctx.task.name}`).start();
167
-
168
- // Register spinner cleanup with ProcessManager
169
- const manager = ProcessManager.getInstance();
170
- const deregister = manager.registerCleanup(() => {
171
- spinner.stop();
172
- });
173
-
174
- try {
175
- spawnResult = await spawnWithRetry(
176
- {
177
- cwd: projectPath,
178
- args: ['--add-dir', sprintDir],
179
- prompt: 'Continue where you left off. Complete the task and signal completion.',
180
- resumeSessionId,
181
- },
182
- {
183
- maxRetries: options.maxRetries,
184
- onRetry: (attempt, delayMs) => {
185
- spinner.text = `Rate limited, retrying in ${String(Math.round(delayMs / 1000))}s (attempt ${String(attempt)})...`;
186
- },
187
- },
188
- p
189
- );
190
- spinner.succeed(`${label} completed: ${ctx.task.name}`);
191
- } catch (err) {
192
- spinner.fail(`${label} failed: ${ctx.task.name}`);
193
- throw err;
194
- } finally {
195
- deregister(); // Clean up callback registration
196
- }
197
- } else {
198
- // Fresh session — build full context
199
- const contextFileName = getContextFileName(sprintId, ctx.task.id);
200
- const gitHistory = getRecentGitHistory(projectPath, 20);
201
- const checkScript = getEffectiveCheckScript(ctx.project, projectPath);
202
- const allProgress = await getProgress(sprintId);
203
- const progressSummary = summarizeProgressForContext(allProgress, projectPath, 3);
204
- const fullTaskContent = buildFullTaskContext(ctx, progressSummary || null, gitHistory, checkScript, checkStatus);
205
- const progressFilePath = getProgressFilePath(sprintId);
206
- const instructions = buildTaskExecutionPrompt(progressFilePath, options.noCommit, contextFileName);
207
- const contextFile = await writeTaskContextFile(projectPath, fullTaskContent, instructions, sprintId, ctx.task.id);
208
-
209
- const spinner = createSpinner(`${label} is working on: ${ctx.task.name}`).start();
210
-
211
- // Register spinner cleanup with ProcessManager
212
- const manager = ProcessManager.getInstance();
213
- const deregister = manager.registerCleanup(() => {
214
- spinner.stop();
215
- });
216
-
217
- try {
218
- const contextContent = await readFile(contextFile, 'utf-8');
219
- spawnResult = await spawnWithRetry(
220
- {
221
- cwd: projectPath,
222
- args: ['--add-dir', sprintDir],
223
- prompt: contextContent,
224
- },
225
- {
226
- maxRetries: options.maxRetries,
227
- onRetry: (attempt, delayMs) => {
228
- spinner.text = `Rate limited, retrying in ${String(Math.round(delayMs / 1000))}s (attempt ${String(attempt)})...`;
229
- },
230
- },
231
- p
232
- );
233
- spinner.succeed(`${label} completed: ${ctx.task.name}`);
234
- } catch (err) {
235
- spinner.fail(`${label} failed: ${ctx.task.name}`);
236
- throw err;
237
- } finally {
238
- deregister(); // Clean up callback registration
239
- try {
240
- await unlink(contextFile);
241
- } catch {
242
- // Ignore cleanup errors
243
- }
244
- }
245
- }
246
-
247
- const parsed = parseExecutionResult(spawnResult.stdout);
248
- return { ...parsed, sessionId: spawnResult.sessionId };
249
- }
250
-
251
- // ============================================================================
252
- // SEQUENTIAL EXECUTION LOOP
253
- // ============================================================================
254
-
255
- /**
256
- * Check if all remaining tasks are blocked by dependencies.
257
- */
258
- async function areAllRemainingBlocked(sprintId: string): Promise<boolean> {
259
- const remaining = await getRemainingTasks(sprintId);
260
- if (remaining.length === 0) return false;
261
-
262
- for (const task of remaining) {
263
- if (task.status === 'in_progress') return false;
264
- const blocked = await isTaskBlocked(task.id, sprintId);
265
- if (!blocked) return false;
266
- }
267
- return true;
268
- }
269
-
270
- /**
271
- * Sequential execution loop - executes tasks one at a time.
272
- * Used for session mode, step mode, or --concurrency 1.
273
- */
274
- export async function executeTaskLoop(
275
- sprintId: string,
276
- options: ExecutorOptions,
277
- checkResults?: CheckResults
278
- ): Promise<ExecutionSummary> {
279
- // Install signal handlers eagerly so Ctrl+C works before the first child spawns
280
- ProcessManager.getInstance().ensureHandlers();
281
-
282
- // Resolve provider once for the entire loop
283
- const provider = await getActiveProvider();
284
- const label = provider.displayName;
285
-
286
- const sprint = await getSprint(sprintId);
287
- let completedCount = 0;
288
- const targetCount = options.count ?? Infinity;
289
-
290
- // Check for resumability - find in_progress task
291
- const firstTask = await getNextTask(sprintId);
292
- if (firstTask?.status === 'in_progress') {
293
- console.log(warning(`\nResuming from: ${firstTask.id} - ${firstTask.name}`));
294
- }
295
-
296
- // Main implementation loop
297
- while (completedCount < targetCount) {
298
- // Break immediately if shutdown is in progress (Ctrl+C)
299
- const manager = ProcessManager.getInstance();
300
- if (manager.isShuttingDown()) {
301
- const remaining = await getRemainingTasks(sprintId);
302
- return {
303
- completed: completedCount,
304
- remaining: remaining.length,
305
- stopReason: 'task_blocked',
306
- blockedTask: null,
307
- blockedReason: 'Interrupted by user',
308
- exitCode: EXIT_ERROR,
309
- };
310
- }
311
-
312
- const task = await getNextTask(sprintId);
313
-
314
- if (!task) {
315
- // Check if all remaining tasks are blocked
316
- if (await areAllRemainingBlocked(sprintId)) {
317
- const remaining = await getRemainingTasks(sprintId);
318
- return {
319
- completed: completedCount,
320
- remaining: remaining.length,
321
- stopReason: 'all_blocked',
322
- blockedTask: null,
323
- blockedReason: 'All remaining tasks are blocked by dependencies',
324
- exitCode: EXIT_ALL_BLOCKED,
325
- };
326
- }
327
-
328
- // Truly no tasks
329
- const remaining = await getRemainingTasks(sprintId);
330
- if (remaining.length === 0 && completedCount === 0) {
331
- return {
332
- completed: 0,
333
- remaining: 0,
334
- stopReason: 'no_tasks',
335
- blockedTask: null,
336
- blockedReason: null,
337
- exitCode: EXIT_NO_TASKS,
338
- };
339
- }
340
-
341
- console.log(success('\nAll tasks completed!'));
342
- return {
343
- completed: completedCount,
344
- remaining: 0,
345
- stopReason: 'all_completed',
346
- blockedTask: null,
347
- blockedReason: null,
348
- exitCode: EXIT_SUCCESS,
349
- };
350
- }
351
-
352
- console.log(info(`\n--- Task ${String(task.order)}: ${task.name} ---`));
353
- console.log(info('ID: ') + task.id);
354
- console.log(info('Project: ') + task.projectPath);
355
- console.log(info('Status: ') + formatTaskStatus(task.status));
356
-
357
- // Mark as in_progress if not already
358
- if (task.status !== 'in_progress') {
359
- await updateTaskStatus(task.id, 'in_progress', sprintId);
360
- console.log(muted('Status updated to: in_progress'));
361
- }
362
-
363
- // Get project for the task (if available)
364
- const project = await getProjectForTask(task, sprint);
365
-
366
- // Build context for AI provider
367
- const ctx: TaskContext = { sprint, task, project };
368
- const taskPrompt = formatTask(ctx);
369
-
370
- // Run permission check (only on first task of the loop)
371
- if (completedCount === 0) {
372
- runPermissionCheck(ctx, options.noCommit, provider.name);
373
- }
374
-
375
- // Branch verification (if sprint has a branch set)
376
- if (sprint.branch) {
377
- if (!verifySprintBranch(task.projectPath, sprint.branch)) {
378
- console.log(warning(`\nBranch verification failed: expected '${sprint.branch}' in ${task.projectPath}`));
379
- console.log(muted(`Task ${task.id} remains in_progress.`));
380
- const remaining = await getRemainingTasks(sprintId);
381
- return {
382
- completed: completedCount,
383
- remaining: remaining.length,
384
- stopReason: 'task_blocked',
385
- blockedTask: task,
386
- blockedReason: `Repository ${task.projectPath} is not on expected branch '${sprint.branch}'`,
387
- exitCode: EXIT_ERROR,
388
- };
389
- }
390
- }
391
-
392
- if (options.session) {
393
- console.log(highlight(`\n[Task Context for ${label}]`));
394
- console.log(muted('─'.repeat(50)));
395
- console.log(taskPrompt);
396
- console.log(muted('─'.repeat(50)));
397
- console.log(muted(`\nStarting ${label} in ${task.projectPath} (session)...\n`));
398
- } else {
399
- console.log(muted(`Starting ${label} in ${task.projectPath} (headless)...`));
400
- }
401
-
402
- // Execute task with AI provider
403
- const result = await executeTask(ctx, options, sprintId, undefined, provider, checkResults?.get(task.projectPath));
404
-
405
- if (!result.success) {
406
- console.log(warning('\nTask not completed.'));
407
- if (result.blockedReason) {
408
- console.log(warning(`Reason: ${result.blockedReason}`));
409
- }
410
- console.log(muted('\nExecution paused. Task remains in_progress.'));
411
- console.log(muted(`Resume with: ralphctl sprint start ${sprintId}\n`));
412
-
413
- const remaining = await getRemainingTasks(sprintId);
414
- return {
415
- completed: completedCount,
416
- remaining: remaining.length,
417
- stopReason: 'task_blocked',
418
- blockedTask: task,
419
- blockedReason: result.blockedReason ?? 'Unknown reason',
420
- exitCode: EXIT_ERROR,
421
- };
422
- }
423
-
424
- // Store verification result if available
425
- if (result.verified) {
426
- await updateTask(
427
- task.id,
428
- {
429
- verified: true,
430
- verificationOutput: result.verificationOutput,
431
- },
432
- sprintId
433
- );
434
- console.log(success('Verification: passed'));
435
- }
436
-
437
- // Post-task check hook — run checkScript as a gate before marking done
438
- const checkScript = getEffectiveCheckScript(project, task.projectPath);
439
- if (checkScript) {
440
- console.log(muted(`Running post-task check: ${checkScript}`));
441
- const hookResult = runLifecycleHook(task.projectPath, checkScript, 'taskComplete');
442
- if (!hookResult.passed) {
443
- console.log(warning(`\nPost-task check failed for: ${task.name}`));
444
- console.log(muted('Task remains in_progress. Execution paused.'));
445
- console.log(muted(`Resume with: ralphctl sprint start ${sprintId}\n`));
446
- const remaining = await getRemainingTasks(sprintId);
447
- return {
448
- completed: completedCount,
449
- remaining: remaining.length,
450
- stopReason: 'task_blocked',
451
- blockedTask: task,
452
- blockedReason: `Post-task check failed: ${hookResult.output.slice(0, 500)}`,
453
- exitCode: EXIT_ERROR,
454
- };
455
- }
456
- console.log(success('Post-task check: passed'));
457
- }
458
-
459
- // Update task status: in_progress → done
460
- await updateTaskStatus(task.id, 'done', sprintId);
461
- console.log(success('Status updated to: done'));
462
-
463
- // Log automatic progress
464
- await logProgress(
465
- `Completed task: ${task.id} - ${task.name}\n\n` +
466
- (task.description ? `Description: ${task.description}\n` : '') +
467
- (task.steps.length > 0 ? `Steps:\n${task.steps.map((s, i) => ` ${String(i + 1)}. ${s}`).join('\n')}` : ''),
468
- { sprintId, projectPath: task.projectPath }
469
- );
470
-
471
- completedCount++;
472
-
473
- // Interactive mode: confirm before continuing
474
- if (options.step && completedCount < targetCount) {
475
- const remaining = await getRemainingTasks(sprintId);
476
- if (remaining.length > 0) {
477
- console.log(info(`\n${String(remaining.length)} task(s) remaining.`));
478
- const continueLoop = await confirm({
479
- message: 'Continue to next task?',
480
- default: true,
481
- });
482
- if (!continueLoop) {
483
- console.log(muted('\nExecution paused.'));
484
- console.log(muted(`Resume with: ralphctl sprint start ${sprintId}\n`));
485
- return {
486
- completed: completedCount,
487
- remaining: remaining.length,
488
- stopReason: 'user_paused',
489
- blockedTask: null,
490
- blockedReason: null,
491
- exitCode: EXIT_SUCCESS,
492
- };
493
- }
494
- }
495
- }
496
- }
497
-
498
- // Reached count limit
499
- const remaining = await getRemainingTasks(sprintId);
500
- return {
501
- completed: completedCount,
502
- remaining: remaining.length,
503
- stopReason: remaining.length === 0 ? 'all_completed' : 'count_reached',
504
- blockedTask: null,
505
- blockedReason: null,
506
- exitCode: EXIT_SUCCESS,
507
- };
508
- }
509
-
510
- // ============================================================================
511
- // PARALLEL EXECUTION LOOP
512
- // ============================================================================
513
-
514
- interface ParallelTaskResult {
515
- task: Task;
516
- result: TaskExecutionResult | null;
517
- error: Error | null;
518
- /** Whether this failure is a rate limit (should retry, not count as failure) */
519
- isRateLimited: boolean;
520
- }
521
-
522
- /**
523
- * Pick tasks to launch: one per unique projectPath, respecting concurrency limit.
524
- * Excludes repos that already have an in-flight task.
525
- */
526
- function pickTasksToLaunch(
527
- readyTasks: Task[],
528
- inFlightPaths: Set<string>,
529
- concurrencyLimit: number,
530
- currentInFlight: number
531
- ): Task[] {
532
- const available = readyTasks.filter((t) => !inFlightPaths.has(t.projectPath));
533
-
534
- // Deduplicate by projectPath — pick the first (lowest order) task per repo
535
- const byPath = new Map<string, Task>();
536
- for (const task of available) {
537
- if (!byPath.has(task.projectPath)) {
538
- byPath.set(task.projectPath, task);
539
- }
540
- }
541
-
542
- const candidates = [...byPath.values()];
543
- const slotsAvailable = concurrencyLimit - currentInFlight;
544
- return candidates.slice(0, Math.max(0, slotsAvailable));
545
- }
546
-
547
- /**
548
- * Parallel execution loop - runs tasks concurrently across different repos.
549
- * At most one task per projectPath runs at a time to avoid git conflicts.
550
- */
551
- export async function executeTaskLoopParallel(
552
- sprintId: string,
553
- options: ExecutorOptions,
554
- checkResults?: CheckResults
555
- ): Promise<ExecutionSummary> {
556
- // Install signal handlers eagerly so Ctrl+C works before the first child spawns
557
- ProcessManager.getInstance().ensureHandlers();
558
-
559
- // Resolve provider once for the entire loop
560
- const provider = await getActiveProvider();
561
- const label = provider.displayName;
562
-
563
- const sprint = await getSprint(sprintId);
564
- let completedCount = 0;
565
- const targetCount = options.count ?? Infinity;
566
- const failFast = options.failFast ?? true;
567
- let hasFailed = false;
568
- let firstBlockedTask: Task | null = null;
569
- let firstBlockedReason: string | null = null;
570
-
571
- // Determine concurrency limit (hard cap prevents resource exhaustion)
572
- const MAX_CONCURRENCY = 10;
573
- const allTasks = await getTasks(sprintId);
574
- const uniqueRepoPaths = new Set(allTasks.map((t) => t.projectPath));
575
- const concurrencyLimit = Math.min(options.concurrency ?? uniqueRepoPaths.size, MAX_CONCURRENCY);
576
-
577
- console.log(muted(`Parallel mode: up to ${String(concurrencyLimit)} concurrent task(s)`));
578
-
579
- // Set up rate limit coordinator
580
- const coordinator = new RateLimitCoordinator({
581
- onPause: (delayMs) => {
582
- console.log(warning(`\nRate limited. Pausing new launches for ${String(Math.round(delayMs / 1000))}s...`));
583
- },
584
- onResume: () => {
585
- console.log(success('Rate limit cooldown ended. Resuming launches.'));
586
- },
587
- });
588
-
589
- // Track in-flight tasks and session IDs for resume
590
- const inFlightPaths = new Set<string>();
591
- const running = new Map<string, Promise<ParallelTaskResult>>();
592
- const taskSessionIds = new Map<string, string>(); // taskId → AI session ID
593
- const branchRetries = new Map<string, number>(); // taskId → branch verification attempts
594
- const MAX_BRANCH_RETRIES = 3;
595
- let permissionCheckDone = false;
596
-
597
- try {
598
- // Check for resumable in_progress tasks
599
- const inProgressTasks = allTasks.filter((t) => t.status === 'in_progress');
600
- if (inProgressTasks.length > 0) {
601
- console.log(warning(`\nResuming ${String(inProgressTasks.length)} in-progress task(s):`));
602
- for (const t of inProgressTasks) {
603
- console.log(warning(` - ${t.id}: ${t.name}`));
604
- }
605
- }
606
-
607
- while (completedCount < targetCount) {
608
- // Break immediately if shutdown is in progress (Ctrl+C)
609
- const manager = ProcessManager.getInstance();
610
- if (manager.isShuttingDown()) {
611
- break;
612
- }
613
-
614
- // Wait if rate limited before checking for new tasks
615
- await coordinator.waitIfPaused();
616
-
617
- // Get current task state from disk
618
- const readyTasks = await getReadyTasks(sprintId);
619
-
620
- // Also check for in_progress tasks (resumable)
621
- const currentTasks = await getTasks(sprintId);
622
- const inProgress = currentTasks.filter((t) => t.status === 'in_progress' && !running.has(t.id));
623
-
624
- // Combine: resume in_progress first, then ready tasks
625
- const launchCandidates = [...inProgress, ...readyTasks.filter((t) => !inProgress.some((ip) => ip.id === t.id))];
626
-
627
- if (launchCandidates.length === 0 && running.size === 0) {
628
- // Nothing to run and nothing in flight
629
- const remaining = await getRemainingTasks(sprintId);
630
- if (remaining.length === 0) {
631
- if (completedCount === 0) {
632
- return {
633
- completed: 0,
634
- remaining: 0,
635
- stopReason: 'no_tasks',
636
- blockedTask: null,
637
- blockedReason: null,
638
- exitCode: EXIT_NO_TASKS,
639
- };
640
- }
641
- console.log(success('\nAll tasks completed!'));
642
- return {
643
- completed: completedCount,
644
- remaining: 0,
645
- stopReason: 'all_completed',
646
- blockedTask: null,
647
- blockedReason: null,
648
- exitCode: EXIT_SUCCESS,
649
- };
650
- }
651
-
652
- // Tasks exist but none are launchable — all blocked
653
- return {
654
- completed: completedCount,
655
- remaining: remaining.length,
656
- stopReason: hasFailed ? 'task_blocked' : 'all_blocked',
657
- blockedTask: firstBlockedTask,
658
- blockedReason: firstBlockedReason ?? 'All remaining tasks are blocked by dependencies',
659
- exitCode: hasFailed ? EXIT_ERROR : EXIT_ALL_BLOCKED,
660
- };
661
- }
662
-
663
- // Pick tasks to launch (if we should)
664
- if (!hasFailed || !failFast) {
665
- const toStart = pickTasksToLaunch(launchCandidates, inFlightPaths, concurrencyLimit, running.size);
666
-
667
- for (const task of toStart) {
668
- if (completedCount + running.size >= targetCount) break;
669
-
670
- // Cache project lookup — reused for permission check and execution
671
- const project = await getProjectForTask(task, sprint);
672
-
673
- // Run permission check once (before any task starts)
674
- if (!permissionCheckDone) {
675
- const ctx: TaskContext = { sprint, task, project };
676
- runPermissionCheck(ctx, options.noCommit, provider.name);
677
- permissionCheckDone = true;
678
- }
679
-
680
- // Branch verification (if sprint has a branch set)
681
- if (sprint.branch) {
682
- if (!verifySprintBranch(task.projectPath, sprint.branch)) {
683
- const attempt = (branchRetries.get(task.id) ?? 0) + 1;
684
- branchRetries.set(task.id, attempt);
685
-
686
- if (attempt < MAX_BRANCH_RETRIES) {
687
- // Transient failure — re-enqueue for retry (similar to rate-limited tasks)
688
- console.log(
689
- warning(
690
- `\n Branch verification failed (attempt ${String(attempt)}/${String(MAX_BRANCH_RETRIES)}): expected '${sprint.branch}' in ${task.projectPath}`
691
- )
692
- );
693
- console.log(muted(` Task ${task.id} will retry on next loop iteration.`));
694
- continue;
695
- }
696
-
697
- // Exhausted retries — treat as a real failure
698
- console.log(
699
- warning(
700
- `\n Branch verification failed after ${String(MAX_BRANCH_RETRIES)} attempts: expected '${sprint.branch}' in ${task.projectPath}`
701
- )
702
- );
703
- console.log(muted(` Task ${task.id} not started — wrong branch.`));
704
- hasFailed = true;
705
- if (!firstBlockedTask) {
706
- firstBlockedTask = task;
707
- firstBlockedReason = `Repository ${task.projectPath} is not on expected branch '${sprint.branch}'`;
708
- }
709
- if (failFast) {
710
- console.log(muted('Fail-fast: waiting for running tasks to finish...'));
711
- }
712
- continue;
713
- }
714
- }
715
-
716
- // Mark as in_progress only after pre-flight passes
717
- if (task.status !== 'in_progress') {
718
- await updateTaskStatus(task.id, 'in_progress', sprintId);
719
- }
720
-
721
- // Check if we have a session ID to resume from (rate-limit recovery)
722
- const resumeId = taskSessionIds.get(task.id);
723
- const action = resumeId ? 'Resuming' : 'Starting';
724
-
725
- console.log(info(`\n--- ${action} task ${String(task.order)}: ${task.name} ---`));
726
- console.log(info('ID: ') + task.id);
727
- console.log(info('Project: ') + task.projectPath);
728
- if (resumeId) {
729
- console.log(muted(`Resuming ${label} session ${resumeId.slice(0, 8)}...`));
730
- } else {
731
- console.log(muted(`Starting ${label} in ${task.projectPath} (headless)...`));
732
- }
733
-
734
- inFlightPaths.add(task.projectPath);
735
-
736
- const taskPromise = (async (): Promise<ParallelTaskResult> => {
737
- try {
738
- const ctx: TaskContext = { sprint, task, project };
739
- const result = await executeTask(
740
- ctx,
741
- options,
742
- sprintId,
743
- resumeId,
744
- provider,
745
- checkResults?.get(task.projectPath)
746
- );
747
-
748
- // Store session ID for potential future resume
749
- if (result.sessionId) {
750
- taskSessionIds.set(task.id, result.sessionId);
751
- }
752
-
753
- return { task, result, error: null, isRateLimited: false };
754
- } catch (err) {
755
- if (err instanceof SpawnError && err.rateLimited) {
756
- // Store session ID from error for resume after cooldown
757
- if (err.sessionId) {
758
- taskSessionIds.set(task.id, err.sessionId);
759
- }
760
- const delay = err.retryAfterMs ?? 60_000;
761
- coordinator.pause(delay);
762
-
763
- return {
764
- task,
765
- result: null,
766
- error: err,
767
- isRateLimited: true,
768
- };
769
- }
770
-
771
- return {
772
- task,
773
- result: null,
774
- error: err instanceof Error ? err : new Error(String(err)),
775
- isRateLimited: false,
776
- };
777
- } finally {
778
- inFlightPaths.delete(task.projectPath);
779
- }
780
- })();
781
-
782
- running.set(task.id, taskPromise);
783
- }
784
- }
785
-
786
- // Wait for any task to complete
787
- if (running.size === 0) {
788
- // Check if any tasks are pending branch retry before giving up
789
- const hasPendingBranchRetry = [...branchRetries.entries()].some(([, count]) => count < MAX_BRANCH_RETRIES);
790
- if (hasPendingBranchRetry) {
791
- // Brief delay before retrying to avoid tight-looping
792
- await new Promise((resolve) => setTimeout(resolve, 1000));
793
- continue;
794
- }
795
- // Nothing launched, nothing running, no retries pending — stop
796
- break;
797
- }
798
-
799
- // Wait for first task to complete, then check rate limit state and launch next batch
800
- const settled = await Promise.race([...running.values()]);
801
- running.delete(settled.task.id);
802
-
803
- // Process the result
804
- if (settled.error) {
805
- if (settled.isRateLimited) {
806
- // Rate limit — not a real failure, will be re-queued after cooldown
807
- const sessionId = taskSessionIds.get(settled.task.id);
808
- console.log(warning(`\nRate limited: ${settled.task.name}`));
809
- if (sessionId) {
810
- console.log(muted(`Session saved for resume: ${sessionId.slice(0, 8)}...`));
811
- }
812
- console.log(muted('Will retry after cooldown.'));
813
- // Don't set hasFailed — this task will be re-launched on next loop iteration
814
- continue;
815
- }
816
-
817
- // Real error
818
- console.log(warning(`\nTask failed: ${settled.task.name}`));
819
- console.log(warning(`Error: ${settled.error.message}`));
820
- console.log(muted(`Task ${settled.task.id} remains in_progress for resumption.`));
821
-
822
- hasFailed = true;
823
- if (!firstBlockedTask) {
824
- firstBlockedTask = settled.task;
825
- firstBlockedReason = settled.error.message;
826
- }
827
-
828
- if (failFast) {
829
- console.log(muted('Fail-fast: waiting for running tasks to finish...'));
830
- }
831
- continue;
832
- }
833
-
834
- if (settled.result && !settled.result.success) {
835
- console.log(warning(`\nTask not completed: ${settled.task.name}`));
836
- if (settled.result.blockedReason) {
837
- console.log(warning(`Reason: ${settled.result.blockedReason}`));
838
- }
839
- console.log(muted(`Task ${settled.task.id} remains in_progress.`));
840
-
841
- hasFailed = true;
842
- if (!firstBlockedTask) {
843
- firstBlockedTask = settled.task;
844
- firstBlockedReason = settled.result.blockedReason ?? 'Unknown reason';
845
- }
846
-
847
- if (failFast) {
848
- console.log(muted('Fail-fast: waiting for running tasks to finish...'));
849
- }
850
- continue;
851
- }
852
-
853
- // Task completed successfully
854
- if (settled.result) {
855
- // Store verification result
856
- if (settled.result.verified) {
857
- await updateTask(
858
- settled.task.id,
859
- {
860
- verified: true,
861
- verificationOutput: settled.result.verificationOutput,
862
- },
863
- sprintId
864
- );
865
- console.log(success(`Verification passed: ${settled.task.name}`));
866
- }
867
-
868
- // Post-task check hook
869
- const taskProject = await getProjectForTask(settled.task, sprint);
870
- const taskCheckScript = getEffectiveCheckScript(taskProject, settled.task.projectPath);
871
- if (taskCheckScript) {
872
- const hookResult = runLifecycleHook(settled.task.projectPath, taskCheckScript, 'taskComplete');
873
- if (!hookResult.passed) {
874
- console.log(warning(`\nPost-task check failed for: ${settled.task.name}`));
875
- console.log(muted(`Task ${settled.task.id} remains in_progress.`));
876
- hasFailed = true;
877
- if (!firstBlockedTask) {
878
- firstBlockedTask = settled.task;
879
- firstBlockedReason = `Post-task check failed: ${hookResult.output.slice(0, 500)}`;
880
- }
881
- if (failFast) {
882
- console.log(muted('Fail-fast: waiting for running tasks to finish...'));
883
- }
884
- continue;
885
- }
886
- console.log(success(`Post-task check passed: ${settled.task.name}`));
887
- }
888
-
889
- // Mark done
890
- await updateTaskStatus(settled.task.id, 'done', sprintId);
891
- console.log(success(`Completed: ${settled.task.name}`));
892
-
893
- // Clean up session tracking
894
- taskSessionIds.delete(settled.task.id);
895
-
896
- // Log progress
897
- await logProgress(
898
- `Completed task: ${settled.task.id} - ${settled.task.name}\n\n` +
899
- (settled.task.description ? `Description: ${settled.task.description}\n` : '') +
900
- (settled.task.steps.length > 0
901
- ? `Steps:\n${settled.task.steps.map((s, i) => ` ${String(i + 1)}. ${s}`).join('\n')}`
902
- : ''),
903
- { sprintId, projectPath: settled.task.projectPath }
904
- );
905
-
906
- completedCount++;
907
- }
908
- }
909
-
910
- // Wait for any remaining in-flight tasks
911
- if (running.size > 0) {
912
- console.log(muted(`\nWaiting for ${String(running.size)} remaining task(s)...`));
913
- const remaining = await Promise.allSettled([...running.values()]);
914
- for (const r of remaining) {
915
- if (r.status === 'fulfilled' && r.value.result?.success) {
916
- if (r.value.result.verified) {
917
- await updateTask(
918
- r.value.task.id,
919
- { verified: true, verificationOutput: r.value.result.verificationOutput },
920
- sprintId
921
- );
922
- }
923
- // Post-task check hook
924
- const drainProject = await getProjectForTask(r.value.task, sprint);
925
- const drainCheckScript = getEffectiveCheckScript(drainProject, r.value.task.projectPath);
926
- if (drainCheckScript) {
927
- const hookResult = runLifecycleHook(r.value.task.projectPath, drainCheckScript, 'taskComplete');
928
- if (!hookResult.passed) {
929
- console.log(warning(`Post-task check failed for: ${r.value.task.name}`));
930
- continue;
931
- }
932
- }
933
- await updateTaskStatus(r.value.task.id, 'done', sprintId);
934
- console.log(success(`Completed: ${r.value.task.name}`));
935
- await logProgress(`Completed task: ${r.value.task.id} - ${r.value.task.name}`, {
936
- sprintId,
937
- projectPath: r.value.task.projectPath,
938
- });
939
- completedCount++;
940
- }
941
- }
942
- }
943
- } finally {
944
- coordinator.dispose();
945
- }
946
-
947
- const remainingTasks = await getRemainingTasks(sprintId);
948
-
949
- if (hasFailed) {
950
- return {
951
- completed: completedCount,
952
- remaining: remainingTasks.length,
953
- stopReason: 'task_blocked',
954
- blockedTask: firstBlockedTask,
955
- blockedReason: firstBlockedReason,
956
- exitCode: EXIT_ERROR,
957
- };
958
- }
959
-
960
- return {
961
- completed: completedCount,
962
- remaining: remainingTasks.length,
963
- stopReason: remainingTasks.length === 0 ? 'all_completed' : 'count_reached',
964
- blockedTask: null,
965
- blockedReason: null,
966
- exitCode: EXIT_SUCCESS,
967
- };
968
- }
969
-
970
- // Re-export for backward compatibility
971
- export { formatTask as formatTaskContext } from '@src/ai/task-context.ts';
972
- // Re-export TaskContext type for consumers
973
- export type { TaskContext } from '@src/ai/task-context.ts';