@arvorco/relentless 0.1.27 → 0.3.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.
@@ -0,0 +1,541 @@
1
+ /**
2
+ * Queue Command Handlers
3
+ *
4
+ * Handles structured commands from the queue system.
5
+ * Implements PAUSE, ABORT, SKIP, and PRIORITY command execution.
6
+ */
7
+
8
+ import type { QueueCommandType } from "../queue/types";
9
+ import { appendProgress } from "../prd/progress";
10
+
11
+ /**
12
+ * Command action types
13
+ */
14
+ export type CommandActionType = "pause" | "abort" | "skip" | "priority" | "none";
15
+
16
+ /**
17
+ * Pause action returned by handlePauseCommand
18
+ */
19
+ export interface PauseAction {
20
+ type: "pause";
21
+ message: string;
22
+ reason?: string;
23
+ }
24
+
25
+ /**
26
+ * Abort action returned by handleAbortCommand
27
+ */
28
+ export interface AbortAction {
29
+ type: "abort";
30
+ reason: string;
31
+ exitCode: number;
32
+ }
33
+
34
+ /**
35
+ * Progress summary for abort message
36
+ */
37
+ export interface AbortProgressSummary {
38
+ storiesCompleted: number;
39
+ storiesTotal: number;
40
+ iterations: number;
41
+ duration: number;
42
+ }
43
+
44
+ /**
45
+ * Result of executing a pause action
46
+ */
47
+ export interface PauseResult {
48
+ resumed: boolean;
49
+ }
50
+
51
+ /**
52
+ * Input function type for pause action
53
+ * Used for dependency injection in tests
54
+ */
55
+ export type InputFunction = () => Promise<string>;
56
+
57
+ /**
58
+ * Check if any PAUSE command exists in the command list
59
+ *
60
+ * @param commands - List of commands from queue processing
61
+ * @returns true if PAUSE command is present
62
+ */
63
+ export function shouldPause(
64
+ commands: Array<{ type: QueueCommandType; storyId?: string }>
65
+ ): boolean {
66
+ return commands.some((cmd) => cmd.type === "PAUSE");
67
+ }
68
+
69
+ /**
70
+ * Handle PAUSE command - creates action object
71
+ *
72
+ * @param reason - Optional custom reason for the pause
73
+ * @returns PauseAction object
74
+ */
75
+ export function handlePauseCommand(reason?: string): PauseAction {
76
+ const baseMessage = "Paused by user. Press Enter to continue...";
77
+ const message = reason ? `${reason}\n${baseMessage}` : baseMessage;
78
+
79
+ return {
80
+ type: "pause",
81
+ message,
82
+ reason,
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Execute pause action - wait for user input
88
+ *
89
+ * In normal execution, this reads from stdin.
90
+ * In test mode, pass a mock input function.
91
+ *
92
+ * @param inputFn - Optional input function for testing
93
+ * @returns PauseResult with resumed status
94
+ */
95
+ export async function executePauseAction(
96
+ inputFn?: InputFunction
97
+ ): Promise<PauseResult> {
98
+ if (inputFn) {
99
+ // Test mode - use provided input function
100
+ await inputFn();
101
+ return { resumed: true };
102
+ }
103
+
104
+ // Normal mode - wait for Enter key from stdin
105
+ return new Promise((resolve) => {
106
+ const stdin = process.stdin;
107
+ stdin.setRawMode?.(false);
108
+ stdin.resume();
109
+
110
+ const onData = () => {
111
+ stdin.pause();
112
+ stdin.removeListener("data", onData);
113
+ resolve({ resumed: true });
114
+ };
115
+
116
+ stdin.once("data", onData);
117
+ });
118
+ }
119
+
120
+ /**
121
+ * Log pause event to progress.txt
122
+ *
123
+ * @param progressPath - Path to progress.txt file
124
+ */
125
+ export async function logPauseToProgress(progressPath: string): Promise<void> {
126
+ const timestamp = new Date().toISOString().split("T")[0];
127
+ const entry = `
128
+ ## Pause Event - ${timestamp}
129
+
130
+ User requested pause via [PAUSE] command.
131
+ Orchestrator paused and waited for user confirmation to continue.
132
+
133
+ ---
134
+ `;
135
+
136
+ await appendProgress(progressPath, entry);
137
+ }
138
+
139
+ /**
140
+ * Format pause message for display
141
+ *
142
+ * @param tuiMode - Whether to format for TUI display
143
+ * @returns Formatted pause message
144
+ */
145
+ export function formatPauseMessage(tuiMode = false): string {
146
+ if (tuiMode) {
147
+ return "⏸️ Paused by user. Press any key to continue...";
148
+ }
149
+ return "⏸️ Paused by user. Press Enter to continue...";
150
+ }
151
+
152
+ // ============================================================================
153
+ // ABORT Command Functions
154
+ // ============================================================================
155
+
156
+ /**
157
+ * Check if any ABORT command exists in the command list
158
+ *
159
+ * @param commands - List of commands from queue processing
160
+ * @returns true if ABORT command is present
161
+ */
162
+ export function shouldAbort(
163
+ commands: Array<{ type: QueueCommandType; storyId?: string }>
164
+ ): boolean {
165
+ return commands.some((cmd) => cmd.type === "ABORT");
166
+ }
167
+
168
+ /**
169
+ * Handle ABORT command - creates action object
170
+ *
171
+ * @param reason - Optional custom reason for the abort
172
+ * @returns AbortAction object
173
+ */
174
+ export function handleAbortCommand(reason?: string): AbortAction {
175
+ return {
176
+ type: "abort",
177
+ reason: reason ?? "User requested abort via [ABORT] command",
178
+ exitCode: 0, // Clean exit, not an error
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Log abort event to progress.txt
184
+ *
185
+ * @param progressPath - Path to progress.txt file
186
+ * @param reason - Optional custom reason for the abort
187
+ */
188
+ export async function logAbortToProgress(
189
+ progressPath: string,
190
+ reason?: string
191
+ ): Promise<void> {
192
+ const timestamp = new Date().toISOString().split("T")[0];
193
+ const reasonText = reason ?? "User requested abort via [ABORT] command";
194
+ const entry = `
195
+ ## Abort Event - ${timestamp}
196
+
197
+ ${reasonText}
198
+ Orchestrator stopped cleanly with exit code 0.
199
+
200
+ ---
201
+ `;
202
+
203
+ await appendProgress(progressPath, entry);
204
+ }
205
+
206
+ /**
207
+ * Format abort message for display
208
+ *
209
+ * @param tuiMode - Whether to format for TUI display
210
+ * @returns Formatted abort message
211
+ */
212
+ export function formatAbortMessage(tuiMode = false): string {
213
+ if (tuiMode) {
214
+ return "🛑 Aborted by user.";
215
+ }
216
+ return "🛑 Aborted by user.";
217
+ }
218
+
219
+ /**
220
+ * Generate progress summary for abort
221
+ *
222
+ * @param summary - Progress summary data
223
+ * @returns Formatted summary string
224
+ */
225
+ export function generateAbortSummary(summary: AbortProgressSummary): string {
226
+ const { storiesCompleted, storiesTotal, iterations, duration } = summary;
227
+
228
+ // Format duration
229
+ const seconds = Math.floor(duration / 1000);
230
+ const minutes = Math.floor(seconds / 60);
231
+ const remainingSeconds = seconds % 60;
232
+ const durationStr =
233
+ minutes > 0 ? `${minutes}m ${remainingSeconds}s` : `${seconds}s`;
234
+
235
+ return `
236
+ Progress Summary:
237
+ Stories: ${storiesCompleted}/${storiesTotal} complete
238
+ Iterations: ${iterations}
239
+ Duration: ${durationStr}
240
+ `;
241
+ }
242
+
243
+ // ============================================================================
244
+ // SKIP Command Functions
245
+ // ============================================================================
246
+
247
+ /**
248
+ * Skip action returned by handleSkipCommand
249
+ */
250
+ export interface SkipAction {
251
+ type: "skip";
252
+ storyId: string;
253
+ rejected: boolean;
254
+ reason?: string;
255
+ customReason?: string;
256
+ }
257
+
258
+ /**
259
+ * Check if any SKIP command exists in the command list
260
+ *
261
+ * @param commands - List of commands from queue processing
262
+ * @returns true if SKIP command is present
263
+ */
264
+ export function shouldSkip(
265
+ commands: Array<{ type: QueueCommandType; storyId?: string }>
266
+ ): boolean {
267
+ return commands.some((cmd) => cmd.type === "SKIP");
268
+ }
269
+
270
+ /**
271
+ * Get all SKIP commands from the command list
272
+ *
273
+ * @param commands - List of commands from queue processing
274
+ * @returns Array of SKIP commands with story IDs
275
+ */
276
+ export function getSkipCommands(
277
+ commands: Array<{ type: QueueCommandType; storyId?: string }>
278
+ ): Array<{ type: "SKIP"; storyId: string }> {
279
+ return commands
280
+ .filter((cmd): cmd is { type: "SKIP"; storyId: string } =>
281
+ cmd.type === "SKIP" && cmd.storyId !== undefined
282
+ );
283
+ }
284
+
285
+ /**
286
+ * Handle SKIP command - creates action object
287
+ *
288
+ * Checks if the story is currently in progress. If so, rejects the skip.
289
+ *
290
+ * @param storyId - The story ID to skip
291
+ * @param currentStoryId - The story currently in progress (or null if none)
292
+ * @param customReason - Optional custom reason for the skip
293
+ * @returns SkipAction object
294
+ */
295
+ export function handleSkipCommand(
296
+ storyId: string,
297
+ currentStoryId: string | null,
298
+ customReason?: string
299
+ ): SkipAction {
300
+ // Check if trying to skip the story currently in progress
301
+ if (currentStoryId && storyId === currentStoryId) {
302
+ return {
303
+ type: "skip",
304
+ storyId,
305
+ rejected: true,
306
+ reason: `Cannot skip ${storyId}: story is currently in progress. Wait for iteration to complete.`,
307
+ };
308
+ }
309
+
310
+ return {
311
+ type: "skip",
312
+ storyId,
313
+ rejected: false,
314
+ customReason,
315
+ };
316
+ }
317
+
318
+ /**
319
+ * Log skip event to progress.txt
320
+ *
321
+ * @param progressPath - Path to progress.txt file
322
+ * @param storyId - The story ID that was skipped
323
+ * @param reason - Optional custom reason for the skip
324
+ */
325
+ export async function logSkipToProgress(
326
+ progressPath: string,
327
+ storyId: string,
328
+ reason?: string
329
+ ): Promise<void> {
330
+ const timestamp = new Date().toISOString().split("T")[0];
331
+ const reasonText = reason ?? "User requested skip via [SKIP] command";
332
+ const entry = `
333
+ ## Skip Event - ${timestamp}
334
+
335
+ Story ${storyId} was skipped.
336
+ ${reasonText}
337
+
338
+ ---
339
+ `;
340
+
341
+ await appendProgress(progressPath, entry);
342
+ }
343
+
344
+ /**
345
+ * Log rejected skip event to progress.txt
346
+ *
347
+ * @param progressPath - Path to progress.txt file
348
+ * @param storyId - The story ID that was attempted to skip
349
+ */
350
+ export async function logSkipRejectedToProgress(
351
+ progressPath: string,
352
+ storyId: string
353
+ ): Promise<void> {
354
+ const timestamp = new Date().toISOString().split("T")[0];
355
+ const entry = `
356
+ ## Skip Rejected - ${timestamp}
357
+
358
+ Attempted to skip ${storyId} but story is currently in progress.
359
+ Skip command was ignored. Wait for the current iteration to complete.
360
+
361
+ ---
362
+ `;
363
+
364
+ await appendProgress(progressPath, entry);
365
+ }
366
+
367
+ /**
368
+ * Format skip message for display
369
+ *
370
+ * @param storyId - The story ID being skipped
371
+ * @param rejected - Whether the skip was rejected
372
+ * @param tuiMode - Whether to format for TUI display
373
+ * @returns Formatted skip message
374
+ */
375
+ export function formatSkipMessage(
376
+ storyId: string,
377
+ rejected: boolean,
378
+ tuiMode = false
379
+ ): string {
380
+ if (rejected) {
381
+ if (tuiMode) {
382
+ return `⚠️ Cannot skip ${storyId}: story is currently in progress`;
383
+ }
384
+ return `⚠️ Cannot skip ${storyId}: story is currently in progress. Wait for iteration to complete.`;
385
+ }
386
+
387
+ if (tuiMode) {
388
+ return `⏭️ Skipped ${storyId}`;
389
+ }
390
+ return `⏭️ Skipped ${storyId}`;
391
+ }
392
+
393
+ // ============================================================================
394
+ // PRIORITY Command Functions
395
+ // ============================================================================
396
+
397
+ /**
398
+ * Priority action returned by handlePriorityCommand
399
+ */
400
+ export interface PriorityAction {
401
+ type: "priority";
402
+ storyId: string;
403
+ isCurrentStory: boolean;
404
+ message?: string;
405
+ customReason?: string;
406
+ }
407
+
408
+ /**
409
+ * Check if any PRIORITY command exists in the command list
410
+ *
411
+ * @param commands - List of commands from queue processing
412
+ * @returns true if PRIORITY command is present
413
+ */
414
+ export function shouldPrioritize(
415
+ commands: Array<{ type: QueueCommandType; storyId?: string }>
416
+ ): boolean {
417
+ return commands.some((cmd) => cmd.type === "PRIORITY");
418
+ }
419
+
420
+ /**
421
+ * Get all PRIORITY commands from the command list
422
+ *
423
+ * @param commands - List of commands from queue processing
424
+ * @returns Array of PRIORITY commands with story IDs
425
+ */
426
+ export function getPriorityCommands(
427
+ commands: Array<{ type: QueueCommandType; storyId?: string }>
428
+ ): Array<{ type: "PRIORITY"; storyId: string }> {
429
+ return commands.filter(
430
+ (cmd): cmd is { type: "PRIORITY"; storyId: string } =>
431
+ cmd.type === "PRIORITY" && cmd.storyId !== undefined
432
+ );
433
+ }
434
+
435
+ /**
436
+ * Handle PRIORITY command - creates action object
437
+ *
438
+ * Checks if the story is the currently executing story. If so, shows an info message.
439
+ *
440
+ * @param storyId - The story ID to prioritize
441
+ * @param currentStoryId - The story currently in progress (or null if none)
442
+ * @param customReason - Optional custom reason for the priority change
443
+ * @returns PriorityAction object
444
+ */
445
+ export function handlePriorityCommand(
446
+ storyId: string,
447
+ currentStoryId: string | null,
448
+ customReason?: string
449
+ ): PriorityAction {
450
+ // Check if trying to prioritize the story currently in progress
451
+ if (currentStoryId && storyId === currentStoryId) {
452
+ return {
453
+ type: "priority",
454
+ storyId,
455
+ isCurrentStory: true,
456
+ message: `Story ${storyId} is already in progress`,
457
+ };
458
+ }
459
+
460
+ return {
461
+ type: "priority",
462
+ storyId,
463
+ isCurrentStory: false,
464
+ customReason,
465
+ };
466
+ }
467
+
468
+ /**
469
+ * Log priority event to progress.txt
470
+ *
471
+ * @param progressPath - Path to progress.txt file
472
+ * @param storyId - The story ID that was prioritized
473
+ * @param reason - Optional custom reason for the priority change
474
+ */
475
+ export async function logPriorityToProgress(
476
+ progressPath: string,
477
+ storyId: string,
478
+ reason?: string
479
+ ): Promise<void> {
480
+ const timestamp = new Date().toISOString().split("T")[0];
481
+ const reasonText = reason ?? "User requested priority via [PRIORITY] command";
482
+ const entry = `
483
+ ## Priority Change - ${timestamp}
484
+
485
+ Story ${storyId} was prioritized to be next.
486
+ ${reasonText}
487
+
488
+ ---
489
+ `;
490
+
491
+ await appendProgress(progressPath, entry);
492
+ }
493
+
494
+ /**
495
+ * Log priority info event to progress.txt (when story is already current)
496
+ *
497
+ * @param progressPath - Path to progress.txt file
498
+ * @param storyId - The story ID that was attempted to prioritize
499
+ */
500
+ export async function logPriorityInfoToProgress(
501
+ progressPath: string,
502
+ storyId: string
503
+ ): Promise<void> {
504
+ const timestamp = new Date().toISOString().split("T")[0];
505
+ const entry = `
506
+ ## Priority Info - ${timestamp}
507
+
508
+ Attempted to prioritize ${storyId} but story is already in progress.
509
+ Execution continues normally.
510
+
511
+ ---
512
+ `;
513
+
514
+ await appendProgress(progressPath, entry);
515
+ }
516
+
517
+ /**
518
+ * Format priority message for display
519
+ *
520
+ * @param storyId - The story ID being prioritized
521
+ * @param isCurrentStory - Whether the story is the current one
522
+ * @param tuiMode - Whether to format for TUI display
523
+ * @returns Formatted priority message
524
+ */
525
+ export function formatPriorityMessage(
526
+ storyId: string,
527
+ isCurrentStory: boolean,
528
+ tuiMode = false
529
+ ): string {
530
+ if (isCurrentStory) {
531
+ if (tuiMode) {
532
+ return `ℹ️ ${storyId} is already in progress`;
533
+ }
534
+ return `ℹ️ Story ${storyId} is already in progress`;
535
+ }
536
+
537
+ if (tuiMode) {
538
+ return `⬆️ Prioritized ${storyId}`;
539
+ }
540
+ return `⬆️ Prioritized ${storyId}`;
541
+ }