@arvorco/relentless 0.5.3 → 0.6.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.
- package/CHANGELOG.md +39 -1
- package/bin/relentless.ts +164 -0
- package/package.json +1 -1
- package/src/agents/claude.ts +39 -1
- package/src/agents/exec.ts +9 -0
- package/src/agents/types.ts +2 -0
- package/src/execution/runner.ts +36 -0
- package/src/tasks/index.ts +79 -0
- package/src/tasks/sync.ts +380 -0
- package/src/tasks/tasklist.ts +425 -0
- package/src/tasks/types.ts +157 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PRD ↔ Claude Tasks Sync
|
|
3
|
+
*
|
|
4
|
+
* Bidirectional synchronization between Relentless PRD and Claude Tasks.
|
|
5
|
+
*
|
|
6
|
+
* PRD → Tasks: Convert user stories to Claude Tasks for cross-session visibility
|
|
7
|
+
* Tasks → PRD: Import Claude's natural planning to structured PRD format
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import type { PRD, UserStory } from "../prd/types";
|
|
13
|
+
import { loadPRD, savePRD } from "../prd";
|
|
14
|
+
import {
|
|
15
|
+
type ClaudeTask,
|
|
16
|
+
type SyncResult,
|
|
17
|
+
type ImportResult,
|
|
18
|
+
generateTaskListId,
|
|
19
|
+
} from "./types";
|
|
20
|
+
import {
|
|
21
|
+
loadTaskList,
|
|
22
|
+
saveTaskList,
|
|
23
|
+
getOrCreateTaskList,
|
|
24
|
+
} from "./tasklist";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Convert a user story to a Claude Task
|
|
28
|
+
*
|
|
29
|
+
* @param story - The user story to convert
|
|
30
|
+
* @returns A Claude Task representation
|
|
31
|
+
*/
|
|
32
|
+
export function storyToTask(story: UserStory): ClaudeTask {
|
|
33
|
+
const now = new Date().toISOString();
|
|
34
|
+
|
|
35
|
+
// Map PRD status to task status
|
|
36
|
+
let status: ClaudeTask["status"] = "pending";
|
|
37
|
+
if (story.passes) {
|
|
38
|
+
status = "completed";
|
|
39
|
+
} else if (story.skipped) {
|
|
40
|
+
status = "completed"; // Skipped stories are considered done
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Create the task content from story details
|
|
44
|
+
const content = `${story.id}: ${story.title}`;
|
|
45
|
+
const activeForm = `Working on ${story.id}: ${story.title}`;
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
id: story.id, // Use story ID as task ID for easy mapping
|
|
49
|
+
content,
|
|
50
|
+
activeForm,
|
|
51
|
+
status,
|
|
52
|
+
storyId: story.id,
|
|
53
|
+
dependencies: story.dependencies,
|
|
54
|
+
createdAt: now,
|
|
55
|
+
updatedAt: now,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Convert a Claude Task to a minimal user story
|
|
61
|
+
*
|
|
62
|
+
* @param task - The Claude Task to convert
|
|
63
|
+
* @param index - Index for priority assignment
|
|
64
|
+
* @returns A user story representation
|
|
65
|
+
*/
|
|
66
|
+
export function taskToStory(task: ClaudeTask, index: number): UserStory {
|
|
67
|
+
// Parse content to extract title (if it follows our format)
|
|
68
|
+
let title = task.content;
|
|
69
|
+
let description = "";
|
|
70
|
+
|
|
71
|
+
// Check if content follows "ID: Title" format
|
|
72
|
+
const idMatch = task.content.match(/^([A-Z]+-\d+):\s*(.+)$/);
|
|
73
|
+
if (idMatch) {
|
|
74
|
+
title = idMatch[2].trim();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Use task ID or generate one
|
|
78
|
+
const id = task.storyId || task.id || `US-${String(index + 1).padStart(3, "0")}`;
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
id,
|
|
82
|
+
title,
|
|
83
|
+
description: description || title,
|
|
84
|
+
acceptanceCriteria: [], // Will need to be filled in later
|
|
85
|
+
priority: index,
|
|
86
|
+
passes: task.status === "completed",
|
|
87
|
+
notes: "",
|
|
88
|
+
dependencies: task.dependencies,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Sync PRD stories to Claude TaskList
|
|
94
|
+
*
|
|
95
|
+
* Creates or updates a TaskList with the current PRD stories.
|
|
96
|
+
* This allows Claude to see and track story progress across sessions.
|
|
97
|
+
*
|
|
98
|
+
* @param prd - The PRD to sync from
|
|
99
|
+
* @param featureName - The feature name
|
|
100
|
+
* @returns Sync result with statistics
|
|
101
|
+
*/
|
|
102
|
+
export async function syncPrdToTasks(
|
|
103
|
+
prd: PRD,
|
|
104
|
+
featureName: string
|
|
105
|
+
): Promise<SyncResult> {
|
|
106
|
+
const result: SyncResult = {
|
|
107
|
+
success: true,
|
|
108
|
+
added: 0,
|
|
109
|
+
updated: 0,
|
|
110
|
+
removed: 0,
|
|
111
|
+
errors: [],
|
|
112
|
+
warnings: [],
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
// Get or create task list
|
|
117
|
+
const taskList = await getOrCreateTaskList(featureName);
|
|
118
|
+
|
|
119
|
+
// Create a map of existing tasks by storyId
|
|
120
|
+
const existingTasks = new Map<string, ClaudeTask>();
|
|
121
|
+
for (const task of taskList.tasks) {
|
|
122
|
+
if (task.storyId) {
|
|
123
|
+
existingTasks.set(task.storyId, task);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Track which stories we've processed
|
|
128
|
+
const processedStoryIds = new Set<string>();
|
|
129
|
+
|
|
130
|
+
// Sync each story
|
|
131
|
+
for (const story of prd.userStories) {
|
|
132
|
+
processedStoryIds.add(story.id);
|
|
133
|
+
|
|
134
|
+
const existingTask = existingTasks.get(story.id);
|
|
135
|
+
|
|
136
|
+
if (existingTask) {
|
|
137
|
+
// Update existing task
|
|
138
|
+
const newStatus = story.passes ? "completed" : "pending";
|
|
139
|
+
if (existingTask.status !== newStatus) {
|
|
140
|
+
existingTask.status = newStatus;
|
|
141
|
+
existingTask.updatedAt = new Date().toISOString();
|
|
142
|
+
result.updated++;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Update dependencies if changed
|
|
146
|
+
const existingDeps = existingTask.dependencies?.join(",") || "";
|
|
147
|
+
const newDeps = story.dependencies?.join(",") || "";
|
|
148
|
+
if (existingDeps !== newDeps) {
|
|
149
|
+
existingTask.dependencies = story.dependencies;
|
|
150
|
+
existingTask.updatedAt = new Date().toISOString();
|
|
151
|
+
if (result.updated === 0 || existingTask.status === (story.passes ? "completed" : "pending")) {
|
|
152
|
+
result.updated++;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
// Add new task
|
|
157
|
+
const newTask = storyToTask(story);
|
|
158
|
+
taskList.tasks.push(newTask);
|
|
159
|
+
result.added++;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Remove tasks for stories that no longer exist
|
|
164
|
+
const originalLength = taskList.tasks.length;
|
|
165
|
+
taskList.tasks = taskList.tasks.filter((task) => {
|
|
166
|
+
if (!task.storyId) return true; // Keep non-story tasks
|
|
167
|
+
return processedStoryIds.has(task.storyId);
|
|
168
|
+
});
|
|
169
|
+
result.removed = originalLength - taskList.tasks.length;
|
|
170
|
+
|
|
171
|
+
// Update metadata
|
|
172
|
+
taskList.metadata = {
|
|
173
|
+
...taskList.metadata,
|
|
174
|
+
source: "relentless",
|
|
175
|
+
lastPrdSync: new Date().toISOString(),
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
// Save the task list
|
|
179
|
+
await saveTaskList(taskList);
|
|
180
|
+
} catch (error) {
|
|
181
|
+
result.success = false;
|
|
182
|
+
result.errors.push(`Failed to sync PRD to tasks: ${error}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return result;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Sync Claude TaskList back to PRD
|
|
190
|
+
*
|
|
191
|
+
* Updates PRD story statuses based on Claude TaskList.
|
|
192
|
+
* This allows progress made in Claude to be reflected in the PRD.
|
|
193
|
+
*
|
|
194
|
+
* @param prdPath - Path to the PRD file
|
|
195
|
+
* @param featureName - The feature name
|
|
196
|
+
* @returns Sync result with statistics
|
|
197
|
+
*/
|
|
198
|
+
export async function syncTasksToPrd(
|
|
199
|
+
prdPath: string,
|
|
200
|
+
featureName: string
|
|
201
|
+
): Promise<SyncResult> {
|
|
202
|
+
const result: SyncResult = {
|
|
203
|
+
success: true,
|
|
204
|
+
added: 0,
|
|
205
|
+
updated: 0,
|
|
206
|
+
removed: 0,
|
|
207
|
+
errors: [],
|
|
208
|
+
warnings: [],
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
// Load task list
|
|
213
|
+
const taskListId = generateTaskListId(featureName);
|
|
214
|
+
const taskList = await loadTaskList(taskListId);
|
|
215
|
+
|
|
216
|
+
if (!taskList) {
|
|
217
|
+
result.warnings.push(`No task list found for ${featureName}`);
|
|
218
|
+
return result;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Load PRD
|
|
222
|
+
if (!existsSync(prdPath)) {
|
|
223
|
+
result.success = false;
|
|
224
|
+
result.errors.push(`PRD file not found: ${prdPath}`);
|
|
225
|
+
return result;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const prd = await loadPRD(prdPath);
|
|
229
|
+
|
|
230
|
+
// Create a map of tasks by storyId
|
|
231
|
+
const taskMap = new Map<string, ClaudeTask>();
|
|
232
|
+
for (const task of taskList.tasks) {
|
|
233
|
+
if (task.storyId) {
|
|
234
|
+
taskMap.set(task.storyId, task);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Update story statuses based on tasks
|
|
239
|
+
for (const story of prd.userStories) {
|
|
240
|
+
const task = taskMap.get(story.id);
|
|
241
|
+
|
|
242
|
+
if (task) {
|
|
243
|
+
const shouldBeComplete = task.status === "completed";
|
|
244
|
+
if (story.passes !== shouldBeComplete) {
|
|
245
|
+
story.passes = shouldBeComplete;
|
|
246
|
+
result.updated++;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Save updated PRD
|
|
252
|
+
if (result.updated > 0) {
|
|
253
|
+
await savePRD(prd, prdPath);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Update task list metadata
|
|
257
|
+
taskList.metadata = {
|
|
258
|
+
...taskList.metadata,
|
|
259
|
+
lastPrdSync: new Date().toISOString(),
|
|
260
|
+
prdPath,
|
|
261
|
+
};
|
|
262
|
+
await saveTaskList(taskList);
|
|
263
|
+
} catch (error) {
|
|
264
|
+
result.success = false;
|
|
265
|
+
result.errors.push(`Failed to sync tasks to PRD: ${error}`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return result;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Import Claude Tasks to create a new PRD
|
|
273
|
+
*
|
|
274
|
+
* Takes a Claude TaskList (from natural planning) and creates
|
|
275
|
+
* a structured PRD for execution with Relentless.
|
|
276
|
+
*
|
|
277
|
+
* @param taskListId - The task list ID to import
|
|
278
|
+
* @param featureName - The feature name for the PRD
|
|
279
|
+
* @param outputDir - Directory to create the PRD in
|
|
280
|
+
* @returns Import result with statistics
|
|
281
|
+
*/
|
|
282
|
+
export async function importTasksToPrd(
|
|
283
|
+
taskListId: string,
|
|
284
|
+
featureName: string,
|
|
285
|
+
outputDir: string
|
|
286
|
+
): Promise<ImportResult> {
|
|
287
|
+
const result: ImportResult = {
|
|
288
|
+
success: true,
|
|
289
|
+
storiesCreated: 0,
|
|
290
|
+
errors: [],
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
try {
|
|
294
|
+
// Load task list
|
|
295
|
+
const taskList = await loadTaskList(taskListId);
|
|
296
|
+
|
|
297
|
+
if (!taskList) {
|
|
298
|
+
result.success = false;
|
|
299
|
+
result.errors.push(`Task list not found: ${taskListId}`);
|
|
300
|
+
return result;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Convert tasks to stories
|
|
304
|
+
const stories: UserStory[] = [];
|
|
305
|
+
for (let i = 0; i < taskList.tasks.length; i++) {
|
|
306
|
+
const task = taskList.tasks[i];
|
|
307
|
+
const story = taskToStory(task, i);
|
|
308
|
+
stories.push(story);
|
|
309
|
+
result.storiesCreated++;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Create PRD structure
|
|
313
|
+
const prd: PRD = {
|
|
314
|
+
project: featureName,
|
|
315
|
+
branchName: `feature/${featureName.toLowerCase().replace(/\s+/g, "-")}`,
|
|
316
|
+
description: taskList.name || `Imported from Claude Tasks: ${taskListId}`,
|
|
317
|
+
userStories: stories,
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
// Determine output path
|
|
321
|
+
const prdPath = join(outputDir, "prd.json");
|
|
322
|
+
result.prdPath = prdPath;
|
|
323
|
+
|
|
324
|
+
// Save PRD
|
|
325
|
+
await savePRD(prd, prdPath);
|
|
326
|
+
|
|
327
|
+
// Update task list metadata
|
|
328
|
+
taskList.metadata = {
|
|
329
|
+
...taskList.metadata,
|
|
330
|
+
lastPrdSync: new Date().toISOString(),
|
|
331
|
+
prdPath,
|
|
332
|
+
};
|
|
333
|
+
await saveTaskList(taskList);
|
|
334
|
+
} catch (error) {
|
|
335
|
+
result.success = false;
|
|
336
|
+
result.errors.push(`Failed to import tasks to PRD: ${error}`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return result;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Bidirectional sync between PRD and Claude Tasks
|
|
344
|
+
*
|
|
345
|
+
* Syncs in both directions, preferring the most recently updated source.
|
|
346
|
+
* This ensures both PRD and Tasks stay in sync.
|
|
347
|
+
*
|
|
348
|
+
* @param prdPath - Path to the PRD file
|
|
349
|
+
* @param featureName - The feature name
|
|
350
|
+
* @returns Sync result with combined statistics
|
|
351
|
+
*/
|
|
352
|
+
export async function bidirectionalSync(
|
|
353
|
+
prdPath: string,
|
|
354
|
+
featureName: string
|
|
355
|
+
): Promise<SyncResult> {
|
|
356
|
+
const result: SyncResult = {
|
|
357
|
+
success: true,
|
|
358
|
+
added: 0,
|
|
359
|
+
updated: 0,
|
|
360
|
+
removed: 0,
|
|
361
|
+
errors: [],
|
|
362
|
+
warnings: [],
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
// First sync PRD to Tasks (ensures all stories are represented)
|
|
366
|
+
const prdToTasks = await syncPrdToTasks(await loadPRD(prdPath), featureName);
|
|
367
|
+
result.added += prdToTasks.added;
|
|
368
|
+
result.errors.push(...prdToTasks.errors);
|
|
369
|
+
result.warnings.push(...prdToTasks.warnings);
|
|
370
|
+
|
|
371
|
+
// Then sync Tasks back to PRD (pick up any Claude-made changes)
|
|
372
|
+
const tasksToPrd = await syncTasksToPrd(prdPath, featureName);
|
|
373
|
+
result.updated += tasksToPrd.updated;
|
|
374
|
+
result.errors.push(...tasksToPrd.errors);
|
|
375
|
+
result.warnings.push(...tasksToPrd.warnings);
|
|
376
|
+
|
|
377
|
+
result.success = prdToTasks.success && tasksToPrd.success;
|
|
378
|
+
|
|
379
|
+
return result;
|
|
380
|
+
}
|