@arvorco/relentless 0.1.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.
Files changed (107) hide show
  1. package/.claude/commands/relentless.analyze.md +20 -0
  2. package/.claude/commands/relentless.checklist.md +15 -0
  3. package/.claude/commands/relentless.clarify.md +19 -0
  4. package/.claude/commands/relentless.constitution.md +78 -0
  5. package/.claude/commands/relentless.implement.md +15 -0
  6. package/.claude/commands/relentless.plan.md +22 -0
  7. package/.claude/commands/relentless.plan.old.md +89 -0
  8. package/.claude/commands/relentless.specify.md +254 -0
  9. package/.claude/commands/relentless.tasks.md +25 -0
  10. package/.claude/commands/relentless.taskstoissues.md +15 -0
  11. package/.claude/settings.local.json +23 -0
  12. package/.claude/skills/analyze/SKILL.md +149 -0
  13. package/.claude/skills/checklist/SKILL.md +173 -0
  14. package/.claude/skills/checklist/templates/checklist-template.md +40 -0
  15. package/.claude/skills/clarify/SKILL.md +174 -0
  16. package/.claude/skills/constitution/SKILL.md +150 -0
  17. package/.claude/skills/constitution/templates/constitution-template.md +228 -0
  18. package/.claude/skills/implement/SKILL.md +141 -0
  19. package/.claude/skills/plan/SKILL.md +179 -0
  20. package/.claude/skills/plan/templates/plan-template.md +104 -0
  21. package/.claude/skills/prd/SKILL.md +242 -0
  22. package/.claude/skills/relentless/SKILL.md +265 -0
  23. package/.claude/skills/specify/SKILL.md +220 -0
  24. package/.claude/skills/specify/scripts/bash/check-prerequisites.sh +166 -0
  25. package/.claude/skills/specify/scripts/bash/common.sh +156 -0
  26. package/.claude/skills/specify/scripts/bash/create-new-feature.sh +305 -0
  27. package/.claude/skills/specify/scripts/bash/setup-plan.sh +61 -0
  28. package/.claude/skills/specify/scripts/bash/update-agent-context.sh +799 -0
  29. package/.claude/skills/specify/templates/spec-template.md +115 -0
  30. package/.claude/skills/tasks/SKILL.md +202 -0
  31. package/.claude/skills/tasks/templates/tasks-template.md +251 -0
  32. package/.claude/skills/taskstoissues/SKILL.md +97 -0
  33. package/.specify/memory/constitution.md +50 -0
  34. package/.specify/scripts/bash/check-prerequisites.sh +166 -0
  35. package/.specify/scripts/bash/common.sh +156 -0
  36. package/.specify/scripts/bash/create-new-feature.sh +297 -0
  37. package/.specify/scripts/bash/setup-plan.sh +61 -0
  38. package/.specify/scripts/bash/update-agent-context.sh +799 -0
  39. package/.specify/templates/agent-file-template.md +28 -0
  40. package/.specify/templates/checklist-template.md +40 -0
  41. package/.specify/templates/plan-template.md +104 -0
  42. package/.specify/templates/spec-template.md +115 -0
  43. package/.specify/templates/tasks-template.md +251 -0
  44. package/CHANGES_SUMMARY.md +255 -0
  45. package/CLAUDE.md +92 -0
  46. package/GEMINI_SETUP.md +256 -0
  47. package/LICENSE +21 -0
  48. package/README.md +1171 -0
  49. package/REFACTOR_SUMMARY.md +267 -0
  50. package/bin/relentless.ts +536 -0
  51. package/bun.lock +352 -0
  52. package/eslint.config.js +37 -0
  53. package/package.json +61 -0
  54. package/prd.json.example +64 -0
  55. package/prompt.md +108 -0
  56. package/ralph.sh +80 -0
  57. package/relentless/config.json +38 -0
  58. package/relentless/features/.gitkeep +0 -0
  59. package/relentless/features/ghsk-ideas/prd.json +229 -0
  60. package/relentless/features/ghsk-ideas/prd.md +191 -0
  61. package/relentless/features/ghsk-ideas/progress.txt +408 -0
  62. package/relentless/prompt.md +79 -0
  63. package/skills/checklist/SKILL.md +349 -0
  64. package/skills/clarify/SKILL.md +476 -0
  65. package/skills/prd/SKILL.md +242 -0
  66. package/skills/relentless/SKILL.md +268 -0
  67. package/skills/tasks/SKILL.md +577 -0
  68. package/src/agents/amp.ts +115 -0
  69. package/src/agents/claude.ts +185 -0
  70. package/src/agents/codex.ts +89 -0
  71. package/src/agents/droid.ts +90 -0
  72. package/src/agents/gemini.ts +109 -0
  73. package/src/agents/index.ts +16 -0
  74. package/src/agents/opencode.ts +88 -0
  75. package/src/agents/registry.ts +95 -0
  76. package/src/agents/types.ts +101 -0
  77. package/src/config/index.ts +8 -0
  78. package/src/config/loader.ts +237 -0
  79. package/src/config/schema.ts +115 -0
  80. package/src/execution/index.ts +8 -0
  81. package/src/execution/router.ts +49 -0
  82. package/src/execution/runner.ts +512 -0
  83. package/src/index.ts +11 -0
  84. package/src/init/index.ts +7 -0
  85. package/src/init/scaffolder.ts +377 -0
  86. package/src/prd/analyzer.ts +512 -0
  87. package/src/prd/index.ts +11 -0
  88. package/src/prd/issues.ts +249 -0
  89. package/src/prd/parser.ts +281 -0
  90. package/src/prd/progress.ts +198 -0
  91. package/src/prd/types.ts +170 -0
  92. package/src/tui/App.tsx +85 -0
  93. package/src/tui/TUIRunner.tsx +400 -0
  94. package/src/tui/components/AgentOutput.tsx +45 -0
  95. package/src/tui/components/AgentStatus.tsx +64 -0
  96. package/src/tui/components/CurrentStory.tsx +66 -0
  97. package/src/tui/components/Header.tsx +49 -0
  98. package/src/tui/components/ProgressBar.tsx +39 -0
  99. package/src/tui/components/StoryGrid.tsx +86 -0
  100. package/src/tui/hooks/useTUI.ts +147 -0
  101. package/src/tui/hooks/useTimer.ts +51 -0
  102. package/src/tui/index.tsx +17 -0
  103. package/src/tui/theme.ts +41 -0
  104. package/src/tui/types.ts +77 -0
  105. package/templates/constitution.md +228 -0
  106. package/templates/plan.md +273 -0
  107. package/tsconfig.json +27 -0
@@ -0,0 +1,249 @@
1
+ /**
2
+ * GitHub Issues Generator
3
+ *
4
+ * Converts user stories to GitHub issues via gh CLI
5
+ */
6
+
7
+ import type { PRD, UserStory } from "./types";
8
+ import { inferStoryType } from "./types";
9
+ import chalk from "chalk";
10
+
11
+ /**
12
+ * Validate git remote matches expected repository
13
+ */
14
+ async function validateGitRemote(): Promise<{ owner: string; repo: string }> {
15
+ const proc = Bun.spawn(["git", "remote", "get-url", "origin"]);
16
+ const output = await new Response(proc.stdout).text();
17
+ const exitCode = await proc.exited;
18
+
19
+ if (exitCode !== 0) {
20
+ throw new Error(`Failed to get git remote: exit code ${exitCode}`);
21
+ }
22
+
23
+ const url = output.trim();
24
+
25
+ // Parse GitHub remote URL
26
+ // Supports: git@github.com:owner/repo.git and https://github.com/owner/repo.git
27
+ const sshMatch = url.match(/git@github\.com:(.+?)\/(.+?)(?:\.git)?$/);
28
+ const httpsMatch = url.match(/https:\/\/github\.com\/(.+?)\/(.+?)(?:\.git)?$/);
29
+
30
+ const match = sshMatch || httpsMatch;
31
+ if (!match) {
32
+ throw new Error(`Invalid GitHub remote URL: ${url}`);
33
+ }
34
+
35
+ return { owner: match[1], repo: match[2] };
36
+ }
37
+
38
+ /**
39
+ * Infer labels from story content
40
+ */
41
+ function inferLabels(story: UserStory): string[] {
42
+ const labels: string[] = [];
43
+ const type = inferStoryType(story);
44
+
45
+ // Add primary type label
46
+ if (type !== "general") {
47
+ labels.push(type);
48
+ }
49
+
50
+ // Add priority label
51
+ if (story.priority <= 3) {
52
+ labels.push("priority:high");
53
+ } else if (story.priority <= 6) {
54
+ labels.push("priority:medium");
55
+ } else {
56
+ labels.push("priority:low");
57
+ }
58
+
59
+ // Add phase label if present
60
+ if (story.phase) {
61
+ labels.push(`phase:${story.phase.toLowerCase()}`);
62
+ }
63
+
64
+ // Add research label if required
65
+ if (story.research) {
66
+ labels.push("research");
67
+ }
68
+
69
+ return labels;
70
+ }
71
+
72
+ /**
73
+ * Format story as GitHub issue body
74
+ */
75
+ function formatIssueBody(story: UserStory): string {
76
+ const parts: string[] = [];
77
+
78
+ // Description
79
+ parts.push("## Description");
80
+ parts.push("");
81
+ parts.push(story.description);
82
+ parts.push("");
83
+
84
+ // Acceptance Criteria
85
+ parts.push("## Acceptance Criteria");
86
+ parts.push("");
87
+ for (const criterion of story.acceptanceCriteria) {
88
+ parts.push(`- [ ] ${criterion}`);
89
+ }
90
+ parts.push("");
91
+
92
+ // Dependencies (if any)
93
+ if (story.dependencies && story.dependencies.length > 0) {
94
+ parts.push("## Dependencies");
95
+ parts.push("");
96
+ parts.push("This story depends on:");
97
+ for (const depId of story.dependencies) {
98
+ parts.push(`- ${depId}`);
99
+ }
100
+ parts.push("");
101
+ }
102
+
103
+ // Phase (if present)
104
+ if (story.phase) {
105
+ parts.push(`**Phase:** ${story.phase}`);
106
+ parts.push("");
107
+ }
108
+
109
+ // Parallel (if applicable)
110
+ if (story.parallel) {
111
+ parts.push("**Can be executed in parallel** with other stories");
112
+ parts.push("");
113
+ }
114
+
115
+ // Research (if required)
116
+ if (story.research) {
117
+ parts.push("⚠️ **Research Phase Required**: This story requires a research phase before implementation.");
118
+ parts.push("");
119
+ }
120
+
121
+ // Notes (if present)
122
+ if (story.notes && story.notes.trim() !== "") {
123
+ parts.push("## Notes");
124
+ parts.push("");
125
+ parts.push(story.notes);
126
+ parts.push("");
127
+ }
128
+
129
+ return parts.join("\n");
130
+ }
131
+
132
+ /**
133
+ * Create a GitHub issue for a user story
134
+ */
135
+ async function createIssue(story: UserStory, labels: string[]): Promise<string> {
136
+ const title = `[${story.id}] ${story.title}`;
137
+ const body = formatIssueBody(story);
138
+
139
+ // Build gh command
140
+ const args = [
141
+ "issue",
142
+ "create",
143
+ "--title",
144
+ title,
145
+ "--body",
146
+ body,
147
+ ];
148
+
149
+ // Add labels
150
+ if (labels.length > 0) {
151
+ args.push("--label");
152
+ args.push(labels.join(","));
153
+ }
154
+
155
+ const proc = Bun.spawn(["gh", ...args]);
156
+ const output = await new Response(proc.stdout).text();
157
+ const stderr = await new Response(proc.stderr).text();
158
+ const exitCode = await proc.exited;
159
+
160
+ if (exitCode !== 0) {
161
+ throw new Error(`Failed to create issue for ${story.id}: ${stderr}`);
162
+ }
163
+
164
+ // Extract issue URL from output (gh returns the URL)
165
+ return output.trim();
166
+ }
167
+
168
+ /**
169
+ * Check if gh CLI is installed
170
+ */
171
+ async function checkGhCLI(): Promise<boolean> {
172
+ try {
173
+ const proc = Bun.spawn(["gh", "--version"]);
174
+ const exitCode = await proc.exited;
175
+ return exitCode === 0;
176
+ } catch (error) {
177
+ // Log unexpected errors (not just "command not found")
178
+ if (error instanceof Error && !error.message.includes("ENOENT")) {
179
+ console.warn(`Unexpected error checking gh CLI: ${error.message}`);
180
+ }
181
+ return false;
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Convert PRD user stories to GitHub issues
187
+ */
188
+ export async function generateGitHubIssues(
189
+ prd: PRD,
190
+ options: {
191
+ dryRun?: boolean;
192
+ onlyIncomplete?: boolean;
193
+ } = {}
194
+ ): Promise<{ created: number; skipped: number; errors: string[] }> {
195
+ const { dryRun = false, onlyIncomplete = true } = options;
196
+
197
+ // Check gh CLI
198
+ const hasGh = await checkGhCLI();
199
+ if (!hasGh) {
200
+ throw new Error("GitHub CLI (gh) is not installed. Install it from: https://cli.github.com/");
201
+ }
202
+
203
+ // Validate git remote
204
+ console.log(chalk.dim("Validating git remote..."));
205
+ const remote = await validateGitRemote();
206
+ console.log(chalk.dim(`Repository: ${remote.owner}/${remote.repo}`));
207
+
208
+ // Filter stories
209
+ const stories = onlyIncomplete
210
+ ? prd.userStories.filter((s) => !s.passes)
211
+ : prd.userStories;
212
+
213
+ if (stories.length === 0) {
214
+ console.log(chalk.yellow("No stories to convert to issues."));
215
+ return { created: 0, skipped: 0, errors: [] };
216
+ }
217
+
218
+ console.log(chalk.dim(`Found ${stories.length} stories to convert\n`));
219
+
220
+ let created = 0;
221
+ let skipped = 0;
222
+ const errors: string[] = [];
223
+
224
+ for (const story of stories) {
225
+ const labels = inferLabels(story);
226
+
227
+ if (dryRun) {
228
+ console.log(chalk.cyan(`[DRY RUN] ${story.id}: ${story.title}`));
229
+ console.log(chalk.dim(` Labels: ${labels.join(", ")}`));
230
+ if (story.dependencies && story.dependencies.length > 0) {
231
+ console.log(chalk.dim(` Dependencies: ${story.dependencies.join(", ")}`));
232
+ }
233
+ created++;
234
+ } else {
235
+ try {
236
+ const issueUrl = await createIssue(story, labels);
237
+ console.log(chalk.green(`✓ ${story.id}: ${story.title}`));
238
+ console.log(chalk.dim(` ${issueUrl}`));
239
+ created++;
240
+ } catch (error) {
241
+ console.log(chalk.red(`✗ ${story.id}: ${story.title}`));
242
+ console.log(chalk.dim(` ${(error as Error).message}`));
243
+ errors.push(`${story.id}: ${(error as Error).message}`);
244
+ }
245
+ }
246
+ }
247
+
248
+ return { created, skipped, errors };
249
+ }
@@ -0,0 +1,281 @@
1
+ /**
2
+ * PRD Parser
3
+ *
4
+ * Parses PRD markdown files and converts them to prd.json format
5
+ */
6
+
7
+ import { PRDSchema, type PRD, type UserStory } from "./types";
8
+
9
+ /**
10
+ * Check if a criterion line is valid (not a file path, divider, etc.)
11
+ */
12
+ function isValidCriterion(text: string): boolean {
13
+ // Skip if it looks like a file path
14
+ if (text.match(/^`[^`]+\.(ts|tsx|js|jsx|css|json|md)`$/)) {
15
+ return false;
16
+ }
17
+ // Skip if it's just a section marker
18
+ if (text.startsWith("**")) {
19
+ return false;
20
+ }
21
+ // Skip if it's empty or too short
22
+ if (text.length < 3) {
23
+ return false;
24
+ }
25
+ return true;
26
+ }
27
+
28
+ /**
29
+ * Parse a PRD markdown file into structured format
30
+ *
31
+ * Supports multiple story formats:
32
+ * - ### US-001: Title
33
+ * - ### Story 1: Title
34
+ * - ### 1. Title
35
+ *
36
+ * Supports multiple acceptance criteria formats:
37
+ * - [ ] Criterion
38
+ * - [x] Criterion
39
+ * - Criterion (plain bullet)
40
+ */
41
+ export function parsePRDMarkdown(content: string): Partial<PRD> {
42
+ const lines = content.split("\n");
43
+ const prd: Partial<PRD> = {
44
+ userStories: [],
45
+ };
46
+
47
+ let currentSection = "";
48
+ let currentStory: Partial<UserStory> | null = null;
49
+ let storyCount = 0;
50
+ let inAcceptanceCriteria = false;
51
+ let descriptionLines: string[] = [];
52
+
53
+ for (const line of lines) {
54
+ const trimmed = line.trim();
55
+
56
+ // Parse title (# PRD: Title or # Title)
57
+ if (trimmed.startsWith("# ") && !trimmed.startsWith("## ") && !trimmed.startsWith("### ")) {
58
+ prd.project = trimmed.replace(/^#\s*(PRD:\s*)?/, "").trim();
59
+ continue;
60
+ }
61
+
62
+ // Parse section headers (## Section)
63
+ if (trimmed.startsWith("## ")) {
64
+ currentSection = trimmed.replace("## ", "").toLowerCase();
65
+ inAcceptanceCriteria = false;
66
+ continue;
67
+ }
68
+
69
+ // Parse user stories - multiple formats supported
70
+ // ### US-001: Title
71
+ // ### Story 1: Title
72
+ // ### 1. Title
73
+ const storyMatch = trimmed.match(/^###\s+(?:US-(\d+)|Story\s+(\d+)|(\d+)\.?)\s*:?\s*(.*)$/i);
74
+ if (storyMatch) {
75
+ // Save previous story
76
+ if (currentStory && currentStory.id) {
77
+ if (descriptionLines.length > 0 && !currentStory.description) {
78
+ currentStory.description = descriptionLines.join(" ").trim();
79
+ }
80
+ prd.userStories!.push(currentStory as UserStory);
81
+ }
82
+
83
+ storyCount++;
84
+ const storyNum = storyMatch[1] || storyMatch[2] || storyMatch[3] || String(storyCount);
85
+ currentStory = {
86
+ id: `US-${storyNum.padStart(3, "0")}`,
87
+ title: storyMatch[4]?.trim() || "",
88
+ description: "",
89
+ acceptanceCriteria: [],
90
+ priority: storyCount,
91
+ passes: false,
92
+ notes: "",
93
+ dependencies: undefined,
94
+ parallel: undefined,
95
+ phase: undefined,
96
+ };
97
+ inAcceptanceCriteria = false;
98
+ descriptionLines = [];
99
+ continue;
100
+ }
101
+
102
+ // Check for acceptance criteria section header
103
+ if (currentStory && trimmed.match(/^\*\*Acceptance Criteria:?\*\*$/i)) {
104
+ inAcceptanceCriteria = true;
105
+ continue;
106
+ }
107
+
108
+ // Parse story description (single line after **Description:**)
109
+ if (currentStory && trimmed.startsWith("**Description:**")) {
110
+ currentStory.description = trimmed.replace("**Description:**", "").trim();
111
+ inAcceptanceCriteria = false;
112
+ continue;
113
+ }
114
+
115
+ // Parse dependencies (Dependencies: US-001, US-002)
116
+ if (currentStory && trimmed.match(/^\*\*Dependencies:?\*\*/i)) {
117
+ const deps = trimmed
118
+ .replace(/^\*\*Dependencies:?\*\*/i, "")
119
+ .trim()
120
+ .split(/[,;]/)
121
+ .map((d) => d.trim())
122
+ .filter(Boolean);
123
+ if (deps.length > 0) {
124
+ currentStory.dependencies = deps;
125
+ }
126
+ inAcceptanceCriteria = false;
127
+ continue;
128
+ }
129
+
130
+ // Parse parallel flag (Parallel: true/yes)
131
+ if (currentStory && trimmed.match(/^\*\*Parallel:?\*\*/i)) {
132
+ const value = trimmed.replace(/^\*\*Parallel:?\*\*/i, "").trim().toLowerCase();
133
+ currentStory.parallel = value === "true" || value === "yes";
134
+ inAcceptanceCriteria = false;
135
+ continue;
136
+ }
137
+
138
+ // Parse phase (Phase: Setup)
139
+ if (currentStory && trimmed.match(/^\*\*Phase:?\*\*/i)) {
140
+ const phase = trimmed.replace(/^\*\*Phase:?\*\*/i, "").trim();
141
+ if (phase) {
142
+ currentStory.phase = phase;
143
+ }
144
+ inAcceptanceCriteria = false;
145
+ continue;
146
+ }
147
+
148
+ // Parse research flag (Research: true/yes)
149
+ if (currentStory && trimmed.match(/^\*\*Research:?\*\*/i)) {
150
+ const value = trimmed.replace(/^\*\*Research:?\*\*/i, "").trim().toLowerCase();
151
+ currentStory.research = value === "true" || value === "yes";
152
+ inAcceptanceCriteria = false;
153
+ continue;
154
+ }
155
+
156
+ // Check for section headers within story that end acceptance criteria
157
+ if (currentStory && trimmed.match(/^\*\*(Files|Note|Technical|Design)/i)) {
158
+ inAcceptanceCriteria = false;
159
+ continue;
160
+ }
161
+
162
+ // Parse acceptance criteria - multiple formats
163
+ // - [ ] Criterion
164
+ // - [x] Criterion
165
+ // - Criterion (plain bullet, only in acceptance criteria section)
166
+ if (currentStory && trimmed.startsWith("-")) {
167
+ // Skip dividers like "---"
168
+ if (trimmed.match(/^-+$/)) {
169
+ inAcceptanceCriteria = false;
170
+ continue;
171
+ }
172
+
173
+ // Checkbox format
174
+ if (trimmed.match(/^-\s*\[.\]/)) {
175
+ const criterion = trimmed.replace(/^-\s*\[.\]\s*/, "").trim();
176
+ if (criterion && isValidCriterion(criterion)) {
177
+ currentStory.acceptanceCriteria!.push(criterion);
178
+ }
179
+ inAcceptanceCriteria = true;
180
+ continue;
181
+ }
182
+ // Plain bullet format (only if we're in acceptance criteria section)
183
+ if (inAcceptanceCriteria) {
184
+ const criterion = trimmed.replace(/^-\s*/, "").trim();
185
+ if (criterion && isValidCriterion(criterion)) {
186
+ currentStory.acceptanceCriteria!.push(criterion);
187
+ }
188
+ continue;
189
+ }
190
+ }
191
+
192
+ // Collect description lines (paragraphs after story header, before acceptance criteria)
193
+ if (currentStory && !inAcceptanceCriteria && trimmed && !trimmed.startsWith("**") && !trimmed.startsWith("#")) {
194
+ descriptionLines.push(trimmed);
195
+ }
196
+
197
+ // Parse description if in introduction/overview section (for PRD description)
198
+ if ((currentSection === "introduction" || currentSection === "overview") && !currentStory) {
199
+ if (trimmed && !prd.description && !trimmed.startsWith("**")) {
200
+ prd.description = trimmed;
201
+ }
202
+ }
203
+ }
204
+
205
+ // Save last story
206
+ if (currentStory && currentStory.id) {
207
+ if (descriptionLines.length > 0 && !currentStory.description) {
208
+ currentStory.description = descriptionLines.join(" ").trim();
209
+ }
210
+ prd.userStories!.push(currentStory as UserStory);
211
+ }
212
+
213
+ return prd;
214
+ }
215
+
216
+ /**
217
+ * Generate branch name from project name
218
+ */
219
+ export function generateBranchName(projectName: string, featureName?: string): string {
220
+ let kebab = projectName
221
+ .toLowerCase()
222
+ .replace(/[^a-z0-9]+/g, "-")
223
+ .replace(/^-|-$/g, "");
224
+
225
+ // If featureName has a number prefix (NNN-name), prepend it to branch name
226
+ if (featureName) {
227
+ const match = featureName.match(/^(\d{3})-/);
228
+ if (match) {
229
+ const numberPrefix = match[1];
230
+ kebab = `${numberPrefix}-${kebab}`;
231
+ }
232
+ }
233
+
234
+ return `ralph/${kebab}`;
235
+ }
236
+
237
+ /**
238
+ * Convert parsed PRD to complete PRD with defaults
239
+ */
240
+ export function createPRD(parsed: Partial<PRD>, featureName?: string): PRD {
241
+ const project = parsed.project ?? "Unnamed Project";
242
+ const prd: PRD = {
243
+ project,
244
+ branchName: parsed.branchName ?? generateBranchName(project, featureName),
245
+ description: parsed.description ?? "",
246
+ userStories: (parsed.userStories ?? []).map((story, index) => ({
247
+ id: story.id ?? `US-${String(index + 1).padStart(3, "0")}`,
248
+ title: story.title ?? "",
249
+ description: story.description ?? "",
250
+ acceptanceCriteria: story.acceptanceCriteria ?? [],
251
+ priority: story.priority ?? index + 1,
252
+ passes: story.passes ?? false,
253
+ notes: story.notes ?? "",
254
+ dependencies: story.dependencies,
255
+ parallel: story.parallel,
256
+ phase: story.phase,
257
+ research: story.research,
258
+ })),
259
+ };
260
+
261
+ // Validate
262
+ return PRDSchema.parse(prd);
263
+ }
264
+
265
+ /**
266
+ * Load PRD from JSON file
267
+ */
268
+ export async function loadPRD(path: string): Promise<PRD> {
269
+ const content = await Bun.file(path).text();
270
+ const json = JSON.parse(content);
271
+ return PRDSchema.parse(json);
272
+ }
273
+
274
+ /**
275
+ * Save PRD to JSON file
276
+ */
277
+ export async function savePRD(prd: PRD, path: string): Promise<void> {
278
+ const validated = PRDSchema.parse(prd);
279
+ const content = JSON.stringify(validated, null, 2);
280
+ await Bun.write(path, content);
281
+ }