@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.
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
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Queue Lock Manager
3
+ *
4
+ * Provides file-based locking for queue operations.
5
+ * Prevents concurrent writes and partial processing issues.
6
+ *
7
+ * Lock file location: <featurePath>/.queue.lock
8
+ */
9
+
10
+ import { join } from "node:path";
11
+ import { unlink } from "node:fs/promises";
12
+
13
+ /** Lock file name */
14
+ const LOCK_FILE = ".queue.lock";
15
+
16
+ /** Default lock timeout in milliseconds (5 seconds) */
17
+ let lockTimeout = 5000;
18
+
19
+ /**
20
+ * Set the lock timeout for testing purposes
21
+ *
22
+ * @param timeout - New timeout in milliseconds
23
+ */
24
+ export function setLockTimeout(timeout: number): void {
25
+ lockTimeout = timeout;
26
+ }
27
+
28
+ /**
29
+ * Get the current lock timeout
30
+ *
31
+ * @returns Current timeout in milliseconds
32
+ */
33
+ export function getLockTimeout(): number {
34
+ return lockTimeout;
35
+ }
36
+
37
+ /**
38
+ * Acquire a lock for queue operations
39
+ *
40
+ * @param featurePath - Path to the feature directory
41
+ * @returns true if lock acquired, false if already locked
42
+ */
43
+ export async function acquireQueueLock(featurePath: string): Promise<boolean> {
44
+ const lockPath = join(featurePath, LOCK_FILE);
45
+ const lockFile = Bun.file(lockPath);
46
+
47
+ // Check if lock exists and is not stale
48
+ if (await lockFile.exists()) {
49
+ const content = await lockFile.text();
50
+
51
+ try {
52
+ const lockData = JSON.parse(content);
53
+ const lockTime = new Date(lockData.timestamp).getTime();
54
+ const now = Date.now();
55
+
56
+ // Check if lock is stale (older than timeout)
57
+ if (now - lockTime < lockTimeout) {
58
+ // Lock is still valid
59
+ return false;
60
+ }
61
+ // Lock is stale, remove it
62
+ await releaseLockFile(lockPath);
63
+ } catch {
64
+ // Invalid lock file, remove it
65
+ await releaseLockFile(lockPath);
66
+ }
67
+ }
68
+
69
+ // Create new lock
70
+ const lockData = {
71
+ timestamp: new Date().toISOString(),
72
+ pid: process.pid,
73
+ };
74
+
75
+ try {
76
+ // Use exclusive flag to prevent race conditions
77
+ await Bun.write(lockPath, JSON.stringify(lockData));
78
+ return true;
79
+ } catch {
80
+ return false;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Release the queue lock
86
+ *
87
+ * @param featurePath - Path to the feature directory
88
+ */
89
+ export async function releaseQueueLock(featurePath: string): Promise<void> {
90
+ const lockPath = join(featurePath, LOCK_FILE);
91
+ await releaseLockFile(lockPath);
92
+ }
93
+
94
+ /**
95
+ * Check if the queue is currently locked
96
+ *
97
+ * @param featurePath - Path to the feature directory
98
+ * @returns true if locked and lock is not stale, false otherwise
99
+ */
100
+ export async function isQueueLocked(featurePath: string): Promise<boolean> {
101
+ const lockPath = join(featurePath, LOCK_FILE);
102
+ const lockFile = Bun.file(lockPath);
103
+
104
+ if (!(await lockFile.exists())) {
105
+ return false;
106
+ }
107
+
108
+ const content = await lockFile.text();
109
+
110
+ try {
111
+ const lockData = JSON.parse(content);
112
+ const lockTime = new Date(lockData.timestamp).getTime();
113
+ const now = Date.now();
114
+
115
+ // Check if lock is stale
116
+ if (now - lockTime >= lockTimeout) {
117
+ return false;
118
+ }
119
+
120
+ return true;
121
+ } catch {
122
+ return false;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Helper to safely remove a lock file
128
+ *
129
+ * @param lockPath - Path to the lock file
130
+ */
131
+ async function releaseLockFile(lockPath: string): Promise<void> {
132
+ try {
133
+ await unlink(lockPath);
134
+ } catch {
135
+ // Ignore if file doesn't exist
136
+ }
137
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Queue Parser
3
+ *
4
+ * Functions for parsing and formatting queue items.
5
+ * Handles line-based queue format with ISO timestamp prefix.
6
+ *
7
+ * Queue line format: `2026-01-13T10:30:00.000Z | content`
8
+ * Command format: `[COMMAND]` or `[COMMAND arg]`
9
+ */
10
+
11
+ import type { QueueItem, QueueCommandType, ParsedCommand } from "./types";
12
+
13
+ /**
14
+ * Valid command names (uppercase for matching)
15
+ */
16
+ const VALID_COMMANDS = ["PAUSE", "SKIP", "PRIORITY", "ABORT"] as const;
17
+
18
+ /**
19
+ * Commands that require a story ID argument
20
+ */
21
+ const COMMANDS_WITH_STORY_ID = ["SKIP", "PRIORITY"] as const;
22
+
23
+ /**
24
+ * Regex patterns for parsing
25
+ */
26
+ const QUEUE_LINE_PATTERN = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)\s*\|\s*(.+)$/;
27
+ const COMMAND_PATTERN = /^\[\s*([A-Za-z]+)(?:\s+([^\]]+))?\s*\]$/;
28
+ const PROCESSED_AT_PATTERN = /\s*\|\s*processedAt:(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)$/;
29
+
30
+ /**
31
+ * Parse a single queue line into a QueueItem
32
+ *
33
+ * @param line - The raw line from the queue file
34
+ * @returns QueueItem if valid, null if malformed
35
+ */
36
+ export function parseQueueLine(line: string): QueueItem | null {
37
+ // Handle empty or whitespace-only lines
38
+ const trimmedLine = line.trim();
39
+ if (!trimmedLine) {
40
+ return null;
41
+ }
42
+
43
+ // Match the queue line pattern: timestamp | content
44
+ const match = trimmedLine.match(QUEUE_LINE_PATTERN);
45
+ if (!match) {
46
+ return null;
47
+ }
48
+
49
+ const [, timestamp, rawContent] = match;
50
+ let trimmedContent = rawContent.trim();
51
+ let processedAt: string | undefined;
52
+
53
+ // Check for processedAt suffix (from processed queue file)
54
+ const processedMatch = trimmedContent.match(PROCESSED_AT_PATTERN);
55
+ if (processedMatch) {
56
+ processedAt = processedMatch[1];
57
+ // Remove the processedAt suffix from content
58
+ trimmedContent = trimmedContent.replace(PROCESSED_AT_PATTERN, "").trim();
59
+ }
60
+
61
+ // Generate unique ID from timestamp
62
+ const id = `${timestamp.replace(/[:.]/g, "-")}-${Date.now() % 1000}`;
63
+
64
+ // Check if content is a command
65
+ const parsedCommand = parseCommand(trimmedContent);
66
+
67
+ if (parsedCommand) {
68
+ const item: QueueItem = {
69
+ id,
70
+ content: trimmedContent,
71
+ type: "command",
72
+ command: parsedCommand.type,
73
+ targetStoryId: parsedCommand.storyId,
74
+ addedAt: timestamp,
75
+ };
76
+ if (processedAt) {
77
+ item.processedAt = processedAt;
78
+ }
79
+ return item;
80
+ }
81
+
82
+ // Regular prompt
83
+ const item: QueueItem = {
84
+ id,
85
+ content: trimmedContent,
86
+ type: "prompt",
87
+ addedAt: timestamp,
88
+ };
89
+ if (processedAt) {
90
+ item.processedAt = processedAt;
91
+ }
92
+ return item;
93
+ }
94
+
95
+ /**
96
+ * Parse command content into a ParsedCommand
97
+ *
98
+ * @param content - The content string (e.g., "[PAUSE]" or "[SKIP US-003]")
99
+ * @returns ParsedCommand if valid command, null otherwise
100
+ */
101
+ export function parseCommand(content: string): ParsedCommand | null {
102
+ const trimmedContent = content.trim();
103
+
104
+ // Match command pattern: [COMMAND] or [COMMAND arg]
105
+ const match = trimmedContent.match(COMMAND_PATTERN);
106
+ if (!match) {
107
+ return null;
108
+ }
109
+
110
+ const [, command, arg] = match;
111
+ const upperCommand = command.toUpperCase();
112
+
113
+ // Validate command name
114
+ if (!VALID_COMMANDS.includes(upperCommand as (typeof VALID_COMMANDS)[number])) {
115
+ return null;
116
+ }
117
+
118
+ const commandType = upperCommand as QueueCommandType;
119
+
120
+ // Check if command requires a story ID
121
+ if (COMMANDS_WITH_STORY_ID.includes(commandType as (typeof COMMANDS_WITH_STORY_ID)[number])) {
122
+ if (!arg) {
123
+ return null;
124
+ }
125
+ // Uppercase the story ID for consistency (e.g., "us-003" -> "US-003")
126
+ const storyId = arg.trim().toUpperCase();
127
+ return { type: commandType, storyId };
128
+ }
129
+
130
+ // Commands without arguments
131
+ return { type: commandType };
132
+ }
133
+
134
+ /**
135
+ * Format a QueueItem back to a queue line string
136
+ *
137
+ * @param item - The queue item to format
138
+ * @returns Formatted queue line string
139
+ */
140
+ export function formatQueueLine(item: Pick<QueueItem, "addedAt" | "content">): string {
141
+ return `${item.addedAt} | ${item.content}`;
142
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Queue Processor
3
+ *
4
+ * Processes pending queue items for orchestration.
5
+ * Separates text prompts from structured commands.
6
+ * Moves processed items to .queue.processed.txt with timestamp.
7
+ *
8
+ * Queue file locations:
9
+ * - Pending: <featurePath>/.queue.txt
10
+ * - Processed: <featurePath>/.queue.processed.txt
11
+ */
12
+
13
+ import { join } from "node:path";
14
+ import { rename } from "node:fs/promises";
15
+ import type { QueueItem, QueueProcessResult, QueueCommandType } from "./types";
16
+ import { parseQueueLine, formatQueueLine } from "./parser";
17
+
18
+ /** Pending queue file name */
19
+ const QUEUE_FILE = ".queue.txt";
20
+
21
+ /** Processed queue file name */
22
+ const QUEUE_PROCESSED_FILE = ".queue.processed.txt";
23
+
24
+ /**
25
+ * Process all pending queue items
26
+ *
27
+ * @param featurePath - Path to the feature directory
28
+ * @returns QueueProcessResult with prompts, commands, and warnings
29
+ */
30
+ export async function processQueue(
31
+ featurePath: string
32
+ ): Promise<QueueProcessResult> {
33
+ const queuePath = join(featurePath, QUEUE_FILE);
34
+ const processedPath = join(featurePath, QUEUE_PROCESSED_FILE);
35
+
36
+ const result: QueueProcessResult = {
37
+ prompts: [],
38
+ commands: [],
39
+ warnings: [],
40
+ };
41
+
42
+ // Check if queue file exists
43
+ const queueFile = Bun.file(queuePath);
44
+ if (!(await queueFile.exists())) {
45
+ return result;
46
+ }
47
+
48
+ // Read queue content
49
+ const content = await queueFile.text();
50
+ const lines = content.split("\n");
51
+
52
+ // Parse all valid items
53
+ const items: QueueItem[] = [];
54
+
55
+ for (const line of lines) {
56
+ const trimmed = line.trim();
57
+
58
+ // Skip empty lines silently
59
+ if (!trimmed) {
60
+ continue;
61
+ }
62
+
63
+ const item = parseQueueLine(trimmed);
64
+
65
+ if (item) {
66
+ items.push(item);
67
+ } else {
68
+ // Record warning for malformed lines
69
+ result.warnings.push(`Skipped malformed line: ${trimmed}`);
70
+ }
71
+ }
72
+
73
+ // If no items to process, return early
74
+ if (items.length === 0) {
75
+ return result;
76
+ }
77
+
78
+ // Process items and separate prompts from commands
79
+ const processedAt = new Date().toISOString();
80
+
81
+ for (const item of items) {
82
+ // Add processedAt timestamp
83
+ item.processedAt = processedAt;
84
+
85
+ if (item.type === "command" && item.command) {
86
+ // Structured command
87
+ const command: { type: QueueCommandType; storyId?: string } = {
88
+ type: item.command,
89
+ };
90
+
91
+ if (item.targetStoryId) {
92
+ command.storyId = item.targetStoryId;
93
+ }
94
+
95
+ result.commands.push(command);
96
+ } else {
97
+ // Text prompt - trim any trailing whitespace
98
+ result.prompts.push(item.content.trim());
99
+ }
100
+ }
101
+
102
+ // Move processed items to .queue.processed.txt
103
+ await appendToProcessedFile(processedPath, items);
104
+
105
+ // Clear the queue file (atomic write)
106
+ const tempPath = `${queuePath}.tmp`;
107
+ await Bun.write(tempPath, "");
108
+ await rename(tempPath, queuePath);
109
+
110
+ return result;
111
+ }
112
+
113
+ /**
114
+ * Append processed items to the processed queue file
115
+ *
116
+ * @param processedPath - Path to the processed queue file
117
+ * @param items - Items to append with processedAt timestamps
118
+ */
119
+ async function appendToProcessedFile(
120
+ processedPath: string,
121
+ items: QueueItem[]
122
+ ): Promise<void> {
123
+ // Read existing content (if any)
124
+ let existingContent = "";
125
+ const processedFile = Bun.file(processedPath);
126
+ if (await processedFile.exists()) {
127
+ existingContent = await processedFile.text();
128
+ }
129
+
130
+ // Format new lines with processedAt marker
131
+ const newLines = items.map((item) => {
132
+ const baseLine = formatQueueLine(item);
133
+ return `${baseLine} | processedAt:${item.processedAt}`;
134
+ });
135
+
136
+ // Combine and write atomically
137
+ const newContent = existingContent + newLines.join("\n") + "\n";
138
+ const tempPath = `${processedPath}.tmp`;
139
+ await Bun.write(tempPath, newContent);
140
+ await rename(tempPath, processedPath);
141
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Queue Types
3
+ *
4
+ * Defines Zod schemas and TypeScript types for the queue system.
5
+ * Used for validating queue items, commands, and state.
6
+ */
7
+
8
+ import { z } from "zod";
9
+
10
+ /**
11
+ * Valid queue command types
12
+ */
13
+ export const QueueCommandTypeSchema = z.enum(["PAUSE", "SKIP", "PRIORITY", "ABORT"]);
14
+ export type QueueCommandType = z.infer<typeof QueueCommandTypeSchema>;
15
+
16
+ /**
17
+ * Queue item type - either a text prompt or a structured command
18
+ */
19
+ export const QueueItemTypeSchema = z.enum(["prompt", "command"]);
20
+ export type QueueItemType = z.infer<typeof QueueItemTypeSchema>;
21
+
22
+ /**
23
+ * A single queue item
24
+ */
25
+ export const QueueItemSchema = z.object({
26
+ /** Unique ID (timestamp-based) */
27
+ id: z.string(),
28
+ /** Raw content from file */
29
+ content: z.string(),
30
+ /** Type of item */
31
+ type: QueueItemTypeSchema,
32
+ /** Parsed command type (if type is "command") */
33
+ command: QueueCommandTypeSchema.optional(),
34
+ /** Target story ID (for SKIP/PRIORITY) */
35
+ targetStoryId: z.string().optional(),
36
+ /** When item was added (ISO timestamp) */
37
+ addedAt: z.string().datetime(),
38
+ /** When item was processed (ISO timestamp, null if pending) */
39
+ processedAt: z.string().datetime().optional(),
40
+ });
41
+ export type QueueItem = z.infer<typeof QueueItemSchema>;
42
+
43
+ /**
44
+ * Queue state for a feature
45
+ */
46
+ export const QueueStateSchema = z.object({
47
+ /** Path to feature directory */
48
+ featurePath: z.string(),
49
+ /** Pending items */
50
+ pending: z.array(QueueItemSchema),
51
+ /** Processed items (audit trail) */
52
+ processed: z.array(QueueItemSchema),
53
+ /** Last time queue was checked (ISO timestamp) */
54
+ lastChecked: z.string().datetime().optional(),
55
+ /** Warnings from loading (e.g., malformed lines) */
56
+ warnings: z.array(z.string()).optional(),
57
+ });
58
+ export type QueueState = z.infer<typeof QueueStateSchema>;
59
+
60
+ /**
61
+ * Result of processing queue commands
62
+ */
63
+ export interface QueueProcessResult {
64
+ /** Text prompts to inject into agent context */
65
+ prompts: string[];
66
+ /** Commands to execute */
67
+ commands: Array<{
68
+ type: QueueCommandType;
69
+ storyId?: string;
70
+ }>;
71
+ /** Warnings (e.g., unrecognized commands treated as prompts) */
72
+ warnings: string[];
73
+ }
74
+
75
+ /**
76
+ * Parsed command result from parseCommand
77
+ */
78
+ export interface ParsedCommand {
79
+ type: QueueCommandType;
80
+ storyId?: string;
81
+ }