@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.
@@ -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
+ }