@arvorco/relentless 0.4.5 → 0.5.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.
@@ -0,0 +1,417 @@
1
+ /**
2
+ * Context Builder - Task-Specific Context Extraction
3
+ *
4
+ * Optimizes token usage by extracting only relevant context for the current story
5
+ * instead of loading entire files wholesale.
6
+ *
7
+ * This module provides:
8
+ * - extractStoryFromTasks: Extract current story + dependencies from tasks.md
9
+ * - filterChecklistForStory: Filter checklist to story-specific items
10
+ * - extractStoryMetadata: Extract current story metadata from PRD
11
+ * - buildProgressSummary: Build concise progress summary
12
+ */
13
+
14
+ import { existsSync } from "node:fs";
15
+ import type { PRD, UserStory } from "../prd/types";
16
+ import { countStories } from "../prd/types";
17
+
18
+ // ============================================================================
19
+ // Types
20
+ // ============================================================================
21
+
22
+ /**
23
+ * Context extracted from tasks.md for a specific story
24
+ */
25
+ export interface StoryContext {
26
+ /** Markdown content for the current story section */
27
+ currentStory: string;
28
+ /** Markdown content for dependency story sections */
29
+ dependencies: string[];
30
+ /** Concise progress summary (e.g., "Progress: 5/18 stories complete") */
31
+ progressSummary: string;
32
+ /** Story statistics from PRD */
33
+ stats: {
34
+ total: number;
35
+ completed: number;
36
+ pending: number;
37
+ skipped: number;
38
+ };
39
+ }
40
+
41
+ /**
42
+ * Checklist items filtered for a specific story
43
+ */
44
+ export interface FilteredChecklist {
45
+ /** Items tagged with the specific story ID [US-XXX] */
46
+ storyItems: string[];
47
+ /** Items tagged [Constitution] - always relevant */
48
+ constitutionItems: string[];
49
+ /** Items tagged [Edge Case], [Gap], [Clarified] - general relevance */
50
+ generalItems: string[];
51
+ /** Total item count in the original checklist */
52
+ totalItemCount: number;
53
+ }
54
+
55
+ /**
56
+ * Metadata extracted for a specific story from PRD
57
+ */
58
+ export interface StoryMetadata {
59
+ id: string;
60
+ title: string;
61
+ description: string;
62
+ acceptanceCriteria: string[];
63
+ priority: number;
64
+ dependencies: string[];
65
+ phase?: string;
66
+ research?: boolean;
67
+ }
68
+
69
+ // ============================================================================
70
+ // Story Extraction from tasks.md
71
+ // ============================================================================
72
+
73
+ /**
74
+ * Extract story section from tasks.md content
75
+ *
76
+ * Stories are identified by the pattern: ### US-XXX: Title
77
+ * The section ends at the next story header or section divider (---)
78
+ */
79
+ function extractStorySectionFromContent(
80
+ content: string,
81
+ storyId: string
82
+ ): string {
83
+ const lines = content.split("\n");
84
+ const storyPattern = new RegExp(`^###\\s+${storyId}:`, "i");
85
+
86
+ let inStory = false;
87
+ let storyLines: string[] = [];
88
+
89
+ for (const line of lines) {
90
+ // Check if we're starting the target story
91
+ if (storyPattern.test(line)) {
92
+ inStory = true;
93
+ storyLines = [line];
94
+ continue;
95
+ }
96
+
97
+ if (inStory) {
98
+ // Check if we've hit the next story or section divider
99
+ if (/^###\s+US-\d+:/.test(line) || /^---$/.test(line)) {
100
+ break;
101
+ }
102
+ storyLines.push(line);
103
+ }
104
+ }
105
+
106
+ return storyLines.join("\n").trim();
107
+ }
108
+
109
+ /**
110
+ * Extract a story section and its dependencies from tasks.md
111
+ *
112
+ * @param tasksPath - Path to tasks.md file
113
+ * @param currentStoryId - ID of the current story (e.g., "US-002")
114
+ * @param prd - PRD object with all story metadata including dependencies
115
+ * @returns StoryContext with current story, dependencies, and stats
116
+ */
117
+ export async function extractStoryFromTasks(
118
+ tasksPath: string,
119
+ currentStoryId: string,
120
+ prd: PRD
121
+ ): Promise<StoryContext> {
122
+ // Get stats from PRD
123
+ const stats = countStories(prd);
124
+ const progressSummary = buildProgressSummary(prd);
125
+
126
+ // Handle missing file
127
+ if (!existsSync(tasksPath)) {
128
+ return {
129
+ currentStory: "",
130
+ dependencies: [],
131
+ progressSummary,
132
+ stats,
133
+ };
134
+ }
135
+
136
+ const content = await Bun.file(tasksPath).text();
137
+
138
+ // Extract current story section
139
+ const currentStory = extractStorySectionFromContent(content, currentStoryId);
140
+
141
+ // Find the story in PRD to get dependencies
142
+ const story = prd.userStories.find((s) => s.id === currentStoryId);
143
+ const dependencyIds = story?.dependencies ?? [];
144
+
145
+ // Extract dependency story sections
146
+ const dependencies: string[] = [];
147
+ for (const depId of dependencyIds) {
148
+ const depSection = extractStorySectionFromContent(content, depId);
149
+ if (depSection) {
150
+ dependencies.push(depSection);
151
+ }
152
+ }
153
+
154
+ return {
155
+ currentStory,
156
+ dependencies,
157
+ progressSummary,
158
+ stats,
159
+ };
160
+ }
161
+
162
+ // ============================================================================
163
+ // Checklist Filtering
164
+ // ============================================================================
165
+
166
+ /**
167
+ * Filter checklist items for a specific story
168
+ *
169
+ * Extracts:
170
+ * - Items tagged with the story ID [US-XXX]
171
+ * - Items tagged [Constitution] (always relevant)
172
+ * - Items tagged [Edge Case], [Gap], [Clarified] (general relevance)
173
+ *
174
+ * @param checklistPath - Path to checklist.md file
175
+ * @param storyId - ID of the current story (e.g., "US-002")
176
+ * @returns FilteredChecklist with categorized items
177
+ */
178
+ export async function filterChecklistForStory(
179
+ checklistPath: string,
180
+ storyId: string
181
+ ): Promise<FilteredChecklist> {
182
+ // Handle missing file
183
+ if (!existsSync(checklistPath)) {
184
+ return {
185
+ storyItems: [],
186
+ constitutionItems: [],
187
+ generalItems: [],
188
+ totalItemCount: 0,
189
+ };
190
+ }
191
+
192
+ const content = await Bun.file(checklistPath).text();
193
+ const lines = content.split("\n");
194
+
195
+ const storyItems: string[] = [];
196
+ const constitutionItems: string[] = [];
197
+ const generalItems: string[] = [];
198
+ let totalItemCount = 0;
199
+
200
+ // Pattern for checklist items: - [ ] CHK-XXX [TAG] Description
201
+ // or: - [x] CHK-XXX [TAG] Description
202
+ const checklistItemPattern = /^-\s+\[[ x]\]\s+CHK-\d+/;
203
+
204
+ for (const line of lines) {
205
+ const trimmed = line.trim();
206
+
207
+ // Check if it's a checklist item
208
+ if (checklistItemPattern.test(trimmed)) {
209
+ totalItemCount++;
210
+
211
+ // Categorize by tag
212
+ if (trimmed.includes(`[${storyId}]`)) {
213
+ storyItems.push(trimmed);
214
+ } else if (trimmed.includes("[Constitution]")) {
215
+ constitutionItems.push(trimmed);
216
+ } else if (
217
+ trimmed.includes("[Edge Case]") ||
218
+ trimmed.includes("[Gap]") ||
219
+ trimmed.includes("[Clarified]")
220
+ ) {
221
+ generalItems.push(trimmed);
222
+ }
223
+ }
224
+ }
225
+
226
+ return {
227
+ storyItems,
228
+ constitutionItems,
229
+ generalItems,
230
+ totalItemCount,
231
+ };
232
+ }
233
+
234
+ // ============================================================================
235
+ // Story Metadata Extraction
236
+ // ============================================================================
237
+
238
+ /**
239
+ * Extract metadata for a specific story from PRD
240
+ *
241
+ * @param prd - PRD object with all stories
242
+ * @param storyId - ID of the story to extract
243
+ * @returns StoryMetadata or null if story not found
244
+ */
245
+ export function extractStoryMetadata(
246
+ prd: PRD,
247
+ storyId: string
248
+ ): StoryMetadata | null {
249
+ const story = prd.userStories.find((s) => s.id === storyId);
250
+
251
+ if (!story) {
252
+ return null;
253
+ }
254
+
255
+ return {
256
+ id: story.id,
257
+ title: story.title,
258
+ description: story.description,
259
+ acceptanceCriteria: story.acceptanceCriteria,
260
+ priority: story.priority,
261
+ dependencies: story.dependencies ?? [],
262
+ phase: story.phase,
263
+ research: story.research,
264
+ };
265
+ }
266
+
267
+ // ============================================================================
268
+ // Progress Summary
269
+ // ============================================================================
270
+
271
+ /**
272
+ * Build a concise progress summary from PRD
273
+ *
274
+ * @param prd - PRD object with all stories
275
+ * @returns Progress summary string (e.g., "Progress: 5/18 stories complete")
276
+ */
277
+ export function buildProgressSummary(prd: PRD): string {
278
+ const stats = countStories(prd);
279
+ return `Progress: ${stats.completed}/${stats.total} stories complete`;
280
+ }
281
+
282
+ // ============================================================================
283
+ // Optimized Context Building (for runner.ts integration)
284
+ // ============================================================================
285
+
286
+ /**
287
+ * Options for building an optimized prompt
288
+ */
289
+ export interface OptimizedContextOptions {
290
+ /** Path to the feature directory */
291
+ featureDir: string;
292
+ /** Current story being worked on */
293
+ story: UserStory;
294
+ /** Full PRD object */
295
+ prd: PRD;
296
+ }
297
+
298
+ /**
299
+ * Optimized context components extracted for prompt building
300
+ */
301
+ export interface OptimizedContext {
302
+ /** Story context from tasks.md */
303
+ storyContext: StoryContext;
304
+ /** Filtered checklist items */
305
+ checklist: FilteredChecklist;
306
+ /** Story metadata from PRD */
307
+ metadata: StoryMetadata | null;
308
+ }
309
+
310
+ /**
311
+ * Build optimized context for a story
312
+ *
313
+ * Extracts only the relevant portions of tasks.md and checklist.md
314
+ * instead of loading the entire files.
315
+ *
316
+ * @param options - Context building options
317
+ * @returns OptimizedContext with all extracted components
318
+ */
319
+ export async function buildOptimizedContext(
320
+ options: OptimizedContextOptions
321
+ ): Promise<OptimizedContext> {
322
+ const { featureDir, story, prd } = options;
323
+
324
+ // Build paths
325
+ const tasksPath = `${featureDir}/tasks.md`;
326
+ const checklistPath = `${featureDir}/checklist.md`;
327
+
328
+ // Extract context in parallel
329
+ const [storyContext, checklist] = await Promise.all([
330
+ extractStoryFromTasks(tasksPath, story.id, prd),
331
+ filterChecklistForStory(checklistPath, story.id),
332
+ ]);
333
+
334
+ // Extract metadata
335
+ const metadata = extractStoryMetadata(prd, story.id);
336
+
337
+ return {
338
+ storyContext,
339
+ checklist,
340
+ metadata,
341
+ };
342
+ }
343
+
344
+ /**
345
+ * Format story context for inclusion in prompt
346
+ *
347
+ * @param context - Story context from extractStoryFromTasks
348
+ * @returns Formatted markdown string
349
+ */
350
+ export function formatStoryContext(context: StoryContext): string {
351
+ if (!context.currentStory) {
352
+ return "";
353
+ }
354
+
355
+ let formatted = `\n\n## Current Story Context\n\n`;
356
+ formatted += `${context.progressSummary}\n\n`;
357
+ formatted += context.currentStory;
358
+
359
+ if (context.dependencies.length > 0) {
360
+ formatted += `\n\n### Dependency Stories (for reference)\n\n`;
361
+ for (const dep of context.dependencies) {
362
+ formatted += `${dep}\n\n`;
363
+ }
364
+ }
365
+
366
+ return formatted;
367
+ }
368
+
369
+ /**
370
+ * Format filtered checklist for inclusion in prompt
371
+ *
372
+ * @param checklist - Filtered checklist from filterChecklistForStory
373
+ * @param storyId - Story ID for header
374
+ * @returns Formatted markdown string
375
+ */
376
+ export function formatFilteredChecklist(
377
+ checklist: FilteredChecklist,
378
+ storyId: string
379
+ ): string {
380
+ const hasItems =
381
+ checklist.storyItems.length > 0 ||
382
+ checklist.constitutionItems.length > 0 ||
383
+ checklist.generalItems.length > 0;
384
+
385
+ if (!hasItems) {
386
+ return "";
387
+ }
388
+
389
+ let formatted = `\n\n## Relevant Quality Checklist Items\n\n`;
390
+ formatted += `Filtered from ${checklist.totalItemCount} total items:\n\n`;
391
+
392
+ if (checklist.storyItems.length > 0) {
393
+ formatted += `### Story-Specific Items (${storyId})\n\n`;
394
+ for (const item of checklist.storyItems) {
395
+ formatted += `${item}\n`;
396
+ }
397
+ formatted += "\n";
398
+ }
399
+
400
+ if (checklist.constitutionItems.length > 0) {
401
+ formatted += `### Constitution Items (Always Required)\n\n`;
402
+ for (const item of checklist.constitutionItems) {
403
+ formatted += `${item}\n`;
404
+ }
405
+ formatted += "\n";
406
+ }
407
+
408
+ if (checklist.generalItems.length > 0) {
409
+ formatted += `### General Quality Items\n\n`;
410
+ for (const item of checklist.generalItems) {
411
+ formatted += `${item}\n`;
412
+ }
413
+ formatted += "\n";
414
+ }
415
+
416
+ return formatted;
417
+ }
@@ -6,3 +6,4 @@
6
6
 
7
7
  export * from "./runner";
8
8
  export * from "./router";
9
+ export * from "./context-builder";
@@ -17,6 +17,13 @@ import { loadProgress, updateProgressMetadata, syncPatternsFromContent, appendPr
17
17
  import { routeTask, type RoutingDecision } from "../routing/router";
18
18
  import { getModelForHarnessAndMode, getFreeModeHarnesses } from "../routing/fallback";
19
19
  import { buildStoryPromptAddition } from "./story-prompt";
20
+ import {
21
+ extractStoryFromTasks,
22
+ filterChecklistForStory,
23
+ formatStoryContext,
24
+ formatFilteredChecklist,
25
+ } from "./context-builder";
26
+ import type { PRD } from "../prd/types";
20
27
  import { processQueue } from "../queue";
21
28
  import type { QueueProcessResult } from "../queue/types";
22
29
  import {
@@ -184,12 +191,19 @@ export function formatQueueLogMessage(promptCount: number, commandCount: number)
184
191
 
185
192
  /**
186
193
  * Build the prompt for an iteration
194
+ *
195
+ * @param promptPath - Path to prompt.md
196
+ * @param workingDirectory - Working directory
197
+ * @param progressPath - Path to progress.txt
198
+ * @param story - Current story (optional)
199
+ * @param prd - PRD object for optimized context extraction (optional)
187
200
  */
188
201
  async function buildPrompt(
189
202
  promptPath: string,
190
203
  workingDirectory: string,
191
204
  progressPath: string,
192
- story?: UserStory
205
+ story?: UserStory,
206
+ prd?: PRD
193
207
  ): Promise<string> {
194
208
  if (!existsSync(promptPath)) {
195
209
  throw new Error(`Prompt file not found: ${promptPath}`);
@@ -243,25 +257,56 @@ async function buildPrompt(
243
257
  prompt += planContent;
244
258
  }
245
259
 
246
- // Load and append tasks.md if available
260
+ // Load tasks.md - optimized extraction when story and PRD are available
247
261
  const tasksPath = join(dirname(progressPath), "tasks.md");
248
262
  if (existsSync(tasksPath)) {
249
- const tasksContent = await Bun.file(tasksPath).text();
250
- prompt += `\n\n## User Stories and Tasks\n\n`;
251
- prompt += `The following tasks file contains all user stories with their acceptance criteria.\n`;
252
- prompt += `**IMPORTANT:** Update the checkboxes in this file as you complete each criterion.\n`;
253
- prompt += `Change \`- [ ]\` to \`- [x]\` for completed items.\n\n`;
254
- prompt += tasksContent;
263
+ if (story && prd) {
264
+ // OPTIMIZED: Extract only current story and dependencies (~84% token savings)
265
+ const storyContext = await extractStoryFromTasks(tasksPath, story.id, prd);
266
+ if (storyContext.currentStory) {
267
+ prompt += `\n\n## User Stories and Tasks (Optimized Context)\n\n`;
268
+ prompt += `${storyContext.progressSummary}\n\n`;
269
+ prompt += `**IMPORTANT:** Update the checkboxes in tasks.md as you complete each criterion.\n`;
270
+ prompt += `Change \`- [ ]\` to \`- [x]\` for completed items.\n\n`;
271
+ prompt += formatStoryContext(storyContext);
272
+ }
273
+ } else {
274
+ // FALLBACK: Load full tasks.md when no story/PRD context available
275
+ const tasksContent = await Bun.file(tasksPath).text();
276
+ prompt += `\n\n## User Stories and Tasks\n\n`;
277
+ prompt += `The following tasks file contains all user stories with their acceptance criteria.\n`;
278
+ prompt += `**IMPORTANT:** Update the checkboxes in this file as you complete each criterion.\n`;
279
+ prompt += `Change \`- [ ]\` to \`- [x]\` for completed items.\n\n`;
280
+ prompt += tasksContent;
281
+ }
255
282
  }
256
283
 
257
- // Load and append checklist.md if available
284
+ // Load checklist.md - optimized filtering when story is available
258
285
  const checklistPath = join(dirname(progressPath), "checklist.md");
259
286
  if (existsSync(checklistPath)) {
260
- const checklistContent = await Bun.file(checklistPath).text();
261
- prompt += `\n\n## Quality Checklist\n\n`;
262
- prompt += `The following quality checks must be validated before marking stories as complete:\n\n`;
263
- prompt += checklistContent;
264
- prompt += `\n\nIMPORTANT: Review this checklist after implementing each story and verify all applicable items.\n`;
287
+ if (story) {
288
+ // OPTIMIZED: Filter to story-specific items only (~80% token savings)
289
+ const filteredChecklist = await filterChecklistForStory(checklistPath, story.id);
290
+ const hasRelevantItems =
291
+ filteredChecklist.storyItems.length > 0 ||
292
+ filteredChecklist.constitutionItems.length > 0 ||
293
+ filteredChecklist.generalItems.length > 0;
294
+
295
+ if (hasRelevantItems) {
296
+ prompt += `\n\n## Quality Checklist (Filtered for ${story.id})\n\n`;
297
+ prompt += `Showing relevant items from ${filteredChecklist.totalItemCount} total checklist items.\n`;
298
+ prompt += `Verify these items as you implement. Update checklist.md to mark items as checked.\n`;
299
+ prompt += formatFilteredChecklist(filteredChecklist, story.id);
300
+ prompt += `\nIMPORTANT: Review these checklist items and verify all applicable ones.\n`;
301
+ }
302
+ } else {
303
+ // FALLBACK: Load full checklist.md when no story context available
304
+ const checklistContent = await Bun.file(checklistPath).text();
305
+ prompt += `\n\n## Quality Checklist\n\n`;
306
+ prompt += `The following quality checks must be validated before marking stories as complete:\n\n`;
307
+ prompt += checklistContent;
308
+ prompt += `\n\nIMPORTANT: Review this checklist after implementing each story and verify all applicable items.\n`;
309
+ }
265
310
  }
266
311
 
267
312
  // Load and append research findings if available
@@ -680,7 +725,7 @@ export async function run(options: RunOptions): Promise<RunResult> {
680
725
  mkdirSync(researchDir, { recursive: true });
681
726
  }
682
727
 
683
- let researchPrompt = await buildPrompt(options.promptPath, options.workingDirectory, progressPath, story);
728
+ let researchPrompt = await buildPrompt(options.promptPath, options.workingDirectory, progressPath, story, currentPRD);
684
729
 
685
730
  // Inject queue prompts into research phase too
686
731
  if (queuePrompts.length > 0) {
@@ -737,7 +782,7 @@ export async function run(options: RunOptions): Promise<RunResult> {
737
782
  console.log(chalk.cyan(" 🔨 Implementation phase - applying research findings..."));
738
783
  }
739
784
 
740
- let prompt = await buildPrompt(options.promptPath, options.workingDirectory, progressPath, story);
785
+ let prompt = await buildPrompt(options.promptPath, options.workingDirectory, progressPath, story, currentPRD);
741
786
 
742
787
  // Inject queue prompts if any
743
788
  if (queuePrompts.length > 0) {
package/src/prd/index.ts CHANGED
@@ -9,3 +9,4 @@ export * from "./parser";
9
9
  export * from "./progress";
10
10
  export * from "./analyzer";
11
11
  export * from "./issues";
12
+ export * from "./validator";