ralphctl 0.1.0

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 (118) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/LICENSE +21 -0
  3. package/README.md +189 -0
  4. package/bin/ralphctl +13 -0
  5. package/package.json +92 -0
  6. package/schemas/config.schema.json +20 -0
  7. package/schemas/ideate-output.schema.json +22 -0
  8. package/schemas/projects.schema.json +53 -0
  9. package/schemas/requirements-output.schema.json +24 -0
  10. package/schemas/sprint.schema.json +109 -0
  11. package/schemas/task-import.schema.json +49 -0
  12. package/schemas/tasks.schema.json +72 -0
  13. package/src/ai/executor.ts +973 -0
  14. package/src/ai/lifecycle.ts +45 -0
  15. package/src/ai/parser.ts +40 -0
  16. package/src/ai/permissions.ts +207 -0
  17. package/src/ai/process-manager.ts +248 -0
  18. package/src/ai/prompts/ideate-auto.md +144 -0
  19. package/src/ai/prompts/ideate.md +165 -0
  20. package/src/ai/prompts/index.ts +89 -0
  21. package/src/ai/prompts/plan-auto.md +131 -0
  22. package/src/ai/prompts/plan-common.md +157 -0
  23. package/src/ai/prompts/plan-interactive.md +190 -0
  24. package/src/ai/prompts/task-execution.md +159 -0
  25. package/src/ai/prompts/ticket-refine.md +230 -0
  26. package/src/ai/rate-limiter.ts +89 -0
  27. package/src/ai/runner.ts +478 -0
  28. package/src/ai/session.ts +319 -0
  29. package/src/ai/task-context.ts +270 -0
  30. package/src/cli-metadata.ts +7 -0
  31. package/src/cli.ts +65 -0
  32. package/src/commands/completion/index.ts +33 -0
  33. package/src/commands/config/config.ts +58 -0
  34. package/src/commands/config/index.ts +33 -0
  35. package/src/commands/dashboard/dashboard.ts +5 -0
  36. package/src/commands/dashboard/index.ts +6 -0
  37. package/src/commands/doctor/doctor.ts +271 -0
  38. package/src/commands/doctor/index.ts +25 -0
  39. package/src/commands/progress/index.ts +25 -0
  40. package/src/commands/progress/log.ts +64 -0
  41. package/src/commands/progress/show.ts +14 -0
  42. package/src/commands/project/add.ts +336 -0
  43. package/src/commands/project/index.ts +104 -0
  44. package/src/commands/project/list.ts +31 -0
  45. package/src/commands/project/remove.ts +43 -0
  46. package/src/commands/project/repo.ts +118 -0
  47. package/src/commands/project/show.ts +49 -0
  48. package/src/commands/sprint/close.ts +180 -0
  49. package/src/commands/sprint/context.ts +109 -0
  50. package/src/commands/sprint/create.ts +60 -0
  51. package/src/commands/sprint/current.ts +75 -0
  52. package/src/commands/sprint/delete.ts +72 -0
  53. package/src/commands/sprint/health.ts +229 -0
  54. package/src/commands/sprint/ideate.ts +496 -0
  55. package/src/commands/sprint/index.ts +226 -0
  56. package/src/commands/sprint/list.ts +86 -0
  57. package/src/commands/sprint/plan-utils.ts +207 -0
  58. package/src/commands/sprint/plan.ts +549 -0
  59. package/src/commands/sprint/refine.ts +359 -0
  60. package/src/commands/sprint/requirements.ts +58 -0
  61. package/src/commands/sprint/show.ts +140 -0
  62. package/src/commands/sprint/start.ts +119 -0
  63. package/src/commands/sprint/switch.ts +20 -0
  64. package/src/commands/task/add.ts +316 -0
  65. package/src/commands/task/import.ts +150 -0
  66. package/src/commands/task/index.ts +123 -0
  67. package/src/commands/task/list.ts +145 -0
  68. package/src/commands/task/next.ts +45 -0
  69. package/src/commands/task/remove.ts +47 -0
  70. package/src/commands/task/reorder.ts +45 -0
  71. package/src/commands/task/show.ts +111 -0
  72. package/src/commands/task/status.ts +99 -0
  73. package/src/commands/ticket/add.ts +265 -0
  74. package/src/commands/ticket/edit.ts +166 -0
  75. package/src/commands/ticket/index.ts +114 -0
  76. package/src/commands/ticket/list.ts +128 -0
  77. package/src/commands/ticket/refine-utils.ts +89 -0
  78. package/src/commands/ticket/refine.ts +268 -0
  79. package/src/commands/ticket/remove.ts +48 -0
  80. package/src/commands/ticket/show.ts +74 -0
  81. package/src/completion/handle.ts +30 -0
  82. package/src/completion/resolver.ts +241 -0
  83. package/src/interactive/dashboard.ts +268 -0
  84. package/src/interactive/escapable.ts +81 -0
  85. package/src/interactive/file-browser.ts +153 -0
  86. package/src/interactive/index.ts +429 -0
  87. package/src/interactive/menu.ts +403 -0
  88. package/src/interactive/selectors.ts +273 -0
  89. package/src/interactive/wizard.ts +221 -0
  90. package/src/providers/claude.ts +53 -0
  91. package/src/providers/copilot.ts +86 -0
  92. package/src/providers/index.ts +43 -0
  93. package/src/providers/types.ts +85 -0
  94. package/src/schemas/index.ts +130 -0
  95. package/src/store/config.ts +74 -0
  96. package/src/store/progress.ts +230 -0
  97. package/src/store/project.ts +276 -0
  98. package/src/store/sprint.ts +229 -0
  99. package/src/store/task.ts +443 -0
  100. package/src/store/ticket.ts +178 -0
  101. package/src/theme/index.ts +215 -0
  102. package/src/theme/ui.ts +872 -0
  103. package/src/utils/detect-scripts.ts +247 -0
  104. package/src/utils/editor-input.ts +41 -0
  105. package/src/utils/editor.ts +37 -0
  106. package/src/utils/exit-codes.ts +27 -0
  107. package/src/utils/file-lock.ts +135 -0
  108. package/src/utils/git.ts +185 -0
  109. package/src/utils/ids.ts +37 -0
  110. package/src/utils/issue-fetch.ts +244 -0
  111. package/src/utils/json-extract.ts +62 -0
  112. package/src/utils/multiline.ts +61 -0
  113. package/src/utils/path-selector.ts +236 -0
  114. package/src/utils/paths.ts +108 -0
  115. package/src/utils/provider.ts +34 -0
  116. package/src/utils/requirements-export.ts +63 -0
  117. package/src/utils/storage.ts +107 -0
  118. package/tsconfig.json +25 -0
@@ -0,0 +1,478 @@
1
+ import { confirm, input, select } from '@inquirer/prompts';
2
+ import { log, printHeader, showError, showRandomQuote, showSuccess, showWarning, terminalBell } from '@src/theme/ui.ts';
3
+ import {
4
+ activateSprint,
5
+ assertSprintStatus,
6
+ closeSprint,
7
+ getSprint,
8
+ resolveSprintId,
9
+ saveSprint,
10
+ } from '@src/store/sprint.ts';
11
+ import {
12
+ areAllTasksDone,
13
+ DependencyCycleError,
14
+ getRemainingTasks,
15
+ getTasks,
16
+ reorderByDependencies,
17
+ } from '@src/store/task.ts';
18
+ import { formatTicketId, getPendingRequirements } from '@src/store/ticket.ts';
19
+ import {
20
+ executeTaskLoop,
21
+ executeTaskLoopParallel,
22
+ type ExecutionSummary,
23
+ type ExecutorOptions,
24
+ } from '@src/ai/executor.ts';
25
+ import {
26
+ getEffectiveCheckScript,
27
+ getProjectForTask,
28
+ type CheckResults,
29
+ type CheckStatus,
30
+ } from '@src/ai/task-context.ts';
31
+ import { runLifecycleHook } from '@src/ai/lifecycle.ts';
32
+ import type { Sprint } from '@src/schemas/index.ts';
33
+ import {
34
+ createAndCheckoutBranch,
35
+ generateBranchName,
36
+ getCurrentBranch,
37
+ hasUncommittedChanges,
38
+ isValidBranchName,
39
+ verifyCurrentBranch,
40
+ } from '@src/utils/git.ts';
41
+
42
+ // Re-export types for convenience
43
+ export type { ExecutorOptions, ExecutionSummary } from '@src/ai/executor.ts';
44
+
45
+ // Alias for backward compatibility
46
+ export type RunnerOptions = ExecutorOptions;
47
+
48
+ // ============================================================================
49
+ // BRANCH MANAGEMENT
50
+ // ============================================================================
51
+
52
+ /**
53
+ * Prompt the user to select a branch strategy for sprint execution.
54
+ * Returns the branch name to use, or null for no branch management.
55
+ */
56
+ export async function promptBranchStrategy(sprintId: string): Promise<string | null> {
57
+ const autoBranch = generateBranchName(sprintId);
58
+
59
+ const strategy = await select({
60
+ message: 'How should this sprint manage branches?',
61
+ choices: [
62
+ {
63
+ name: `Create sprint branch: ${autoBranch} (Recommended)`,
64
+ value: 'auto',
65
+ },
66
+ {
67
+ name: 'Keep current branch (no branch management)',
68
+ value: 'keep',
69
+ },
70
+ {
71
+ name: 'Custom branch name',
72
+ value: 'custom',
73
+ },
74
+ ],
75
+ });
76
+
77
+ if (strategy === 'keep') return null;
78
+ if (strategy === 'auto') return autoBranch;
79
+
80
+ // Custom branch name
81
+ const customName = await input({
82
+ message: 'Enter branch name:',
83
+ validate: (value) => {
84
+ if (!value.trim()) return 'Branch name cannot be empty';
85
+ if (!isValidBranchName(value.trim())) {
86
+ return 'Invalid branch name. Use alphanumeric characters, hyphens, underscores, dots, and slashes.';
87
+ }
88
+ return true;
89
+ },
90
+ });
91
+
92
+ return customName.trim();
93
+ }
94
+
95
+ /**
96
+ * Resolve the branch to use for sprint execution.
97
+ *
98
+ * Priority:
99
+ * 1. options.branchName — explicit CLI override
100
+ * 2. options.branch — auto-generate from sprint ID
101
+ * 3. sprint.branch — saved from previous run (resume)
102
+ * 4. Interactive prompt — first run without flags
103
+ *
104
+ * Returns the branch name or null (no branch management).
105
+ */
106
+ export async function resolveBranch(
107
+ sprintId: string,
108
+ sprint: Sprint,
109
+ options: ExecutorOptions
110
+ ): Promise<string | null> {
111
+ if (options.branchName) return options.branchName;
112
+ if (options.branch) return generateBranchName(sprintId);
113
+ if (sprint.branch) return sprint.branch;
114
+ return promptBranchStrategy(sprintId);
115
+ }
116
+
117
+ /**
118
+ * Create/checkout the sprint branch in every repo that has remaining tasks.
119
+ *
120
+ * - Collects unique projectPath values from remaining tasks
121
+ * - Fails fast if any repo has uncommitted changes
122
+ * - Creates or checks out the branch (idempotent for resume)
123
+ * - Persists sprint.branch for subsequent runs
124
+ */
125
+ export async function ensureSprintBranches(sprintId: string, sprint: Sprint, branchName: string): Promise<void> {
126
+ if (!isValidBranchName(branchName)) {
127
+ throw new Error(`Invalid branch name: ${branchName}`);
128
+ }
129
+
130
+ const tasks = await getTasks(sprintId);
131
+ const remainingTasks = tasks.filter((t) => t.status !== 'done');
132
+ const uniquePaths = [...new Set(remainingTasks.map((t) => t.projectPath))];
133
+
134
+ if (uniquePaths.length === 0) return;
135
+
136
+ // Check for uncommitted changes in all repos first (fail-fast)
137
+ for (const projectPath of uniquePaths) {
138
+ try {
139
+ if (hasUncommittedChanges(projectPath)) {
140
+ throw new Error(
141
+ `Repository at ${projectPath} has uncommitted changes. ` + 'Commit or stash them before starting the sprint.'
142
+ );
143
+ }
144
+ } catch (err) {
145
+ if (err instanceof Error && err.message.includes('uncommitted changes')) {
146
+ throw err;
147
+ }
148
+ // Not a git repo or other git error — skip with notice
149
+ log.dim(` Skipping ${projectPath} — not a git repository`);
150
+ continue;
151
+ }
152
+ }
153
+
154
+ // Create/checkout branch in each repo
155
+ for (const projectPath of uniquePaths) {
156
+ try {
157
+ const currentBranch = getCurrentBranch(projectPath);
158
+ if (currentBranch === branchName) {
159
+ log.dim(` Already on branch '${branchName}' in ${projectPath}`);
160
+ } else {
161
+ createAndCheckoutBranch(projectPath, branchName);
162
+ log.success(` Branch '${branchName}' ready in ${projectPath}`);
163
+ }
164
+ } catch (err) {
165
+ throw new Error(
166
+ `Failed to create branch '${branchName}' in ${projectPath}: ${err instanceof Error ? err.message : String(err)}`,
167
+ { cause: err }
168
+ );
169
+ }
170
+ }
171
+
172
+ // Persist the branch name
173
+ if (sprint.branch !== branchName) {
174
+ sprint.branch = branchName;
175
+ await saveSprint(sprint);
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Verify a repo is on the expected sprint branch before task execution.
181
+ * Attempts auto-recovery via checkout if on wrong branch.
182
+ *
183
+ * @returns true if on correct branch, false if recovery failed
184
+ */
185
+ export function verifySprintBranch(projectPath: string, expectedBranch: string): boolean {
186
+ try {
187
+ if (verifyCurrentBranch(projectPath, expectedBranch)) {
188
+ return true;
189
+ }
190
+
191
+ // Attempt auto-recovery
192
+ log.dim(` Branch mismatch in ${projectPath} — checking out '${expectedBranch}'`);
193
+ createAndCheckoutBranch(projectPath, expectedBranch);
194
+ return verifyCurrentBranch(projectPath, expectedBranch);
195
+ } catch {
196
+ return false;
197
+ }
198
+ }
199
+
200
+ // ============================================================================
201
+ // CHECK SCRIPT EXECUTION
202
+ // ============================================================================
203
+
204
+ /**
205
+ * Run checkScript for every unique projectPath that has remaining tasks.
206
+ *
207
+ * This is "stage zero" — the environment must be ready before any AI agent
208
+ * starts work (aligned with the Anthropic effective-harnesses article).
209
+ *
210
+ * Design notes:
211
+ * - Check tracking: timestamps recorded in sprint.checkRanAt so re-runs skip
212
+ * already-completed checks (idempotent resume). Use refreshCheck to force.
213
+ * - Fail-fast on multi-repo — partial setup is worse than no setup, so we abort
214
+ * on first failure rather than continuing with an inconsistent environment
215
+ * - Repos without a configured check script are skipped with a dim warning
216
+ * - Returns a CheckResults map so the executor can inform each AI agent what ran
217
+ *
218
+ * @returns { success, results } — results maps projectPath → CheckStatus
219
+ */
220
+ export async function runCheckScripts(
221
+ sprintId: string,
222
+ sprint: Sprint,
223
+ refreshCheck = false
224
+ ): Promise<{ success: true; results: CheckResults } | { success: false; error: string }> {
225
+ const results: CheckResults = new Map();
226
+ const tasks = await getTasks(sprintId);
227
+ const remainingTasks = tasks.filter((t) => t.status !== 'done');
228
+
229
+ // Collect unique project paths from remaining tasks
230
+ const uniquePaths = [...new Set(remainingTasks.map((t) => t.projectPath))];
231
+
232
+ if (uniquePaths.length === 0) {
233
+ return { success: true, results };
234
+ }
235
+
236
+ for (const projectPath of uniquePaths) {
237
+ // Find a representative task for this path so we can look up its project
238
+ const taskForPath = remainingTasks.find((t) => t.projectPath === projectPath);
239
+ if (!taskForPath) continue;
240
+
241
+ const project = await getProjectForTask(taskForPath, sprint);
242
+
243
+ // Check scripts come from explicit repo config only — no runtime auto-detection.
244
+ // Heuristic detection is used as suggestions during `project add` / `project repo add`.
245
+ const checkScript = getEffectiveCheckScript(project, projectPath);
246
+ const repo = project?.repositories.find((r) => r.path === projectPath);
247
+ const repoName = repo?.name ?? projectPath;
248
+
249
+ if (!checkScript) {
250
+ log.dim(` No check script for ${repoName} — configure via 'project add'`);
251
+ results.set(projectPath, { ran: false, reason: 'no-script' } satisfies CheckStatus);
252
+ continue;
253
+ }
254
+
255
+ // Check if already ran this sprint (skip unless --refresh-check)
256
+ const previousRun = sprint.checkRanAt[projectPath];
257
+ if (previousRun && !refreshCheck) {
258
+ log.dim(` Check already ran for ${repoName} at ${previousRun} — skipping`);
259
+ results.set(projectPath, { ran: true, script: checkScript } satisfies CheckStatus);
260
+ continue;
261
+ }
262
+
263
+ log.info(`\nRunning check for ${repoName}: ${checkScript}`);
264
+
265
+ const hookResult = runLifecycleHook(projectPath, checkScript, 'sprintStart');
266
+
267
+ if (!hookResult.passed) {
268
+ return {
269
+ success: false,
270
+ error: `Check failed for ${repoName}: ${checkScript}\n${hookResult.output}`,
271
+ };
272
+ }
273
+
274
+ // Record timestamp per-repo (persisted immediately so partial failures are safe)
275
+ sprint.checkRanAt[projectPath] = new Date().toISOString();
276
+ await saveSprint(sprint);
277
+
278
+ log.success(`Check complete: ${repoName}`);
279
+ results.set(projectPath, { ran: true, script: checkScript } satisfies CheckStatus);
280
+ }
281
+
282
+ return { success: true, results };
283
+ }
284
+
285
+ /**
286
+ * Determine if execution should use parallel mode.
287
+ * Forces sequential for session mode, step mode, or explicit --concurrency 1.
288
+ */
289
+ function shouldRunParallel(options: ExecutorOptions): boolean {
290
+ if (options.session) return false;
291
+ if (options.step) return false;
292
+ if (options.concurrency === 1) return false;
293
+ return true;
294
+ }
295
+
296
+ /**
297
+ * Run sprint execution with lifecycle management.
298
+ * Handles sprint activation, dependency reordering, execution, and closing.
299
+ */
300
+ export async function runSprint(
301
+ sprintId: string | undefined,
302
+ options: ExecutorOptions
303
+ ): Promise<ExecutionSummary | undefined> {
304
+ const id = await resolveSprintId(sprintId);
305
+ let sprint = await getSprint(id);
306
+
307
+ // Precondition: warn if draft sprint has unrefined tickets
308
+ if (sprint.status === 'draft' && !options.force) {
309
+ const unrefinedTickets = getPendingRequirements(sprint.tickets);
310
+ if (unrefinedTickets.length > 0) {
311
+ showWarning(
312
+ `Sprint has ${String(unrefinedTickets.length)} unrefined ticket${unrefinedTickets.length !== 1 ? 's' : ''}:`
313
+ );
314
+ for (const ticket of unrefinedTickets) {
315
+ log.item(`${formatTicketId(ticket)} \u2014 ${ticket.title}`);
316
+ }
317
+ log.newline();
318
+
319
+ const shouldContinue = await confirm({
320
+ message: 'Start anyway without refining?',
321
+ default: false,
322
+ });
323
+ if (!shouldContinue) {
324
+ log.dim("Run 'sprint refine' first, or use --force to skip this check.");
325
+ log.newline();
326
+ return undefined;
327
+ }
328
+ }
329
+ }
330
+
331
+ // Precondition: block activation if draft sprint has approved tickets without tasks
332
+ if (sprint.status === 'draft' && !options.force) {
333
+ const tasks = await getTasks(id);
334
+ const ticketIdsWithTasks = new Set(tasks.map((t) => t.ticketId).filter(Boolean));
335
+ const unplannedTickets = sprint.tickets.filter(
336
+ (t) => t.requirementStatus === 'approved' && !ticketIdsWithTasks.has(t.id)
337
+ );
338
+
339
+ if (unplannedTickets.length > 0) {
340
+ showWarning('Sprint has refined tickets with no planned tasks:');
341
+ for (const ticket of unplannedTickets) {
342
+ log.item(`${formatTicketId(ticket)} \u2014 ${ticket.title}`);
343
+ }
344
+ log.newline();
345
+
346
+ const shouldContinue = await confirm({
347
+ message: 'Start anyway without planning?',
348
+ default: false,
349
+ });
350
+ if (!shouldContinue) {
351
+ log.dim("Run 'sprint plan' first, or use --force to skip this check.");
352
+ log.newline();
353
+ return undefined;
354
+ }
355
+ }
356
+ }
357
+
358
+ // Resolve branch strategy before activation (prompt while still interactable)
359
+ const branchName = await resolveBranch(id, sprint, options);
360
+
361
+ // Auto-activate if sprint is draft
362
+ if (sprint.status === 'draft') {
363
+ sprint = await activateSprint(id);
364
+ }
365
+
366
+ // Validate sprint is active
367
+ assertSprintStatus(sprint, ['active'], 'start');
368
+
369
+ printHeader('Sprint Start');
370
+ log.info(`Sprint: ${sprint.name}`);
371
+ log.info(`ID: ${sprint.id}`);
372
+
373
+ const modes: string[] = [];
374
+ if (options.session) {
375
+ modes.push('session');
376
+ } else {
377
+ modes.push('headless');
378
+ }
379
+ if (options.step) {
380
+ modes.push('step-by-step');
381
+ }
382
+ if (options.noCommit) {
383
+ modes.push('no-commit');
384
+ }
385
+
386
+ const parallel = shouldRunParallel(options);
387
+ if (parallel) {
388
+ modes.push('parallel');
389
+ }
390
+ log.dim(`Mode: ${modes.join(', ')}`);
391
+ if (options.count) {
392
+ log.dim(`Limit: ${String(options.count)} task(s)`);
393
+ }
394
+
395
+ // Display branch info
396
+ if (branchName) {
397
+ log.info(`Branch: ${branchName}`);
398
+ }
399
+
400
+ // Ensure sprint branches are created/checked out in all repos
401
+ if (branchName) {
402
+ try {
403
+ await ensureSprintBranches(id, sprint, branchName);
404
+ } catch (err) {
405
+ log.newline();
406
+ showError(err instanceof Error ? err.message : String(err));
407
+ log.newline();
408
+ return undefined;
409
+ }
410
+ }
411
+
412
+ // Reorder tasks by dependencies
413
+ try {
414
+ await reorderByDependencies(id);
415
+ log.dim('Tasks reordered by dependencies');
416
+ } catch (err) {
417
+ if (err instanceof DependencyCycleError) {
418
+ log.newline();
419
+ showWarning(err.message);
420
+ log.dim('Fix the dependency cycle before starting.');
421
+ log.newline();
422
+ return undefined;
423
+ }
424
+ throw err;
425
+ }
426
+
427
+ // Stage zero: run check scripts for all repositories
428
+ const checkResult = await runCheckScripts(id, sprint, options.refreshCheck);
429
+ if (!checkResult.success) {
430
+ log.newline();
431
+ showError(checkResult.error);
432
+ log.newline();
433
+ return undefined;
434
+ }
435
+
436
+ // Execute the task loop (parallel or sequential)
437
+ const summary = parallel
438
+ ? await executeTaskLoopParallel(id, options, checkResult.results)
439
+ : await executeTaskLoop(id, options, checkResult.results);
440
+
441
+ // Print summary
442
+ printHeader('Summary');
443
+ log.info(`Completed: ${String(summary.completed)} task(s)`);
444
+ log.info(`Remaining: ${String(summary.remaining)} task(s)`);
445
+
446
+ // Handle sprint closing for fully completed sprints
447
+ if (await areAllTasksDone(id)) {
448
+ terminalBell();
449
+ showSuccess('All tasks in sprint are done!');
450
+ showRandomQuote();
451
+ const shouldClose = await confirm({
452
+ message: 'Close the sprint?',
453
+ default: true,
454
+ });
455
+ if (shouldClose) {
456
+ await closeSprint(id);
457
+ showSuccess(`Sprint closed: ${id}`);
458
+ }
459
+ } else if (summary.stopReason === 'all_blocked') {
460
+ log.newline();
461
+ showWarning('All remaining tasks are blocked by dependencies.');
462
+ const remaining = await getRemainingTasks(id);
463
+ const blockedTasks = remaining.filter((t) => t.blockedBy.length > 0);
464
+ if (blockedTasks.length > 0) {
465
+ log.dim('Blocked tasks:');
466
+ for (const t of blockedTasks.slice(0, 5)) {
467
+ log.item(`${t.name} (blocked by: ${t.blockedBy.join(', ')})`);
468
+ }
469
+ if (blockedTasks.length > 5) {
470
+ log.dim(` ... and ${String(blockedTasks.length - 5)} more`);
471
+ }
472
+ }
473
+ }
474
+
475
+ log.newline();
476
+
477
+ return summary;
478
+ }