@arvorco/relentless 0.4.5 → 0.5.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,553 @@
1
+ /**
2
+ * PRD Validator
3
+ *
4
+ * Validates tasks.md content before conversion to prd.json.
5
+ * Provides early detection of format issues with clear error messages.
6
+ */
7
+
8
+ import { z } from "zod";
9
+
10
+ // ============================================================================
11
+ // Validation Types
12
+ // ============================================================================
13
+
14
+ /**
15
+ * Severity level for validation issues
16
+ */
17
+ export type ValidationSeverity = "error" | "warning" | "info";
18
+
19
+ /**
20
+ * Validation issue with context
21
+ */
22
+ export interface ValidationIssue {
23
+ severity: ValidationSeverity;
24
+ code: string;
25
+ message: string;
26
+ storyId?: string;
27
+ line?: number;
28
+ suggestion?: string;
29
+ }
30
+
31
+ /**
32
+ * Filtered criterion with reason
33
+ */
34
+ export interface FilteredCriterion {
35
+ storyId: string;
36
+ text: string;
37
+ reason: string;
38
+ line?: number;
39
+ suggestion?: string;
40
+ }
41
+
42
+ /**
43
+ * Complete validation result
44
+ */
45
+ export interface ValidationResult {
46
+ valid: boolean;
47
+ errors: ValidationIssue[];
48
+ warnings: ValidationIssue[];
49
+ info: ValidationIssue[];
50
+ filteredCriteria: FilteredCriterion[];
51
+ summary: {
52
+ totalStories: number;
53
+ totalCriteria: number;
54
+ filteredCriteriaCount: number;
55
+ storiesWithNoCriteria: string[];
56
+ };
57
+ }
58
+
59
+ // ============================================================================
60
+ // Validation Schemas
61
+ // ============================================================================
62
+
63
+ /**
64
+ * Schema for story ID format
65
+ */
66
+ export const StoryIdSchema = z.string().regex(/^US-\d{3}$/, {
67
+ message: "Story ID must be in US-XXX format (e.g., US-001)",
68
+ });
69
+
70
+ // ============================================================================
71
+ // Criterion Validation
72
+ // ============================================================================
73
+
74
+ /**
75
+ * Result of validating a criterion
76
+ */
77
+ export interface CriterionValidation {
78
+ valid: boolean;
79
+ reason?: string;
80
+ suggestion?: string;
81
+ }
82
+
83
+ /**
84
+ * Check if a criterion line is valid with detailed feedback
85
+ *
86
+ * Returns validation result with reason and suggestion for invalid criteria.
87
+ */
88
+ export function validateCriterion(text: string): CriterionValidation {
89
+ // Skip empty or very short lines
90
+ if (!text || text.length < 3) {
91
+ return {
92
+ valid: false,
93
+ reason: "Criterion too short (less than 3 characters)",
94
+ suggestion: "Add more context to describe what should be verified",
95
+ };
96
+ }
97
+
98
+ // Skip pure file paths like `src/file.ts` (standalone, not in a sentence)
99
+ // But allow file paths WITH context like "`src/file.ts` contains Zod schemas"
100
+ if (text.match(/^`[^`]+\.(ts|tsx|js|jsx|css|json|md|py|go|rs|java|kt|swift|rb)`$/)) {
101
+ return {
102
+ valid: false,
103
+ reason: "Standalone file path without context",
104
+ suggestion: `Add context: "${text} contains [description]" or "${text} is updated with [changes]"`,
105
+ };
106
+ }
107
+
108
+ // Skip pure section markers like **Files:** or **Note:**
109
+ // But allow labeled criteria like **Important:** User can log in
110
+ if (text.match(/^\*\*[^*:]+:\*\*\s*$/)) {
111
+ return {
112
+ valid: false,
113
+ reason: "Section marker without content",
114
+ suggestion: "Either add content after the marker or remove from acceptance criteria",
115
+ };
116
+ }
117
+
118
+ // Skip dividers
119
+ if (text.match(/^[-=]{3,}$/)) {
120
+ return {
121
+ valid: false,
122
+ reason: "Line divider, not an acceptance criterion",
123
+ };
124
+ }
125
+
126
+ return { valid: true };
127
+ }
128
+
129
+ // ============================================================================
130
+ // Story Validation
131
+ // ============================================================================
132
+
133
+ /**
134
+ * Extract story ID from a markdown header line
135
+ */
136
+ export function parseStoryId(line: string): { id: string | null; format: "standard" | "story" | "numbered" | null } {
137
+ // ### US-001: Title
138
+ const usMatch = line.match(/^###\s+US-(\d+)\s*:?\s*/i);
139
+ if (usMatch) {
140
+ return { id: `US-${usMatch[1].padStart(3, "0")}`, format: "standard" };
141
+ }
142
+
143
+ // ### Story 1: Title
144
+ const storyMatch = line.match(/^###\s+Story\s+(\d+)\s*:?\s*/i);
145
+ if (storyMatch) {
146
+ return { id: `US-${storyMatch[1].padStart(3, "0")}`, format: "story" };
147
+ }
148
+
149
+ // ### 1. Title or ### 1: Title
150
+ const numberedMatch = line.match(/^###\s+(\d+)\.?\s*:?\s*/);
151
+ if (numberedMatch) {
152
+ return { id: `US-${numberedMatch[1].padStart(3, "0")}`, format: "numbered" };
153
+ }
154
+
155
+ return { id: null, format: null };
156
+ }
157
+
158
+ /**
159
+ * Parse dependency line and extract referenced story IDs
160
+ */
161
+ export function parseDependencies(line: string): { ids: string[]; issues: ValidationIssue[] } {
162
+ const issues: ValidationIssue[] = [];
163
+ const ids: string[] = [];
164
+
165
+ // Extract all US-XXX references
166
+ const usMatches = line.matchAll(/US[-_]?(\d+)/gi);
167
+ for (const match of usMatches) {
168
+ const fullMatch = match[0];
169
+ const number = match[1].padStart(3, "0");
170
+ const normalized = `US-${number}`;
171
+
172
+ // Check for underscore format
173
+ if (fullMatch.includes("_")) {
174
+ issues.push({
175
+ severity: "warning",
176
+ code: "DEPENDENCY_FORMAT",
177
+ message: `Dependency "${fullMatch}" uses underscore instead of dash`,
178
+ suggestion: `Use "${normalized}" instead of "${fullMatch}"`,
179
+ });
180
+ }
181
+
182
+ ids.push(normalized);
183
+ }
184
+
185
+ return { ids, issues };
186
+ }
187
+
188
+ // ============================================================================
189
+ // Main Validation Function
190
+ // ============================================================================
191
+
192
+ /**
193
+ * Validate tasks.md content before conversion
194
+ *
195
+ * Checks for:
196
+ * - Story ID format and uniqueness
197
+ * - Dependency validity (no circular, no missing)
198
+ * - Acceptance criteria quality (warns on filtered criteria)
199
+ * - Common format issues
200
+ */
201
+ export function validateTasksMarkdown(content: string): ValidationResult {
202
+ const lines = content.split("\n");
203
+ const result: ValidationResult = {
204
+ valid: true,
205
+ errors: [],
206
+ warnings: [],
207
+ info: [],
208
+ filteredCriteria: [],
209
+ summary: {
210
+ totalStories: 0,
211
+ totalCriteria: 0,
212
+ filteredCriteriaCount: 0,
213
+ storiesWithNoCriteria: [],
214
+ },
215
+ };
216
+
217
+ // Track state
218
+ const storyIds = new Map<string, number>(); // id -> line number
219
+ const storyDependencies = new Map<string, string[]>(); // id -> dependency ids
220
+ const storyCriteriaCount = new Map<string, number>(); // id -> count of valid criteria
221
+ let currentStoryId: string | null = null;
222
+ let inAcceptanceCriteria = false;
223
+
224
+ for (let i = 0; i < lines.length; i++) {
225
+ const line = lines[i];
226
+ const trimmed = line.trim();
227
+ const lineNum = i + 1; // 1-based line numbers for human readability
228
+
229
+ // Check for story header
230
+ const storyParsed = parseStoryId(trimmed);
231
+ if (storyParsed.id) {
232
+ // If we have a previous story, check if it had criteria
233
+ if (currentStoryId && (storyCriteriaCount.get(currentStoryId) ?? 0) === 0) {
234
+ result.summary.storiesWithNoCriteria.push(currentStoryId);
235
+ }
236
+
237
+ currentStoryId = storyParsed.id;
238
+ inAcceptanceCriteria = false;
239
+ storyCriteriaCount.set(currentStoryId, 0);
240
+
241
+ // Check for non-standard format
242
+ if (storyParsed.format === "story") {
243
+ result.info.push({
244
+ severity: "info",
245
+ code: "STORY_FORMAT",
246
+ message: `Line ${lineNum}: "Story X" format will be normalized to "${currentStoryId}"`,
247
+ storyId: currentStoryId,
248
+ line: lineNum,
249
+ });
250
+ } else if (storyParsed.format === "numbered") {
251
+ result.info.push({
252
+ severity: "info",
253
+ code: "STORY_FORMAT",
254
+ message: `Line ${lineNum}: Numbered format will be normalized to "${currentStoryId}"`,
255
+ storyId: currentStoryId,
256
+ line: lineNum,
257
+ });
258
+ }
259
+
260
+ // Check for duplicate story ID
261
+ if (storyIds.has(currentStoryId)) {
262
+ result.errors.push({
263
+ severity: "error",
264
+ code: "DUPLICATE_STORY_ID",
265
+ message: `Duplicate story ID "${currentStoryId}" at line ${lineNum} (first defined at line ${storyIds.get(currentStoryId)})`,
266
+ storyId: currentStoryId,
267
+ line: lineNum,
268
+ suggestion: `Use a unique ID like US-${String(storyIds.size + 1).padStart(3, "0")}`,
269
+ });
270
+ result.valid = false;
271
+ } else {
272
+ storyIds.set(currentStoryId, lineNum);
273
+ }
274
+
275
+ result.summary.totalStories++;
276
+ continue;
277
+ }
278
+
279
+ // Check for acceptance criteria section
280
+ if (currentStoryId && trimmed.match(/^\*\*Acceptance Criteria:?\*\*$/i)) {
281
+ inAcceptanceCriteria = true;
282
+ continue;
283
+ }
284
+
285
+ // Check for dependency line
286
+ if (currentStoryId && trimmed.match(/^\*\*Dependencies?:?\*\*/i)) {
287
+ const { ids, issues } = parseDependencies(trimmed);
288
+ storyDependencies.set(currentStoryId, ids);
289
+
290
+ for (const issue of issues) {
291
+ issue.storyId = currentStoryId;
292
+ issue.line = lineNum;
293
+ result.warnings.push(issue);
294
+ }
295
+ inAcceptanceCriteria = false;
296
+ continue;
297
+ }
298
+
299
+ // Check for section headers that end acceptance criteria
300
+ if (currentStoryId && trimmed.match(/^\*\*(Files|Note|Technical|Design|Phase|Priority|Parallel|Research)/i)) {
301
+ inAcceptanceCriteria = false;
302
+ continue;
303
+ }
304
+
305
+ // Check for section header (##) ending current story
306
+ if (trimmed.startsWith("## ")) {
307
+ if (currentStoryId && (storyCriteriaCount.get(currentStoryId) ?? 0) === 0) {
308
+ result.summary.storiesWithNoCriteria.push(currentStoryId);
309
+ }
310
+ currentStoryId = null;
311
+ inAcceptanceCriteria = false;
312
+ continue;
313
+ }
314
+
315
+ // Parse acceptance criteria
316
+ if (currentStoryId && trimmed.startsWith("-")) {
317
+ // Skip dividers
318
+ if (trimmed.match(/^-+$/)) {
319
+ inAcceptanceCriteria = false;
320
+ continue;
321
+ }
322
+
323
+ // Extract criterion text
324
+ let criterionText = "";
325
+ if (trimmed.match(/^-\s*\[.\]/)) {
326
+ criterionText = trimmed.replace(/^-\s*\[.\]\s*/, "").trim();
327
+ inAcceptanceCriteria = true;
328
+ } else if (inAcceptanceCriteria) {
329
+ criterionText = trimmed.replace(/^-\s*/, "").trim();
330
+ }
331
+
332
+ if (criterionText) {
333
+ result.summary.totalCriteria++;
334
+
335
+ const validation = validateCriterion(criterionText);
336
+ if (!validation.valid) {
337
+ result.filteredCriteria.push({
338
+ storyId: currentStoryId,
339
+ text: criterionText,
340
+ reason: validation.reason!,
341
+ line: lineNum,
342
+ suggestion: validation.suggestion,
343
+ });
344
+ result.summary.filteredCriteriaCount++;
345
+ } else {
346
+ const count = storyCriteriaCount.get(currentStoryId) ?? 0;
347
+ storyCriteriaCount.set(currentStoryId, count + 1);
348
+ }
349
+ }
350
+ }
351
+ }
352
+
353
+ // Check last story for criteria
354
+ if (currentStoryId && (storyCriteriaCount.get(currentStoryId) ?? 0) === 0) {
355
+ result.summary.storiesWithNoCriteria.push(currentStoryId);
356
+ }
357
+
358
+ // Validate dependencies
359
+ for (const [storyId, deps] of storyDependencies) {
360
+ for (const depId of deps) {
361
+ if (!storyIds.has(depId)) {
362
+ result.errors.push({
363
+ severity: "error",
364
+ code: "MISSING_DEPENDENCY",
365
+ message: `Story ${storyId} depends on non-existent story ${depId}`,
366
+ storyId,
367
+ suggestion: `Check if the dependency ID is correct or create ${depId}`,
368
+ });
369
+ result.valid = false;
370
+ }
371
+ }
372
+ }
373
+
374
+ // Check for circular dependencies
375
+ const circularCheck = detectCircularDependencies(storyDependencies);
376
+ if (circularCheck.hasCircle) {
377
+ result.errors.push({
378
+ severity: "error",
379
+ code: "CIRCULAR_DEPENDENCY",
380
+ message: `Circular dependency detected: ${circularCheck.cycle!.join(" → ")}`,
381
+ suggestion: "Review dependencies and remove the circular reference",
382
+ });
383
+ result.valid = false;
384
+ }
385
+
386
+ // Add warnings for stories with no acceptance criteria after filtering
387
+ for (const storyId of result.summary.storiesWithNoCriteria) {
388
+ result.warnings.push({
389
+ severity: "warning",
390
+ code: "NO_CRITERIA",
391
+ message: `Story ${storyId} has no valid acceptance criteria after filtering`,
392
+ storyId,
393
+ suggestion: "Add acceptance criteria that describe testable requirements",
394
+ });
395
+ }
396
+
397
+ // Add warnings for filtered criteria
398
+ if (result.filteredCriteria.length > 0) {
399
+ result.warnings.push({
400
+ severity: "warning",
401
+ code: "FILTERED_CRITERIA",
402
+ message: `${result.filteredCriteria.length} acceptance criteria will be filtered during conversion`,
403
+ suggestion: "Review the filtered criteria list and add context where needed",
404
+ });
405
+ }
406
+
407
+ return result;
408
+ }
409
+
410
+ // ============================================================================
411
+ // Circular Dependency Detection
412
+ // ============================================================================
413
+
414
+ /**
415
+ * Detect circular dependencies using DFS
416
+ */
417
+ function detectCircularDependencies(
418
+ dependencies: Map<string, string[]>
419
+ ): { hasCircle: boolean; cycle?: string[] } {
420
+ const visited = new Set<string>();
421
+ const recursionStack = new Set<string>();
422
+
423
+ function dfs(storyId: string, path: string[]): string[] | null {
424
+ if (recursionStack.has(storyId)) {
425
+ // Found a cycle - return the cycle path
426
+ const cycleStart = path.indexOf(storyId);
427
+ return [...path.slice(cycleStart), storyId];
428
+ }
429
+
430
+ if (visited.has(storyId)) {
431
+ return null;
432
+ }
433
+
434
+ visited.add(storyId);
435
+ recursionStack.add(storyId);
436
+
437
+ const deps = dependencies.get(storyId) ?? [];
438
+ for (const depId of deps) {
439
+ const cycle = dfs(depId, [...path, storyId]);
440
+ if (cycle) {
441
+ return cycle;
442
+ }
443
+ }
444
+
445
+ recursionStack.delete(storyId);
446
+ return null;
447
+ }
448
+
449
+ for (const storyId of dependencies.keys()) {
450
+ const cycle = dfs(storyId, []);
451
+ if (cycle) {
452
+ return { hasCircle: true, cycle };
453
+ }
454
+ }
455
+
456
+ return { hasCircle: false };
457
+ }
458
+
459
+ // ============================================================================
460
+ // Formatting
461
+ // ============================================================================
462
+
463
+ /**
464
+ * Format validation result as human-readable output
465
+ */
466
+ export function formatValidationResult(result: ValidationResult): string {
467
+ const lines: string[] = [];
468
+
469
+ // Header
470
+ if (result.valid) {
471
+ lines.push("✅ Validation passed");
472
+ } else {
473
+ lines.push("❌ Validation failed");
474
+ }
475
+ lines.push("");
476
+
477
+ // Summary
478
+ lines.push(`Stories: ${result.summary.totalStories}`);
479
+ lines.push(`Criteria: ${result.summary.totalCriteria} total, ${result.summary.filteredCriteriaCount} filtered`);
480
+ lines.push("");
481
+
482
+ // Errors
483
+ if (result.errors.length > 0) {
484
+ lines.push(`ERRORS (${result.errors.length}):`);
485
+ for (const error of result.errors) {
486
+ const location = error.line ? ` (line ${error.line})` : "";
487
+ lines.push(` ❌ [${error.code}]${location}`);
488
+ lines.push(` ${error.message}`);
489
+ if (error.suggestion) {
490
+ lines.push(` 💡 ${error.suggestion}`);
491
+ }
492
+ }
493
+ lines.push("");
494
+ }
495
+
496
+ // Warnings
497
+ if (result.warnings.length > 0) {
498
+ lines.push(`WARNINGS (${result.warnings.length}):`);
499
+ for (const warning of result.warnings) {
500
+ const location = warning.line ? ` (line ${warning.line})` : "";
501
+ lines.push(` ⚠️ [${warning.code}]${location}`);
502
+ lines.push(` ${warning.message}`);
503
+ if (warning.suggestion) {
504
+ lines.push(` 💡 ${warning.suggestion}`);
505
+ }
506
+ }
507
+ lines.push("");
508
+ }
509
+
510
+ // Filtered criteria details (if any)
511
+ if (result.filteredCriteria.length > 0 && result.filteredCriteria.length <= 10) {
512
+ lines.push("FILTERED CRITERIA:");
513
+ for (const fc of result.filteredCriteria) {
514
+ const location = fc.line ? ` (line ${fc.line})` : "";
515
+ lines.push(` ${fc.storyId}${location}: "${fc.text}"`);
516
+ lines.push(` Reason: ${fc.reason}`);
517
+ if (fc.suggestion) {
518
+ lines.push(` 💡 ${fc.suggestion}`);
519
+ }
520
+ }
521
+ lines.push("");
522
+ } else if (result.filteredCriteria.length > 10) {
523
+ lines.push(`FILTERED CRITERIA: ${result.filteredCriteria.length} items (use --verbose to see all)`);
524
+ lines.push("");
525
+ }
526
+
527
+ // Stories with no criteria
528
+ if (result.summary.storiesWithNoCriteria.length > 0) {
529
+ lines.push("STORIES WITH NO VALID CRITERIA:");
530
+ for (const storyId of result.summary.storiesWithNoCriteria) {
531
+ lines.push(` ${storyId}`);
532
+ }
533
+ lines.push("");
534
+ }
535
+
536
+ // Info (only if there are no errors or warnings)
537
+ if (result.info.length > 0 && result.errors.length === 0 && result.warnings.length === 0) {
538
+ lines.push(`INFO (${result.info.length}):`);
539
+ for (const info of result.info) {
540
+ lines.push(` ℹ️ ${info.message}`);
541
+ }
542
+ lines.push("");
543
+ }
544
+
545
+ return lines.join("\n");
546
+ }
547
+
548
+ /**
549
+ * Format validation result as JSON
550
+ */
551
+ export function formatValidationResultJSON(result: ValidationResult): string {
552
+ return JSON.stringify(result, null, 2);
553
+ }
@@ -93,7 +93,7 @@ export const MODEL_REGISTRY: ModelProfile[] = [
93
93
  strengths: ["code_review", "architecture", "debugging", "final_review", "complex_reasoning"],
94
94
  limitations: ["expensive", "slower_start"],
95
95
  cliFlag: "--model",
96
- cliValue: "claude-opus-4-5-20251101",
96
+ cliValue: "claude-opus-4-5",
97
97
  },
98
98
  {
99
99
  id: "sonnet-4.5",
@@ -106,7 +106,7 @@ export const MODEL_REGISTRY: ModelProfile[] = [
106
106
  strengths: ["frontend", "refactoring", "daily_coding", "balanced"],
107
107
  limitations: [],
108
108
  cliFlag: "--model",
109
- cliValue: "claude-sonnet-4-5-20250929",
109
+ cliValue: "claude-sonnet-4-5",
110
110
  },
111
111
  {
112
112
  id: "haiku-4.5",
@@ -121,7 +121,7 @@ export const MODEL_REGISTRY: ModelProfile[] = [
121
121
  strengths: ["prototyping", "scaffolding", "simple_tasks", "fast"],
122
122
  limitations: ["less_reasoning"],
123
123
  cliFlag: "--model",
124
- cliValue: "claude-haiku-4-5-20251001",
124
+ cliValue: "claude-haiku-4-5",
125
125
  },
126
126
 
127
127
  // ============== Codex (OpenAI) Models ==============
@@ -196,7 +196,7 @@ export const MODEL_REGISTRY: ModelProfile[] = [
196
196
  strengths: ["architecture", "debugging", "complex_reasoning"],
197
197
  limitations: ["expensive"],
198
198
  cliFlag: "-m",
199
- cliValue: "claude-opus-4-5-20251101",
199
+ cliValue: "claude-opus-4-5",
200
200
  },
201
201
  {
202
202
  id: "claude-sonnet-4-5-20250929",
@@ -209,7 +209,7 @@ export const MODEL_REGISTRY: ModelProfile[] = [
209
209
  strengths: ["balanced", "daily_coding"],
210
210
  limitations: [],
211
211
  cliFlag: "-m",
212
- cliValue: "claude-sonnet-4-5-20250929",
212
+ cliValue: "claude-sonnet-4-5",
213
213
  },
214
214
  {
215
215
  id: "claude-haiku-4-5-20251001",
@@ -222,7 +222,7 @@ export const MODEL_REGISTRY: ModelProfile[] = [
222
222
  strengths: ["fast", "simple_tasks"],
223
223
  limitations: ["less_reasoning"],
224
224
  cliFlag: "-m",
225
- cliValue: "claude-haiku-4-5-20251001",
225
+ cliValue: "claude-haiku-4-5",
226
226
  },
227
227
  {
228
228
  id: "gpt-5.2",