@arvorco/relentless 0.2.0 → 0.3.1

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.
@@ -11,11 +11,29 @@ import type { AgentAdapter, AgentName } from "../agents/types";
11
11
  import { getAgent, getInstalledAgents } from "../agents/registry";
12
12
  import type { RelentlessConfig } from "../config/schema";
13
13
  import { loadConstitution, validateConstitution } from "../config/loader";
14
- import { loadPRD, getNextStory, isComplete, countStories } from "../prd";
14
+ import { loadPRD, getNextStory, isComplete, countStories, markStoryAsSkipped } from "../prd";
15
15
  import type { UserStory } from "../prd/types";
16
- import { loadProgress, updateProgressMetadata, syncPatternsFromContent } from "../prd/progress";
16
+ import { loadProgress, updateProgressMetadata, syncPatternsFromContent, appendProgress } from "../prd/progress";
17
17
  import { routeStory } from "./router";
18
18
  import { buildStoryPromptAddition } from "./story-prompt";
19
+ import { processQueue } from "../queue";
20
+ import type { QueueProcessResult } from "../queue/types";
21
+ import {
22
+ shouldPause,
23
+ executePauseAction,
24
+ logPauseToProgress,
25
+ formatPauseMessage,
26
+ shouldAbort,
27
+ logAbortToProgress,
28
+ formatAbortMessage,
29
+ generateAbortSummary,
30
+ shouldSkip,
31
+ getSkipCommands,
32
+ handleSkipCommand,
33
+ logSkipToProgress,
34
+ logSkipRejectedToProgress,
35
+ formatSkipMessage,
36
+ } from "./commands";
19
37
 
20
38
  export interface RunOptions {
21
39
  /** Agent to use (or "auto" for smart routing) */
@@ -55,6 +73,106 @@ interface AgentLimitState {
55
73
  detectedAt: Date;
56
74
  }
57
75
 
76
+ /**
77
+ * Process queue items for an iteration
78
+ *
79
+ * Reads pending queue items and processes them.
80
+ * This is called at the start of each iteration.
81
+ *
82
+ * @param featurePath - Path to the feature directory
83
+ * @returns QueueProcessResult with prompts, commands, and warnings
84
+ */
85
+ export async function processQueueForIteration(
86
+ featurePath: string
87
+ ): Promise<QueueProcessResult> {
88
+ return processQueue(featurePath);
89
+ }
90
+
91
+ /**
92
+ * Inject queue prompts into the agent prompt
93
+ *
94
+ * Adds a "Queued User Guidance" section to the prompt with
95
+ * numbered list of user messages from the queue.
96
+ *
97
+ * @param basePrompt - The original prompt
98
+ * @param prompts - Queue prompts to inject
99
+ * @returns Modified prompt with queue guidance section
100
+ */
101
+ export function injectQueuePrompts(basePrompt: string, prompts: string[]): string {
102
+ if (prompts.length === 0) {
103
+ return basePrompt;
104
+ }
105
+
106
+ const numberedList = prompts.map((p, i) => `${i + 1}. ${p}`).join("\n");
107
+
108
+ const queueSection = `
109
+
110
+ ## Queued User Guidance
111
+
112
+ The following messages were queued by the user during the run. Please incorporate this guidance into your work:
113
+
114
+ ${numberedList}
115
+
116
+ ---
117
+ `;
118
+
119
+ return basePrompt + queueSection;
120
+ }
121
+
122
+ /**
123
+ * Acknowledge queue processing in progress.txt
124
+ *
125
+ * Appends a note about processed queue items to the progress log.
126
+ *
127
+ * @param progressPath - Path to progress.txt
128
+ * @param prompts - Prompts that were processed
129
+ */
130
+ export async function acknowledgeQueueInProgress(
131
+ progressPath: string,
132
+ prompts: string[]
133
+ ): Promise<void> {
134
+ if (prompts.length === 0) {
135
+ return;
136
+ }
137
+
138
+ const timestamp = new Date().toISOString().split("T")[0];
139
+ const entry = `
140
+ ## Queue Processed - ${timestamp}
141
+
142
+ Acknowledged ${prompts.length} queued message(s):
143
+ ${prompts.map((p) => `- ${p}`).join("\n")}
144
+
145
+ ---
146
+ `;
147
+
148
+ await appendProgress(progressPath, entry);
149
+ }
150
+
151
+ /**
152
+ * Format queue log message for console output
153
+ *
154
+ * @param promptCount - Number of prompts in queue
155
+ * @param commandCount - Number of commands in queue
156
+ * @returns Formatted log message
157
+ */
158
+ export function formatQueueLogMessage(promptCount: number, commandCount: number): string {
159
+ const total = promptCount + commandCount;
160
+
161
+ if (total === 0) {
162
+ return "";
163
+ }
164
+
165
+ const itemWord = total === 1 ? "item" : "items";
166
+ let message = `Processing ${total} queue ${itemWord}...`;
167
+
168
+ if (commandCount > 0) {
169
+ const cmdWord = commandCount === 1 ? "command" : "commands";
170
+ message = `Processing ${total} queue ${itemWord} (${commandCount} ${cmdWord})...`;
171
+ }
172
+
173
+ return message;
174
+ }
175
+
58
176
  /**
59
177
  * Build the prompt for an iteration
60
178
  */
@@ -370,6 +488,109 @@ export async function run(options: RunOptions): Promise<RunResult> {
370
488
  console.log(chalk.bold(` Story: ${chalk.yellow(story.id)} - ${story.title}`));
371
489
  console.log(chalk.bold(`${"═".repeat(60)}\n`));
372
490
 
491
+ // Process queue at start of iteration
492
+ const featureDir = dirname(options.prdPath);
493
+ let queuePrompts: string[] = [];
494
+ try {
495
+ const queueResult = await processQueueForIteration(featureDir);
496
+ queuePrompts = queueResult.prompts;
497
+
498
+ // Log if there are queue items (silent for empty queue)
499
+ const logMessage = formatQueueLogMessage(queueResult.prompts.length, queueResult.commands.length);
500
+ if (logMessage) {
501
+ console.log(chalk.cyan(` 📬 ${logMessage}`));
502
+ }
503
+
504
+ // Log warnings if any
505
+ for (const warning of queueResult.warnings) {
506
+ console.log(chalk.yellow(` ⚠️ ${warning}`));
507
+ }
508
+
509
+ // Acknowledge in progress.txt
510
+ if (queueResult.prompts.length > 0 && existsSync(progressPath)) {
511
+ await acknowledgeQueueInProgress(progressPath, queueResult.prompts);
512
+ }
513
+
514
+ // Handle PAUSE command
515
+ if (shouldPause(queueResult.commands)) {
516
+ console.log(chalk.yellow(`\n ${formatPauseMessage()}`));
517
+
518
+ // Log pause to progress.txt
519
+ if (existsSync(progressPath)) {
520
+ await logPauseToProgress(progressPath);
521
+ }
522
+
523
+ // Wait for user input
524
+ await executePauseAction();
525
+ console.log(chalk.green(" ▶️ Resuming...\n"));
526
+ }
527
+
528
+ // Handle ABORT command
529
+ if (shouldAbort(queueResult.commands)) {
530
+ console.log(chalk.red(`\n ${formatAbortMessage()}`));
531
+
532
+ // Log abort to progress.txt
533
+ if (existsSync(progressPath)) {
534
+ await logAbortToProgress(progressPath);
535
+ }
536
+
537
+ // Calculate summary
538
+ const currentPRDForAbort = await loadPRD(options.prdPath);
539
+ const currentCount = countStories(currentPRDForAbort);
540
+ const duration = Date.now() - startTime;
541
+
542
+ // Show progress summary
543
+ const summary = generateAbortSummary({
544
+ storiesCompleted: currentCount.completed,
545
+ storiesTotal: currentCount.total,
546
+ iterations: i,
547
+ duration,
548
+ });
549
+ console.log(chalk.dim(summary));
550
+
551
+ // Return with success (clean exit)
552
+ return {
553
+ success: false, // Not all stories complete
554
+ iterations: i,
555
+ storiesCompleted: currentCount.completed - initialCount.completed,
556
+ duration,
557
+ };
558
+ }
559
+
560
+ // Handle SKIP command(s)
561
+ if (shouldSkip(queueResult.commands)) {
562
+ const skipCommands = getSkipCommands(queueResult.commands);
563
+ for (const skipCmd of skipCommands) {
564
+ // Check if trying to skip the current story in progress
565
+ const action = handleSkipCommand(skipCmd.storyId, story.id);
566
+
567
+ if (action.rejected) {
568
+ // Log rejection to console and progress.txt
569
+ console.log(chalk.yellow(`\n ${formatSkipMessage(action.storyId, true)}`));
570
+ if (existsSync(progressPath)) {
571
+ await logSkipRejectedToProgress(progressPath, action.storyId);
572
+ }
573
+ } else {
574
+ // Mark story as skipped in PRD
575
+ const skipResult = await markStoryAsSkipped(options.prdPath, action.storyId);
576
+
577
+ if (skipResult.success) {
578
+ console.log(chalk.cyan(`\n ${formatSkipMessage(action.storyId, false)}`));
579
+ if (existsSync(progressPath)) {
580
+ await logSkipToProgress(progressPath, action.storyId, action.customReason);
581
+ }
582
+ } else if (skipResult.error) {
583
+ console.log(chalk.yellow(`\n ⚠️ ${skipResult.error}`));
584
+ }
585
+ }
586
+ }
587
+ }
588
+
589
+ // TODO: Handle PRIORITY command in future story (US-012)
590
+ } catch (queueError) {
591
+ console.warn(chalk.yellow(` ⚠️ Queue processing error: ${queueError}`));
592
+ }
593
+
373
594
  if (options.dryRun) {
374
595
  console.log(chalk.dim(" [Dry run - skipping execution]"));
375
596
  continue;
@@ -391,7 +612,13 @@ export async function run(options: RunOptions): Promise<RunResult> {
391
612
  mkdirSync(researchDir, { recursive: true });
392
613
  }
393
614
 
394
- const researchPrompt = await buildPrompt(options.promptPath, options.workingDirectory, progressPath, story);
615
+ let researchPrompt = await buildPrompt(options.promptPath, options.workingDirectory, progressPath, story);
616
+
617
+ // Inject queue prompts into research phase too
618
+ if (queuePrompts.length > 0) {
619
+ researchPrompt = injectQueuePrompts(researchPrompt, queuePrompts);
620
+ }
621
+
395
622
  const researchResult = await agent.invoke(researchPrompt, {
396
623
  workingDirectory: options.workingDirectory,
397
624
  dangerouslyAllowAll: options.config.agents[agent.name]?.dangerouslyAllowAll ?? true,
@@ -430,7 +657,12 @@ export async function run(options: RunOptions): Promise<RunResult> {
430
657
  console.log(chalk.cyan(" 🔨 Implementation phase - applying research findings..."));
431
658
  }
432
659
 
433
- const prompt = await buildPrompt(options.promptPath, options.workingDirectory, progressPath, story);
660
+ let prompt = await buildPrompt(options.promptPath, options.workingDirectory, progressPath, story);
661
+
662
+ // Inject queue prompts if any
663
+ if (queuePrompts.length > 0) {
664
+ prompt = injectQueuePrompts(prompt, queuePrompts);
665
+ }
434
666
 
435
667
  if (!needsResearch) {
436
668
  console.log(chalk.dim(" Running agent..."));
package/src/prd/parser.ts CHANGED
@@ -279,3 +279,143 @@ export async function savePRD(prd: PRD, path: string): Promise<void> {
279
279
  const content = JSON.stringify(validated, null, 2);
280
280
  await Bun.write(path, content);
281
281
  }
282
+
283
+ /**
284
+ * Result of marking a story as skipped
285
+ */
286
+ export interface MarkSkippedResult {
287
+ success: boolean;
288
+ error?: string;
289
+ alreadySkipped?: boolean;
290
+ }
291
+
292
+ /**
293
+ * Result of prioritizing a story
294
+ */
295
+ export interface PrioritizeStoryResult {
296
+ success: boolean;
297
+ error?: string;
298
+ previousPriority?: number;
299
+ }
300
+
301
+ /**
302
+ * Mark a story as skipped in the PRD file
303
+ *
304
+ * @param prdPath - Path to the prd.json file
305
+ * @param storyId - The ID of the story to skip
306
+ * @returns Result indicating success or failure
307
+ */
308
+ export async function markStoryAsSkipped(
309
+ prdPath: string,
310
+ storyId: string
311
+ ): Promise<MarkSkippedResult> {
312
+ // Load current PRD
313
+ const prd = await loadPRD(prdPath);
314
+
315
+ // Find the story
316
+ const storyIndex = prd.userStories.findIndex((s) => s.id === storyId);
317
+ if (storyIndex === -1) {
318
+ return {
319
+ success: false,
320
+ error: `Story ${storyId} not found in PRD`,
321
+ };
322
+ }
323
+
324
+ const story = prd.userStories[storyIndex];
325
+
326
+ // Check if story is already completed
327
+ if (story.passes) {
328
+ return {
329
+ success: false,
330
+ error: `Story ${storyId} is already completed and cannot be skipped`,
331
+ };
332
+ }
333
+
334
+ // Check if already skipped
335
+ if (story.skipped) {
336
+ return {
337
+ success: true,
338
+ alreadySkipped: true,
339
+ };
340
+ }
341
+
342
+ // Mark as skipped
343
+ prd.userStories[storyIndex] = {
344
+ ...story,
345
+ skipped: true,
346
+ notes: story.notes
347
+ ? `${story.notes}\n[SKIPPED] ${new Date().toISOString().split("T")[0]}`
348
+ : `[SKIPPED] ${new Date().toISOString().split("T")[0]}`,
349
+ };
350
+
351
+ // Save the updated PRD
352
+ await savePRD(prd, prdPath);
353
+
354
+ return {
355
+ success: true,
356
+ };
357
+ }
358
+
359
+ /**
360
+ * Prioritize a story to be the next one worked on
361
+ *
362
+ * Sets the story's priority to 0 (highest priority) so it becomes
363
+ * the next story selected by getNextStory().
364
+ *
365
+ * @param prdPath - Path to the prd.json file
366
+ * @param storyId - The ID of the story to prioritize
367
+ * @returns Result indicating success or failure
368
+ */
369
+ export async function prioritizeStory(
370
+ prdPath: string,
371
+ storyId: string
372
+ ): Promise<PrioritizeStoryResult> {
373
+ // Load current PRD
374
+ const prd = await loadPRD(prdPath);
375
+
376
+ // Find the story
377
+ const storyIndex = prd.userStories.findIndex((s) => s.id === storyId);
378
+ if (storyIndex === -1) {
379
+ return {
380
+ success: false,
381
+ error: `Story ${storyId} not found in PRD`,
382
+ };
383
+ }
384
+
385
+ const story = prd.userStories[storyIndex];
386
+
387
+ // Check if story is already completed
388
+ if (story.passes) {
389
+ return {
390
+ success: false,
391
+ error: `Story ${storyId} is already completed and cannot be prioritized`,
392
+ };
393
+ }
394
+
395
+ // Check if story is skipped
396
+ if (story.skipped) {
397
+ return {
398
+ success: false,
399
+ error: `Story ${storyId} is skipped and cannot be prioritized`,
400
+ };
401
+ }
402
+
403
+ const previousPriority = story.priority;
404
+
405
+ // Set priority to 0 (highest priority)
406
+ prd.userStories[storyIndex] = {
407
+ ...story,
408
+ priority: 0,
409
+ notes: story.notes
410
+ ? `${story.notes}\n[PRIORITIZED] ${new Date().toISOString().split("T")[0]} (was priority ${previousPriority})`
411
+ : `[PRIORITIZED] ${new Date().toISOString().split("T")[0]} (was priority ${previousPriority})`,
412
+ };
413
+
414
+ // Save the updated PRD
415
+ await savePRD(prd, prdPath);
416
+
417
+ return {
418
+ success: true,
419
+ previousPriority,
420
+ };
421
+ }
package/src/prd/types.ts CHANGED
@@ -14,13 +14,14 @@ export const UserStorySchema = z.object({
14
14
  title: z.string(),
15
15
  description: z.string(),
16
16
  acceptanceCriteria: z.array(z.string()),
17
- priority: z.number().int().positive(),
17
+ priority: z.number().int().nonnegative(), // 0 = highest priority (used by PRIORITY command)
18
18
  passes: z.boolean().default(false),
19
19
  notes: z.string().default(""),
20
20
  dependencies: z.array(z.string()).optional(), // Array of story IDs this story depends on
21
21
  parallel: z.boolean().optional(), // Can be executed in parallel with other stories
22
22
  phase: z.string().optional(), // Phase marker (e.g., "Setup", "Foundation", "Stories", "Polish")
23
23
  research: z.boolean().optional(), // Requires research phase before implementation
24
+ skipped: z.boolean().optional(), // Whether the story was skipped by user command
24
25
  });
25
26
 
26
27
  export type UserStory = z.infer<typeof UserStorySchema>;
@@ -116,9 +117,9 @@ export function getNextStory(prd: PRD): UserStory | null {
116
117
  // Validate dependencies first
117
118
  validateDependencies(prd);
118
119
 
119
- // Find highest priority story where passes is false and dependencies are met
120
+ // Find highest priority story where passes is false, not skipped, and dependencies are met
120
121
  const pendingStories = prd.userStories
121
- .filter((s) => !s.passes && areDependenciesMet(s, prd))
122
+ .filter((s) => !s.passes && !s.skipped && areDependenciesMet(s, prd))
122
123
  .sort((a, b) => a.priority - b.priority);
123
124
 
124
125
  return pendingStories[0] ?? null;
@@ -134,11 +135,12 @@ export function isComplete(prd: PRD): boolean {
134
135
  /**
135
136
  * Count stories by status
136
137
  */
137
- export function countStories(prd: PRD): { total: number; completed: number; pending: number } {
138
+ export function countStories(prd: PRD): { total: number; completed: number; skipped: number; pending: number } {
138
139
  const total = prd.userStories.length;
139
140
  const completed = prd.userStories.filter((s) => s.passes).length;
140
- const pending = total - completed;
141
- return { total, completed, pending };
141
+ const skipped = prd.userStories.filter((s) => s.skipped && !s.passes).length;
142
+ const pending = total - completed - skipped;
143
+ return { total, completed, skipped, pending };
142
144
  }
143
145
 
144
146
  /**
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Queue Module
3
+ *
4
+ * Provides queue management for mid-run user input.
5
+ * Agents check the queue between iterations to receive
6
+ * user guidance and structured commands.
7
+ */
8
+
9
+ // Export types
10
+ export type {
11
+ QueueItem,
12
+ QueueState,
13
+ QueueCommandType,
14
+ QueueItemType,
15
+ QueueProcessResult,
16
+ ParsedCommand,
17
+ } from "./types";
18
+
19
+ export {
20
+ QueueItemSchema,
21
+ QueueStateSchema,
22
+ QueueCommandTypeSchema,
23
+ QueueItemTypeSchema,
24
+ } from "./types";
25
+
26
+ // Export parser functions
27
+ export { parseQueueLine, parseCommand, formatQueueLine } from "./parser";
28
+
29
+ // Export writer functions
30
+ export { addToQueue, removeFromQueue, clearQueue } from "./writer";
31
+
32
+ // Export loader functions
33
+ export { loadQueue } from "./loader";
34
+
35
+ // Export processor functions
36
+ export { processQueue } from "./processor";
37
+
38
+ // Export lock functions
39
+ export {
40
+ acquireQueueLock,
41
+ releaseQueueLock,
42
+ isQueueLocked,
43
+ setLockTimeout,
44
+ getLockTimeout,
45
+ } from "./lock";
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Queue Loader
3
+ *
4
+ * Functions for loading queue state from files.
5
+ * Handles pending and processed items with graceful error handling.
6
+ *
7
+ * Queue file locations:
8
+ * - Pending: <featurePath>/.queue.txt
9
+ * - Processed: <featurePath>/.queue.processed.txt
10
+ */
11
+
12
+ import { join } from "node:path";
13
+ import type { QueueItem, QueueState } from "./types";
14
+ import { parseQueueLine } from "./parser";
15
+
16
+ /** Pending queue file name */
17
+ const QUEUE_FILE = ".queue.txt";
18
+
19
+ /** Processed queue file name */
20
+ const QUEUE_PROCESSED_FILE = ".queue.processed.txt";
21
+
22
+ /**
23
+ * Load queue state from files
24
+ *
25
+ * @param featurePath - Path to the feature directory
26
+ * @returns QueueState with pending and processed items
27
+ */
28
+ export async function loadQueue(featurePath: string): Promise<QueueState> {
29
+ const pendingPath = join(featurePath, QUEUE_FILE);
30
+ const processedPath = join(featurePath, QUEUE_PROCESSED_FILE);
31
+
32
+ const warnings: string[] = [];
33
+
34
+ // Load pending items
35
+ const pending = await loadQueueFile(pendingPath, warnings);
36
+
37
+ // Load processed items
38
+ const processed = await loadQueueFile(processedPath, warnings);
39
+
40
+ // Build state
41
+ const state: QueueState = {
42
+ featurePath,
43
+ pending,
44
+ processed,
45
+ lastChecked: new Date().toISOString(),
46
+ };
47
+
48
+ // Only include warnings if there are any
49
+ if (warnings.length > 0) {
50
+ state.warnings = warnings;
51
+ }
52
+
53
+ return state;
54
+ }
55
+
56
+ /**
57
+ * Load items from a queue file
58
+ *
59
+ * @param filePath - Path to the queue file
60
+ * @param warnings - Array to collect warnings for malformed lines
61
+ * @returns Array of QueueItems
62
+ */
63
+ async function loadQueueFile(
64
+ filePath: string,
65
+ warnings: string[]
66
+ ): Promise<QueueItem[]> {
67
+ const file = Bun.file(filePath);
68
+
69
+ // Handle missing file gracefully
70
+ if (!(await file.exists())) {
71
+ return [];
72
+ }
73
+
74
+ const content = await file.text();
75
+ const lines = content.split("\n");
76
+ const items: QueueItem[] = [];
77
+
78
+ for (const line of lines) {
79
+ // Skip empty lines
80
+ const trimmed = line.trim();
81
+ if (!trimmed) {
82
+ continue;
83
+ }
84
+
85
+ // Try to parse the line
86
+ const item = parseQueueLine(trimmed);
87
+
88
+ if (item) {
89
+ items.push(item);
90
+ } else {
91
+ // Record warning for malformed line
92
+ warnings.push(`Skipped malformed line: ${trimmed}`);
93
+ }
94
+ }
95
+
96
+ return items;
97
+ }