@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/CHANGELOG.md +95 -1
- package/README.md +205 -983
- package/Relentless.png +0 -0
- package/Relentless.svg +1 -0
- package/bin/relentless.ts +121 -0
- package/package.json +1 -1
- package/src/agents/amp.ts +26 -13
- package/src/agents/codex.ts +35 -1
- package/src/agents/droid.ts +35 -1
- package/src/agents/opencode.ts +35 -1
- package/src/cli/queue.ts +406 -0
- package/src/execution/commands.ts +541 -0
- package/src/execution/runner.ts +236 -4
- package/src/init/scaffolder.ts +343 -117
- package/src/prd/parser.ts +140 -0
- package/src/prd/types.ts +8 -6
- package/src/queue/index.ts +45 -0
- package/src/queue/loader.ts +97 -0
- package/src/queue/lock.ts +137 -0
- package/src/queue/parser.ts +142 -0
- package/src/queue/processor.ts +141 -0
- package/src/queue/types.ts +81 -0
- package/src/queue/writer.ts +210 -0
- package/src/tui/App.tsx +23 -0
- package/src/tui/TUIRunner.tsx +183 -2
- package/src/tui/components/QueueInput.tsx +160 -0
- package/src/tui/components/QueuePanel.tsx +169 -0
- package/src/tui/components/QueueRemoval.tsx +306 -0
- package/src/tui/hooks/useTUI.ts +6 -0
- package/src/tui/types.ts +13 -0
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().
|
|
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
|
|
141
|
-
|
|
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
|
+
}
|