claude-autopm 2.3.0 ā 2.4.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.
- package/bin/autopm.js +2 -2
- package/lib/cli/commands/epic.js +777 -0
- package/lib/services/EpicService.js +284 -10
- package/package.json +1 -1
package/bin/autopm.js
CHANGED
|
@@ -180,8 +180,8 @@ function main() {
|
|
|
180
180
|
.command(require('./commands/config'))
|
|
181
181
|
// MCP management command
|
|
182
182
|
.command(require('./commands/mcp'))
|
|
183
|
-
// Epic management command
|
|
184
|
-
.command(require('
|
|
183
|
+
// Epic management command (STANDALONE)
|
|
184
|
+
.command(require('../lib/cli/commands/epic'))
|
|
185
185
|
// PRD management command (STANDALONE)
|
|
186
186
|
.command(require('../lib/cli/commands/prd'))
|
|
187
187
|
// Task management command (STANDALONE)
|
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI Epic Commands
|
|
3
|
+
*
|
|
4
|
+
* Provides Epic management commands.
|
|
5
|
+
* Implements subcommands for epic lifecycle management.
|
|
6
|
+
*
|
|
7
|
+
* @module cli/commands/epic
|
|
8
|
+
* @requires ../../services/EpicService
|
|
9
|
+
* @requires fs-extra
|
|
10
|
+
* @requires ora
|
|
11
|
+
* @requires chalk
|
|
12
|
+
* @requires path
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const EpicService = require('../../services/EpicService');
|
|
16
|
+
const fs = require('fs-extra');
|
|
17
|
+
const ora = require('ora');
|
|
18
|
+
const chalk = require('chalk');
|
|
19
|
+
const path = require('path');
|
|
20
|
+
const { spawn } = require('child_process');
|
|
21
|
+
const readline = require('readline');
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get epic directory path
|
|
25
|
+
* @param {string} name - Epic name
|
|
26
|
+
* @returns {string} Full path to epic directory
|
|
27
|
+
*/
|
|
28
|
+
function getEpicPath(name) {
|
|
29
|
+
return path.join(process.cwd(), '.claude', 'epics', name);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Read epic file
|
|
34
|
+
* @param {string} name - Epic name
|
|
35
|
+
* @returns {Promise<string>} Epic content
|
|
36
|
+
* @throws {Error} If file doesn't exist or can't be read
|
|
37
|
+
*/
|
|
38
|
+
async function readEpicFile(name) {
|
|
39
|
+
const epicPath = path.join(getEpicPath(name), 'epic.md');
|
|
40
|
+
|
|
41
|
+
const exists = await fs.pathExists(epicPath);
|
|
42
|
+
if (!exists) {
|
|
43
|
+
throw new Error(`Epic file not found: ${epicPath}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return await fs.readFile(epicPath, 'utf8');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* List all epics
|
|
51
|
+
* @param {Object} argv - Command arguments
|
|
52
|
+
*/
|
|
53
|
+
async function epicList(argv) {
|
|
54
|
+
const spinner = ora('Loading epics...').start();
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const epicService = new EpicService();
|
|
58
|
+
const epics = await epicService.listEpics();
|
|
59
|
+
|
|
60
|
+
if (epics.length === 0) {
|
|
61
|
+
spinner.info(chalk.yellow('No epics found'));
|
|
62
|
+
console.log(chalk.yellow('\nCreate your first epic with: autopm epic new <name>'));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
spinner.succeed(chalk.green(`Found ${epics.length} epic(s)`));
|
|
67
|
+
|
|
68
|
+
// Group by status
|
|
69
|
+
const grouped = {
|
|
70
|
+
'Planning': [],
|
|
71
|
+
'In Progress': [],
|
|
72
|
+
'Completed': []
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
epics.forEach(epic => {
|
|
76
|
+
const category = epicService.categorizeStatus(epic.status);
|
|
77
|
+
if (category === 'planning') {
|
|
78
|
+
grouped['Planning'].push(epic);
|
|
79
|
+
} else if (category === 'in_progress') {
|
|
80
|
+
grouped['In Progress'].push(epic);
|
|
81
|
+
} else if (category === 'done') {
|
|
82
|
+
grouped['Completed'].push(epic);
|
|
83
|
+
} else {
|
|
84
|
+
grouped['Planning'].push(epic);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Display grouped epics
|
|
89
|
+
console.log(chalk.green('\nš Epics:\n'));
|
|
90
|
+
|
|
91
|
+
Object.entries(grouped).forEach(([status, statusEpics]) => {
|
|
92
|
+
if (statusEpics.length > 0) {
|
|
93
|
+
const statusColor = status === 'Completed' ? chalk.green :
|
|
94
|
+
status === 'In Progress' ? chalk.yellow :
|
|
95
|
+
chalk.blue;
|
|
96
|
+
|
|
97
|
+
console.log(statusColor(`\n${status}:`));
|
|
98
|
+
|
|
99
|
+
statusEpics.forEach((epic, index) => {
|
|
100
|
+
console.log(` ${index + 1}. ${chalk.bold(epic.name)}`);
|
|
101
|
+
console.log(` ${chalk.dim('Progress:')} ${epic.progress.padEnd(10)} ${chalk.dim('Tasks:')} ${epic.taskCount}`);
|
|
102
|
+
|
|
103
|
+
if (epic.githubIssue) {
|
|
104
|
+
console.log(` ${chalk.dim('GitHub:')} #${epic.githubIssue}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (epic.created) {
|
|
108
|
+
console.log(` ${chalk.dim('Created:')} ${epic.created.split('T')[0]}`);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
console.log(chalk.dim(`\nTotal: ${epics.length} epic(s)`));
|
|
115
|
+
console.log(chalk.dim('Use: autopm epic show <name> to view details\n'));
|
|
116
|
+
|
|
117
|
+
} catch (error) {
|
|
118
|
+
spinner.fail(chalk.red('Failed to list epics'));
|
|
119
|
+
console.error(chalk.red(`\nError: ${error.message}`));
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Show epic content
|
|
125
|
+
* @param {Object} argv - Command arguments
|
|
126
|
+
*/
|
|
127
|
+
async function epicShow(argv) {
|
|
128
|
+
const spinner = ora(`Loading epic: ${argv.name}`).start();
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
const content = await readEpicFile(argv.name);
|
|
132
|
+
const epicService = new EpicService();
|
|
133
|
+
const metadata = epicService.parseFrontmatter(content);
|
|
134
|
+
|
|
135
|
+
spinner.succeed(chalk.green('Epic loaded'));
|
|
136
|
+
|
|
137
|
+
// Display metadata table
|
|
138
|
+
console.log('\n' + chalk.bold('š Epic Metadata') + '\n');
|
|
139
|
+
console.log(chalk.gray('ā'.repeat(50)) + '\n');
|
|
140
|
+
|
|
141
|
+
if (metadata) {
|
|
142
|
+
console.log(chalk.bold('Name: ') + (metadata.name || argv.name));
|
|
143
|
+
console.log(chalk.bold('Status: ') + (metadata.status || 'N/A'));
|
|
144
|
+
console.log(chalk.bold('Progress: ') + (metadata.progress || 'N/A'));
|
|
145
|
+
console.log(chalk.bold('Priority: ') + (metadata.priority || 'N/A'));
|
|
146
|
+
console.log(chalk.bold('Created: ') + (metadata.created ? metadata.created.split('T')[0] : 'N/A'));
|
|
147
|
+
|
|
148
|
+
if (metadata.github) {
|
|
149
|
+
console.log(chalk.bold('GitHub: ') + metadata.github);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
console.log('\n' + chalk.gray('ā'.repeat(80)) + '\n');
|
|
154
|
+
console.log(content);
|
|
155
|
+
console.log('\n' + chalk.gray('ā'.repeat(80)) + '\n');
|
|
156
|
+
|
|
157
|
+
const epicPath = path.join(getEpicPath(argv.name), 'epic.md');
|
|
158
|
+
console.log(chalk.dim(`File: ${epicPath}\n`));
|
|
159
|
+
|
|
160
|
+
} catch (error) {
|
|
161
|
+
spinner.fail(chalk.red('Failed to show epic'));
|
|
162
|
+
|
|
163
|
+
if (error.message.includes('not found')) {
|
|
164
|
+
console.error(chalk.red(`\nError: ${error.message}`));
|
|
165
|
+
console.error(chalk.yellow('Use: autopm epic list to see available epics'));
|
|
166
|
+
} else {
|
|
167
|
+
console.error(chalk.red(`\nError: ${error.message}`));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Create new epic
|
|
174
|
+
* @param {Object} argv - Command arguments
|
|
175
|
+
*/
|
|
176
|
+
async function epicNew(argv) {
|
|
177
|
+
const spinner = ora(`Creating epic: ${argv.name}`).start();
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const epicPath = getEpicPath(argv.name);
|
|
181
|
+
const epicFilePath = path.join(epicPath, 'epic.md');
|
|
182
|
+
|
|
183
|
+
// Check if epic already exists
|
|
184
|
+
const exists = await fs.pathExists(epicFilePath);
|
|
185
|
+
if (exists) {
|
|
186
|
+
spinner.fail(chalk.red('Epic already exists'));
|
|
187
|
+
console.error(chalk.red(`\nError: Epic file already exists: ${epicFilePath}`));
|
|
188
|
+
console.error(chalk.yellow('Use: autopm epic edit ' + argv.name + ' to modify it'));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Create epic directory
|
|
193
|
+
await fs.ensureDir(epicPath);
|
|
194
|
+
|
|
195
|
+
spinner.stop();
|
|
196
|
+
|
|
197
|
+
// Handle --from-prd flag
|
|
198
|
+
if (argv['from-prd']) {
|
|
199
|
+
return await epicNewFromPRD(argv, epicPath, epicFilePath);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Interactive prompts
|
|
203
|
+
const rl = readline.createInterface({
|
|
204
|
+
input: process.stdin,
|
|
205
|
+
output: process.stdout
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const prompt = (question) => new Promise((resolve) => {
|
|
209
|
+
rl.question(question, resolve);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
console.log(chalk.cyan('\nš Creating new epic...\n'));
|
|
213
|
+
|
|
214
|
+
const title = await prompt(chalk.cyan('Title [' + argv.name + ']: ')) || argv.name;
|
|
215
|
+
const description = await prompt(chalk.cyan('Description: '));
|
|
216
|
+
const priority = await prompt(chalk.cyan('Priority (P0/P1/P2/P3) [P2]: ')) || 'P2';
|
|
217
|
+
|
|
218
|
+
rl.close();
|
|
219
|
+
|
|
220
|
+
// Build epic content
|
|
221
|
+
const now = new Date().toISOString();
|
|
222
|
+
const frontmatter = `---
|
|
223
|
+
name: ${argv.name}
|
|
224
|
+
status: backlog
|
|
225
|
+
created: ${now}
|
|
226
|
+
progress: 0%
|
|
227
|
+
prd: .claude/prds/${argv.name}.md
|
|
228
|
+
github: [Will be updated when synced to GitHub]
|
|
229
|
+
priority: ${priority}
|
|
230
|
+
---`;
|
|
231
|
+
|
|
232
|
+
const content = `${frontmatter}
|
|
233
|
+
|
|
234
|
+
# Epic: ${title}
|
|
235
|
+
|
|
236
|
+
## Overview
|
|
237
|
+
${description || 'Epic description goes here.'}
|
|
238
|
+
|
|
239
|
+
## Task Breakdown
|
|
240
|
+
|
|
241
|
+
Tasks will be added as epic progresses.
|
|
242
|
+
`;
|
|
243
|
+
|
|
244
|
+
// Write epic file
|
|
245
|
+
await fs.writeFile(epicFilePath, content);
|
|
246
|
+
|
|
247
|
+
console.log(chalk.green('\nā
Epic created successfully!'));
|
|
248
|
+
console.log(chalk.cyan(`š File: ${epicFilePath}\n`));
|
|
249
|
+
|
|
250
|
+
// Show next steps
|
|
251
|
+
console.log(chalk.bold('š What You Can Do Next:\n'));
|
|
252
|
+
console.log(` ${chalk.cyan('1.')} Edit epic: ${chalk.yellow('autopm epic edit ' + argv.name)}`);
|
|
253
|
+
console.log(` ${chalk.cyan('2.')} Check status: ${chalk.yellow('autopm epic status ' + argv.name)}`);
|
|
254
|
+
console.log(` ${chalk.cyan('3.')} Start working: ${chalk.yellow('autopm epic start ' + argv.name)}`);
|
|
255
|
+
console.log(` ${chalk.cyan('4.')} Sync to GitHub: ${chalk.yellow('autopm epic sync ' + argv.name)}\n`);
|
|
256
|
+
|
|
257
|
+
} catch (error) {
|
|
258
|
+
spinner.fail(chalk.red('Failed to create epic'));
|
|
259
|
+
console.error(chalk.red(`\nError: ${error.message}`));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Create new epic from PRD
|
|
265
|
+
* @param {Object} argv - Command arguments
|
|
266
|
+
* @param {string} epicPath - Path to epic directory
|
|
267
|
+
* @param {string} epicFilePath - Path to epic.md file
|
|
268
|
+
*/
|
|
269
|
+
async function epicNewFromPRD(argv, epicPath, epicFilePath) {
|
|
270
|
+
const spinner = ora('Creating epic from PRD...').start();
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
const prdPath = path.join(process.cwd(), '.claude', 'prds', `${argv['from-prd']}.md`);
|
|
274
|
+
const prdExists = await fs.pathExists(prdPath);
|
|
275
|
+
|
|
276
|
+
if (!prdExists) {
|
|
277
|
+
spinner.fail(chalk.red('PRD not found'));
|
|
278
|
+
console.error(chalk.red(`\nError: PRD file not found: ${prdPath}`));
|
|
279
|
+
console.error(chalk.yellow('Use: autopm prd list to see available PRDs'));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const prdContent = await fs.readFile(prdPath, 'utf8');
|
|
284
|
+
const epicService = new EpicService();
|
|
285
|
+
const prdMetadata = epicService.parseFrontmatter(prdContent);
|
|
286
|
+
|
|
287
|
+
// Build epic from PRD
|
|
288
|
+
const now = new Date().toISOString();
|
|
289
|
+
const frontmatter = `---
|
|
290
|
+
name: ${argv.name}
|
|
291
|
+
status: backlog
|
|
292
|
+
created: ${now}
|
|
293
|
+
progress: 0%
|
|
294
|
+
prd: .claude/prds/${argv['from-prd']}.md
|
|
295
|
+
github: [Will be updated when synced to GitHub]
|
|
296
|
+
priority: ${prdMetadata?.priority || 'P2'}
|
|
297
|
+
---`;
|
|
298
|
+
|
|
299
|
+
const content = `${frontmatter}
|
|
300
|
+
|
|
301
|
+
# Epic: ${argv.name}
|
|
302
|
+
|
|
303
|
+
## Overview
|
|
304
|
+
Created from PRD: ${argv['from-prd']}
|
|
305
|
+
|
|
306
|
+
${prdContent.split('## Problem Statement')[1]?.split('##')[0] || ''}
|
|
307
|
+
|
|
308
|
+
## Task Breakdown
|
|
309
|
+
|
|
310
|
+
Tasks will be generated from PRD analysis.
|
|
311
|
+
`;
|
|
312
|
+
|
|
313
|
+
await fs.writeFile(epicFilePath, content);
|
|
314
|
+
|
|
315
|
+
spinner.succeed(chalk.green('Epic created from PRD'));
|
|
316
|
+
|
|
317
|
+
console.log(chalk.green('\nā
Epic created successfully!'));
|
|
318
|
+
console.log(chalk.cyan(`š File: ${epicFilePath}`));
|
|
319
|
+
console.log(chalk.cyan(`š Source PRD: ${prdPath}\n`));
|
|
320
|
+
|
|
321
|
+
} catch (error) {
|
|
322
|
+
spinner.fail(chalk.red('Failed to create epic from PRD'));
|
|
323
|
+
console.error(chalk.red(`\nError: ${error.message}`));
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Edit epic in editor
|
|
329
|
+
* @param {Object} argv - Command arguments
|
|
330
|
+
*/
|
|
331
|
+
async function epicEdit(argv) {
|
|
332
|
+
const spinner = ora(`Opening epic: ${argv.name}`).start();
|
|
333
|
+
|
|
334
|
+
try {
|
|
335
|
+
const epicFilePath = path.join(getEpicPath(argv.name), 'epic.md');
|
|
336
|
+
|
|
337
|
+
// Check if file exists
|
|
338
|
+
const exists = await fs.pathExists(epicFilePath);
|
|
339
|
+
if (!exists) {
|
|
340
|
+
spinner.fail(chalk.red('Epic not found'));
|
|
341
|
+
console.error(chalk.red(`\nError: Epic file not found: ${epicFilePath}`));
|
|
342
|
+
console.error(chalk.yellow('Use: autopm epic list to see available epics'));
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
spinner.succeed(chalk.green('Opening editor...'));
|
|
347
|
+
|
|
348
|
+
// Determine editor
|
|
349
|
+
const editor = process.env.EDITOR || process.env.VISUAL || 'nano';
|
|
350
|
+
|
|
351
|
+
// Spawn editor
|
|
352
|
+
const child = spawn(editor, [epicFilePath], {
|
|
353
|
+
stdio: 'inherit',
|
|
354
|
+
cwd: process.cwd()
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// Wait for editor to close
|
|
358
|
+
await new Promise((resolve, reject) => {
|
|
359
|
+
child.on('close', (code) => {
|
|
360
|
+
if (code === 0) {
|
|
361
|
+
console.log(chalk.green('\nā Epic saved'));
|
|
362
|
+
resolve();
|
|
363
|
+
} else {
|
|
364
|
+
reject(new Error(`Editor exited with code ${code}`));
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
child.on('error', reject);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
} catch (error) {
|
|
371
|
+
spinner.fail(chalk.red('Failed to edit epic'));
|
|
372
|
+
console.error(chalk.red(`\nError: ${error.message}`));
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Show epic status
|
|
378
|
+
* @param {Object} argv - Command arguments
|
|
379
|
+
*/
|
|
380
|
+
async function epicStatus(argv) {
|
|
381
|
+
const spinner = ora(`Analyzing epic: ${argv.name}`).start();
|
|
382
|
+
|
|
383
|
+
try {
|
|
384
|
+
const epicService = new EpicService();
|
|
385
|
+
const epic = await epicService.getEpic(argv.name);
|
|
386
|
+
|
|
387
|
+
spinner.succeed(chalk.green('Status analyzed'));
|
|
388
|
+
|
|
389
|
+
// Display status
|
|
390
|
+
console.log('\n' + chalk.bold('š Epic Status Report') + '\n');
|
|
391
|
+
console.log(chalk.gray('ā'.repeat(50)) + '\n');
|
|
392
|
+
|
|
393
|
+
console.log(chalk.bold('Metadata:'));
|
|
394
|
+
console.log(` Name: ${epic.name}`);
|
|
395
|
+
console.log(` Status: ${chalk.yellow(epic.status)}`);
|
|
396
|
+
console.log(` Priority: ${chalk.red(epic.priority || 'P2')}`);
|
|
397
|
+
console.log(` Created: ${epic.created ? epic.created.split('T')[0] : 'N/A'}`);
|
|
398
|
+
|
|
399
|
+
if (epic.github && epic.githubIssue) {
|
|
400
|
+
console.log(` GitHub: #${epic.githubIssue}`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Progress bar
|
|
404
|
+
const progressPercent = parseInt(epic.progress) || 0;
|
|
405
|
+
const progressBar = epicService.generateProgressBar(progressPercent, 20);
|
|
406
|
+
|
|
407
|
+
console.log('\n' + chalk.bold('Progress:') + ` ${progressPercent}%`);
|
|
408
|
+
console.log(` [${progressPercent >= 80 ? chalk.green(progressBar.bar.slice(1, -1)) :
|
|
409
|
+
progressPercent >= 50 ? chalk.yellow(progressBar.bar.slice(1, -1)) :
|
|
410
|
+
chalk.red(progressBar.bar.slice(1, -1))}]`);
|
|
411
|
+
|
|
412
|
+
// Task statistics
|
|
413
|
+
console.log('\n' + chalk.bold('Tasks:'));
|
|
414
|
+
console.log(` Total: ${epic.taskCount}`);
|
|
415
|
+
|
|
416
|
+
console.log('\n' + chalk.gray('ā'.repeat(50)) + '\n');
|
|
417
|
+
|
|
418
|
+
const epicPath = path.join(getEpicPath(argv.name), 'epic.md');
|
|
419
|
+
console.log(chalk.dim(`File: ${epicPath}\n`));
|
|
420
|
+
|
|
421
|
+
} catch (error) {
|
|
422
|
+
spinner.fail(chalk.red('Failed to analyze status'));
|
|
423
|
+
|
|
424
|
+
if (error.message.includes('not found')) {
|
|
425
|
+
console.error(chalk.red(`\nError: ${error.message}`));
|
|
426
|
+
} else {
|
|
427
|
+
console.error(chalk.red(`\nError: ${error.message}`));
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Validate epic structure
|
|
434
|
+
* @param {Object} argv - Command arguments
|
|
435
|
+
*/
|
|
436
|
+
async function epicValidate(argv) {
|
|
437
|
+
const spinner = ora(`Validating epic: ${argv.name}`).start();
|
|
438
|
+
|
|
439
|
+
try {
|
|
440
|
+
const epicService = new EpicService();
|
|
441
|
+
const result = await epicService.validateEpicStructure(argv.name);
|
|
442
|
+
|
|
443
|
+
if (result.valid) {
|
|
444
|
+
spinner.succeed(chalk.green('Epic is valid'));
|
|
445
|
+
console.log(chalk.green('\nā Validation passed - Epic structure is correct'));
|
|
446
|
+
} else {
|
|
447
|
+
spinner.fail(chalk.red(`Epic validation failed - ${result.issues.length} issues found`));
|
|
448
|
+
console.error(chalk.red(`\nā Validation failed - ${result.issues.length} issue(s):`));
|
|
449
|
+
result.issues.forEach((issue, index) => {
|
|
450
|
+
console.error(chalk.red(` ${index + 1}. ${issue}`));
|
|
451
|
+
});
|
|
452
|
+
process.exit(1);
|
|
453
|
+
}
|
|
454
|
+
} catch (error) {
|
|
455
|
+
spinner.fail(chalk.red('Failed to validate epic'));
|
|
456
|
+
|
|
457
|
+
if (error.message.includes('not found')) {
|
|
458
|
+
console.error(chalk.red(`\nā Error: Epic not found`));
|
|
459
|
+
console.error(chalk.red(` ${error.message}`));
|
|
460
|
+
console.error(chalk.yellow('\nš” Use: autopm epic list to see available epics'));
|
|
461
|
+
} else {
|
|
462
|
+
console.error(chalk.red(`\nā Error: ${error.message}`));
|
|
463
|
+
}
|
|
464
|
+
process.exit(1);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Start working on epic
|
|
470
|
+
* @param {Object} argv - Command arguments
|
|
471
|
+
*/
|
|
472
|
+
async function epicStart(argv) {
|
|
473
|
+
const spinner = ora(`Starting epic: ${argv.name}`).start();
|
|
474
|
+
|
|
475
|
+
try {
|
|
476
|
+
const epicFilePath = path.join(getEpicPath(argv.name), 'epic.md');
|
|
477
|
+
|
|
478
|
+
// Check if file exists
|
|
479
|
+
const exists = await fs.pathExists(epicFilePath);
|
|
480
|
+
if (!exists) {
|
|
481
|
+
spinner.fail(chalk.red('Epic not found'));
|
|
482
|
+
console.error(chalk.red(`\nError: Epic file not found: ${epicFilePath}`));
|
|
483
|
+
console.error(chalk.yellow('Use: autopm epic list to see available epics'));
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Read current content
|
|
488
|
+
let content = await fs.readFile(epicFilePath, 'utf8');
|
|
489
|
+
|
|
490
|
+
// Update status to in-progress
|
|
491
|
+
content = content.replace(/^status:\s*.+$/m, 'status: in-progress');
|
|
492
|
+
|
|
493
|
+
// Add started date if not present
|
|
494
|
+
if (!content.includes('started:')) {
|
|
495
|
+
const now = new Date().toISOString();
|
|
496
|
+
content = content.replace(/^(created:.+)$/m, `$1\nstarted: ${now}`);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Write updated content
|
|
500
|
+
await fs.writeFile(epicFilePath, content);
|
|
501
|
+
|
|
502
|
+
spinner.succeed(chalk.green('Epic started'));
|
|
503
|
+
|
|
504
|
+
console.log(chalk.green('\nā
Epic is now in progress!'));
|
|
505
|
+
console.log(chalk.cyan(`š File: ${epicFilePath}\n`));
|
|
506
|
+
|
|
507
|
+
console.log(chalk.bold('š What You Can Do Next:\n'));
|
|
508
|
+
console.log(` ${chalk.cyan('1.')} Check status: ${chalk.yellow('autopm epic status ' + argv.name)}`);
|
|
509
|
+
console.log(` ${chalk.cyan('2.')} Edit epic: ${chalk.yellow('autopm epic edit ' + argv.name)}`);
|
|
510
|
+
console.log(` ${chalk.cyan('3.')} Close when done: ${chalk.yellow('autopm epic close ' + argv.name)}\n`);
|
|
511
|
+
|
|
512
|
+
} catch (error) {
|
|
513
|
+
spinner.fail(chalk.red('Failed to start epic'));
|
|
514
|
+
console.error(chalk.red(`\nError: ${error.message}`));
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Close epic
|
|
520
|
+
* @param {Object} argv - Command arguments
|
|
521
|
+
*/
|
|
522
|
+
async function epicClose(argv) {
|
|
523
|
+
const spinner = ora(`Closing epic: ${argv.name}`).start();
|
|
524
|
+
|
|
525
|
+
try {
|
|
526
|
+
const epicFilePath = path.join(getEpicPath(argv.name), 'epic.md');
|
|
527
|
+
|
|
528
|
+
// Check if file exists
|
|
529
|
+
const exists = await fs.pathExists(epicFilePath);
|
|
530
|
+
if (!exists) {
|
|
531
|
+
spinner.fail(chalk.red('Epic not found'));
|
|
532
|
+
console.error(chalk.red(`\nError: Epic file not found: ${epicFilePath}`));
|
|
533
|
+
console.error(chalk.yellow('Use: autopm epic list to see available epics'));
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Read current content
|
|
538
|
+
let content = await fs.readFile(epicFilePath, 'utf8');
|
|
539
|
+
|
|
540
|
+
// Update status to completed
|
|
541
|
+
content = content.replace(/^status:\s*.+$/m, 'status: completed');
|
|
542
|
+
|
|
543
|
+
// Update progress to 100%
|
|
544
|
+
content = content.replace(/^progress:\s*.+$/m, 'progress: 100%');
|
|
545
|
+
|
|
546
|
+
// Add completed date if not present
|
|
547
|
+
if (!content.includes('completed:')) {
|
|
548
|
+
const now = new Date().toISOString();
|
|
549
|
+
content = content.replace(/^(created:.+)$/m, `$1\ncompleted: ${now}`);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Write updated content
|
|
553
|
+
await fs.writeFile(epicFilePath, content);
|
|
554
|
+
|
|
555
|
+
spinner.succeed(chalk.green('Epic closed'));
|
|
556
|
+
|
|
557
|
+
console.log(chalk.green('\nā
Epic completed!'));
|
|
558
|
+
console.log(chalk.cyan(`š File: ${epicFilePath}\n`));
|
|
559
|
+
|
|
560
|
+
console.log(chalk.bold('š What You Can Do Next:\n'));
|
|
561
|
+
console.log(` ${chalk.cyan('1.')} Check status: ${chalk.yellow('autopm epic status ' + argv.name)}`);
|
|
562
|
+
console.log(` ${chalk.cyan('2.')} View epic: ${chalk.yellow('autopm epic show ' + argv.name)}\n`);
|
|
563
|
+
|
|
564
|
+
} catch (error) {
|
|
565
|
+
spinner.fail(chalk.red('Failed to close epic'));
|
|
566
|
+
console.error(chalk.red(`\nError: ${error.message}`));
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Sync epic with GitHub
|
|
572
|
+
* @param {Object} argv - Command arguments
|
|
573
|
+
*/
|
|
574
|
+
async function epicSync(argv) {
|
|
575
|
+
const spinner = ora(`Syncing epic: ${argv.name}`).start();
|
|
576
|
+
|
|
577
|
+
try {
|
|
578
|
+
const epicService = new EpicService();
|
|
579
|
+
const epic = await epicService.getEpic(argv.name);
|
|
580
|
+
|
|
581
|
+
// TODO: Implement GitHub integration
|
|
582
|
+
// For now, just show a message
|
|
583
|
+
spinner.info(chalk.yellow('GitHub sync not yet implemented'));
|
|
584
|
+
|
|
585
|
+
console.log(chalk.yellow('\nā ļø GitHub sync feature coming soon!\n'));
|
|
586
|
+
|
|
587
|
+
console.log(chalk.dim('This feature will:'));
|
|
588
|
+
console.log(chalk.dim(' - Create GitHub issue if not exists'));
|
|
589
|
+
console.log(chalk.dim(' - Update existing GitHub issue'));
|
|
590
|
+
console.log(chalk.dim(' - Sync epic status and progress\n'));
|
|
591
|
+
|
|
592
|
+
console.log(chalk.bold('For now, you can:'));
|
|
593
|
+
console.log(` ${chalk.cyan('1.')} View epic: ${chalk.yellow('autopm epic show ' + argv.name)}`);
|
|
594
|
+
console.log(` ${chalk.cyan('2.')} Check status: ${chalk.yellow('autopm epic status ' + argv.name)}\n`);
|
|
595
|
+
|
|
596
|
+
} catch (error) {
|
|
597
|
+
spinner.fail(chalk.red('Failed to sync epic'));
|
|
598
|
+
console.error(chalk.red(`\nError: ${error.message}`));
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Command builder - registers all subcommands
|
|
604
|
+
* @param {Object} yargs - Yargs instance
|
|
605
|
+
* @returns {Object} Configured yargs instance
|
|
606
|
+
*/
|
|
607
|
+
function builder(yargs) {
|
|
608
|
+
return yargs
|
|
609
|
+
.command(
|
|
610
|
+
'list',
|
|
611
|
+
'List all epics',
|
|
612
|
+
(yargs) => {
|
|
613
|
+
return yargs
|
|
614
|
+
.option('status', {
|
|
615
|
+
describe: 'Filter by status (planning/in-progress/completed)',
|
|
616
|
+
type: 'string'
|
|
617
|
+
})
|
|
618
|
+
.example('autopm epic list', 'Show all epics')
|
|
619
|
+
.example('autopm epic list --status in-progress', 'Show active epics');
|
|
620
|
+
},
|
|
621
|
+
epicList
|
|
622
|
+
)
|
|
623
|
+
.command(
|
|
624
|
+
'show <name>',
|
|
625
|
+
'Display epic content',
|
|
626
|
+
(yargs) => {
|
|
627
|
+
return yargs
|
|
628
|
+
.positional('name', {
|
|
629
|
+
describe: 'Epic name',
|
|
630
|
+
type: 'string'
|
|
631
|
+
})
|
|
632
|
+
.example('autopm epic show user-auth', 'Display epic details');
|
|
633
|
+
},
|
|
634
|
+
epicShow
|
|
635
|
+
)
|
|
636
|
+
.command(
|
|
637
|
+
'new <name>',
|
|
638
|
+
'Create new epic',
|
|
639
|
+
(yargs) => {
|
|
640
|
+
return yargs
|
|
641
|
+
.positional('name', {
|
|
642
|
+
describe: 'Epic name (use-kebab-case)',
|
|
643
|
+
type: 'string'
|
|
644
|
+
})
|
|
645
|
+
.option('from-prd', {
|
|
646
|
+
describe: 'Create from PRD',
|
|
647
|
+
type: 'string'
|
|
648
|
+
})
|
|
649
|
+
.option('template', {
|
|
650
|
+
describe: 'Template to use',
|
|
651
|
+
type: 'string'
|
|
652
|
+
})
|
|
653
|
+
.example('autopm epic new user-auth', 'Create new epic')
|
|
654
|
+
.example('autopm epic new user-auth --from-prd my-feature', 'Create from PRD');
|
|
655
|
+
},
|
|
656
|
+
epicNew
|
|
657
|
+
)
|
|
658
|
+
.command(
|
|
659
|
+
'edit <name>',
|
|
660
|
+
'Edit epic in your editor',
|
|
661
|
+
(yargs) => {
|
|
662
|
+
return yargs
|
|
663
|
+
.positional('name', {
|
|
664
|
+
describe: 'Epic name',
|
|
665
|
+
type: 'string'
|
|
666
|
+
})
|
|
667
|
+
.example('autopm epic edit user-auth', 'Open epic in editor')
|
|
668
|
+
.example('EDITOR=code autopm epic edit user-auth', 'Open in VS Code');
|
|
669
|
+
},
|
|
670
|
+
epicEdit
|
|
671
|
+
)
|
|
672
|
+
.command(
|
|
673
|
+
'status <name>',
|
|
674
|
+
'Show epic status and progress',
|
|
675
|
+
(yargs) => {
|
|
676
|
+
return yargs
|
|
677
|
+
.positional('name', {
|
|
678
|
+
describe: 'Epic name',
|
|
679
|
+
type: 'string'
|
|
680
|
+
})
|
|
681
|
+
.example('autopm epic status user-auth', 'Show epic status report');
|
|
682
|
+
},
|
|
683
|
+
epicStatus
|
|
684
|
+
)
|
|
685
|
+
.command(
|
|
686
|
+
'validate <name>',
|
|
687
|
+
'Validate epic structure',
|
|
688
|
+
(yargs) => {
|
|
689
|
+
return yargs
|
|
690
|
+
.positional('name', {
|
|
691
|
+
describe: 'Epic name',
|
|
692
|
+
type: 'string'
|
|
693
|
+
})
|
|
694
|
+
.example('autopm epic validate user-auth', 'Validate epic structure');
|
|
695
|
+
},
|
|
696
|
+
epicValidate
|
|
697
|
+
)
|
|
698
|
+
.command(
|
|
699
|
+
'start <name>',
|
|
700
|
+
'Start working on epic',
|
|
701
|
+
(yargs) => {
|
|
702
|
+
return yargs
|
|
703
|
+
.positional('name', {
|
|
704
|
+
describe: 'Epic name',
|
|
705
|
+
type: 'string'
|
|
706
|
+
})
|
|
707
|
+
.example('autopm epic start user-auth', 'Mark epic as in-progress');
|
|
708
|
+
},
|
|
709
|
+
epicStart
|
|
710
|
+
)
|
|
711
|
+
.command(
|
|
712
|
+
'close <name>',
|
|
713
|
+
'Close epic',
|
|
714
|
+
(yargs) => {
|
|
715
|
+
return yargs
|
|
716
|
+
.positional('name', {
|
|
717
|
+
describe: 'Epic name',
|
|
718
|
+
type: 'string'
|
|
719
|
+
})
|
|
720
|
+
.example('autopm epic close user-auth', 'Mark epic as completed');
|
|
721
|
+
},
|
|
722
|
+
epicClose
|
|
723
|
+
)
|
|
724
|
+
.command(
|
|
725
|
+
'sync <name>',
|
|
726
|
+
'Sync epic with GitHub',
|
|
727
|
+
(yargs) => {
|
|
728
|
+
return yargs
|
|
729
|
+
.positional('name', {
|
|
730
|
+
describe: 'Epic name',
|
|
731
|
+
type: 'string'
|
|
732
|
+
})
|
|
733
|
+
.example('autopm epic sync user-auth', 'Sync epic to GitHub issue');
|
|
734
|
+
},
|
|
735
|
+
epicSync
|
|
736
|
+
)
|
|
737
|
+
.demandCommand(1, 'You must specify an epic action')
|
|
738
|
+
.strictCommands()
|
|
739
|
+
.help();
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Command export
|
|
744
|
+
*/
|
|
745
|
+
module.exports = {
|
|
746
|
+
command: 'epic',
|
|
747
|
+
describe: 'Manage epics and epic lifecycle',
|
|
748
|
+
builder,
|
|
749
|
+
handler: (argv) => {
|
|
750
|
+
if (!argv._.includes('epic') || argv._.length === 1) {
|
|
751
|
+
console.log(chalk.yellow('\nPlease specify an epic command\n'));
|
|
752
|
+
console.log('Usage: autopm epic <command>\n');
|
|
753
|
+
console.log('Available commands:');
|
|
754
|
+
console.log(' list List all epics');
|
|
755
|
+
console.log(' show <name> Display epic');
|
|
756
|
+
console.log(' new <name> Create new epic');
|
|
757
|
+
console.log(' edit <name> Edit epic');
|
|
758
|
+
console.log(' status <name> Show epic status');
|
|
759
|
+
console.log(' validate <name> Validate epic');
|
|
760
|
+
console.log(' start <name> Start working on epic');
|
|
761
|
+
console.log(' close <name> Close epic');
|
|
762
|
+
console.log(' sync <name> Sync with GitHub');
|
|
763
|
+
console.log('\nUse: autopm epic <command> --help for more info\n');
|
|
764
|
+
}
|
|
765
|
+
},
|
|
766
|
+
handlers: {
|
|
767
|
+
list: epicList,
|
|
768
|
+
show: epicShow,
|
|
769
|
+
new: epicNew,
|
|
770
|
+
edit: epicEdit,
|
|
771
|
+
status: epicStatus,
|
|
772
|
+
validate: epicValidate,
|
|
773
|
+
start: epicStart,
|
|
774
|
+
close: epicClose,
|
|
775
|
+
sync: epicSync
|
|
776
|
+
}
|
|
777
|
+
};
|
|
@@ -40,26 +40,28 @@ class EpicService {
|
|
|
40
40
|
* Create a new EpicService instance
|
|
41
41
|
*
|
|
42
42
|
* @param {Object} options - Configuration options
|
|
43
|
-
* @param {PRDService} options.prdService - PRDService instance for parsing
|
|
43
|
+
* @param {PRDService} options.prdService - PRDService instance for parsing (optional for CLI operations)
|
|
44
44
|
* @param {ConfigManager} [options.configManager] - Optional ConfigManager instance
|
|
45
45
|
* @param {Object} [options.provider] - Optional AI provider instance for streaming
|
|
46
|
+
* @param {string} [options.epicsDir] - Path to epics directory (default: .claude/epics)
|
|
47
|
+
* @param {string} [options.defaultStatus] - Default epic status (default: backlog)
|
|
46
48
|
*/
|
|
47
49
|
constructor(options = {}) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
if (!(options.prdService instanceof PRDService)) {
|
|
53
|
-
throw new Error('prdService must be an instance of PRDService');
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
this.prdService = options.prdService;
|
|
50
|
+
// PRDService is optional now - only required for PRD parsing operations
|
|
51
|
+
this.prdService = options.prdService || null;
|
|
57
52
|
|
|
58
53
|
// Store ConfigManager if provided (for future use)
|
|
59
54
|
this.configManager = options.configManager || undefined;
|
|
60
55
|
|
|
61
56
|
// Store provider if provided (for streaming operations)
|
|
62
57
|
this.provider = options.provider || undefined;
|
|
58
|
+
|
|
59
|
+
// CLI operation options
|
|
60
|
+
this.options = {
|
|
61
|
+
epicsDir: options.epicsDir || '.claude/epics',
|
|
62
|
+
defaultStatus: options.defaultStatus || 'backlog',
|
|
63
|
+
...options
|
|
64
|
+
};
|
|
63
65
|
}
|
|
64
66
|
|
|
65
67
|
// ==========================================
|
|
@@ -312,6 +314,10 @@ class EpicService {
|
|
|
312
314
|
* // }
|
|
313
315
|
*/
|
|
314
316
|
analyzePRD(prdContent) {
|
|
317
|
+
if (!this.prdService) {
|
|
318
|
+
throw new Error('PRDService instance is required for PRD analysis operations');
|
|
319
|
+
}
|
|
320
|
+
|
|
315
321
|
const frontmatter = this.prdService.parseFrontmatter(prdContent);
|
|
316
322
|
const sections = this.prdService.extractPrdContent(prdContent);
|
|
317
323
|
|
|
@@ -321,6 +327,59 @@ class EpicService {
|
|
|
321
327
|
};
|
|
322
328
|
}
|
|
323
329
|
|
|
330
|
+
/**
|
|
331
|
+
* Parse YAML frontmatter from markdown content
|
|
332
|
+
*
|
|
333
|
+
* Extracts key-value pairs from YAML frontmatter block.
|
|
334
|
+
* Returns null if frontmatter is missing or malformed.
|
|
335
|
+
*
|
|
336
|
+
* @param {string} content - Markdown content with frontmatter
|
|
337
|
+
* @returns {Object|null} Parsed frontmatter object or null
|
|
338
|
+
*/
|
|
339
|
+
parseFrontmatter(content) {
|
|
340
|
+
if (!content || typeof content !== 'string') {
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Match frontmatter block: ---\n...\n--- or ---\n---
|
|
345
|
+
const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/) ||
|
|
346
|
+
content.match(/^---\n---/);
|
|
347
|
+
|
|
348
|
+
if (!frontmatterMatch) {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Empty frontmatter (---\n---)
|
|
353
|
+
if (!frontmatterMatch[1] && content.startsWith('---\n---')) {
|
|
354
|
+
return {};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const frontmatter = {};
|
|
358
|
+
const lines = (frontmatterMatch[1] || '').split('\n');
|
|
359
|
+
|
|
360
|
+
for (const line of lines) {
|
|
361
|
+
// Skip empty lines
|
|
362
|
+
if (!line.trim()) {
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Find first colon to split key and value
|
|
367
|
+
const colonIndex = line.indexOf(':');
|
|
368
|
+
if (colonIndex === -1) {
|
|
369
|
+
continue; // Skip lines without colons
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const key = line.substring(0, colonIndex).trim();
|
|
373
|
+
const value = line.substring(colonIndex + 1).trim();
|
|
374
|
+
|
|
375
|
+
if (key) {
|
|
376
|
+
frontmatter[key] = value;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return frontmatter;
|
|
381
|
+
}
|
|
382
|
+
|
|
324
383
|
/**
|
|
325
384
|
* Determine dependencies between features
|
|
326
385
|
*
|
|
@@ -604,6 +663,221 @@ ${prdContent}`;
|
|
|
604
663
|
yield chunk;
|
|
605
664
|
}
|
|
606
665
|
}
|
|
666
|
+
|
|
667
|
+
// ==========================================
|
|
668
|
+
// 6. CLI OPERATIONS (I/O Methods)
|
|
669
|
+
// ==========================================
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* List all epics with metadata
|
|
673
|
+
*
|
|
674
|
+
* @returns {Promise<Array<Object>>} Array of epic objects
|
|
675
|
+
*/
|
|
676
|
+
async listEpics() {
|
|
677
|
+
const fs = require('fs-extra');
|
|
678
|
+
const path = require('path');
|
|
679
|
+
|
|
680
|
+
const epicsDir = path.join(process.cwd(), this.options.epicsDir);
|
|
681
|
+
|
|
682
|
+
// Check if epics directory exists
|
|
683
|
+
const dirExists = await fs.pathExists(epicsDir);
|
|
684
|
+
if (!dirExists) {
|
|
685
|
+
return [];
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Read all epic directories
|
|
689
|
+
let epicDirs;
|
|
690
|
+
try {
|
|
691
|
+
const items = await fs.readdir(epicsDir, { withFileTypes: true });
|
|
692
|
+
epicDirs = items
|
|
693
|
+
.filter(dirent => dirent.isDirectory())
|
|
694
|
+
.map(dirent => dirent.name);
|
|
695
|
+
} catch (error) {
|
|
696
|
+
return [];
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const epics = [];
|
|
700
|
+
|
|
701
|
+
for (const epicDir of epicDirs) {
|
|
702
|
+
const epicPath = path.join(epicsDir, epicDir);
|
|
703
|
+
const epicFilePath = path.join(epicPath, 'epic.md');
|
|
704
|
+
|
|
705
|
+
// Skip directories without epic.md file
|
|
706
|
+
const fileExists = await fs.pathExists(epicFilePath);
|
|
707
|
+
if (!fileExists) {
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
let metadata;
|
|
712
|
+
try {
|
|
713
|
+
const content = await fs.readFile(epicFilePath, 'utf8');
|
|
714
|
+
metadata = this.parseFrontmatter(content);
|
|
715
|
+
} catch (error) {
|
|
716
|
+
// Skip files that can't be read
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Apply defaults
|
|
721
|
+
const name = (metadata && metadata.name) || epicDir;
|
|
722
|
+
const status = (metadata && metadata.status) || this.options.defaultStatus;
|
|
723
|
+
const progress = (metadata && metadata.progress) || '0%';
|
|
724
|
+
const github = (metadata && metadata.github) || '';
|
|
725
|
+
const created = (metadata && metadata.created) || '';
|
|
726
|
+
|
|
727
|
+
// Count tasks
|
|
728
|
+
const taskCount = await this.countTasks(epicPath);
|
|
729
|
+
|
|
730
|
+
// Extract GitHub issue number
|
|
731
|
+
const githubIssue = this.extractGitHubIssue(github);
|
|
732
|
+
|
|
733
|
+
epics.push({
|
|
734
|
+
name,
|
|
735
|
+
status,
|
|
736
|
+
progress,
|
|
737
|
+
github,
|
|
738
|
+
githubIssue,
|
|
739
|
+
created,
|
|
740
|
+
taskCount,
|
|
741
|
+
epicDir,
|
|
742
|
+
epicPath: path.join(epicPath, 'epic.md')
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
return epics;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/**
|
|
750
|
+
* Get detailed epic information
|
|
751
|
+
*
|
|
752
|
+
* @param {string} epicName - Epic directory name
|
|
753
|
+
* @returns {Promise<Object>} Epic data with metadata and task count
|
|
754
|
+
* @throws {Error} If epic not found
|
|
755
|
+
*/
|
|
756
|
+
async getEpic(epicName) {
|
|
757
|
+
const fs = require('fs-extra');
|
|
758
|
+
|
|
759
|
+
const epicPath = this.getEpicPath(epicName);
|
|
760
|
+
const epicFilePath = this.getEpicFilePath(epicName);
|
|
761
|
+
|
|
762
|
+
// Check if epic exists
|
|
763
|
+
const exists = await fs.pathExists(epicFilePath);
|
|
764
|
+
if (!exists) {
|
|
765
|
+
throw new Error(`Epic not found: ${epicName}`);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Read epic content
|
|
769
|
+
const content = await fs.readFile(epicFilePath, 'utf8');
|
|
770
|
+
const metadata = this.parseFrontmatter(content);
|
|
771
|
+
|
|
772
|
+
// Apply defaults
|
|
773
|
+
const name = (metadata && metadata.name) || epicName;
|
|
774
|
+
const status = (metadata && metadata.status) || this.options.defaultStatus;
|
|
775
|
+
const progress = (metadata && metadata.progress) || '0%';
|
|
776
|
+
const github = (metadata && metadata.github) || '';
|
|
777
|
+
const created = (metadata && metadata.created) || '';
|
|
778
|
+
|
|
779
|
+
// Count tasks
|
|
780
|
+
const taskCount = await this.countTasks(epicPath);
|
|
781
|
+
|
|
782
|
+
// Extract GitHub issue
|
|
783
|
+
const githubIssue = this.extractGitHubIssue(github);
|
|
784
|
+
|
|
785
|
+
return {
|
|
786
|
+
name,
|
|
787
|
+
status,
|
|
788
|
+
progress,
|
|
789
|
+
github,
|
|
790
|
+
githubIssue,
|
|
791
|
+
created,
|
|
792
|
+
taskCount,
|
|
793
|
+
epicDir: epicName,
|
|
794
|
+
epicPath: epicFilePath,
|
|
795
|
+
content
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Validate epic structure and completeness
|
|
801
|
+
*
|
|
802
|
+
* @param {string} epicName - Epic directory name
|
|
803
|
+
* @returns {Promise<Object>} Validation result: { valid: boolean, issues: string[] }
|
|
804
|
+
* @throws {Error} If epic not found
|
|
805
|
+
*/
|
|
806
|
+
async validateEpicStructure(epicName) {
|
|
807
|
+
const fs = require('fs-extra');
|
|
808
|
+
|
|
809
|
+
const epicFilePath = this.getEpicFilePath(epicName);
|
|
810
|
+
|
|
811
|
+
// Check if epic exists
|
|
812
|
+
const exists = await fs.pathExists(epicFilePath);
|
|
813
|
+
if (!exists) {
|
|
814
|
+
throw new Error(`Epic not found: ${epicName}`);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const issues = [];
|
|
818
|
+
|
|
819
|
+
// Read epic content
|
|
820
|
+
const content = await fs.readFile(epicFilePath, 'utf8');
|
|
821
|
+
|
|
822
|
+
// Check for frontmatter
|
|
823
|
+
const frontmatter = this.parseFrontmatter(content);
|
|
824
|
+
if (!frontmatter) {
|
|
825
|
+
issues.push('Missing frontmatter');
|
|
826
|
+
} else {
|
|
827
|
+
// Check for required fields
|
|
828
|
+
if (!frontmatter.name) {
|
|
829
|
+
issues.push('Missing name in frontmatter');
|
|
830
|
+
}
|
|
831
|
+
if (!frontmatter.status) {
|
|
832
|
+
issues.push('Missing status in frontmatter');
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
return {
|
|
837
|
+
valid: issues.length === 0,
|
|
838
|
+
issues
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* Count task files in epic directory
|
|
844
|
+
*
|
|
845
|
+
* @param {string} epicPath - Path to epic directory
|
|
846
|
+
* @returns {Promise<number>} Number of task files
|
|
847
|
+
*/
|
|
848
|
+
async countTasks(epicPath) {
|
|
849
|
+
const fs = require('fs-extra');
|
|
850
|
+
|
|
851
|
+
try {
|
|
852
|
+
const files = await fs.readdir(epicPath);
|
|
853
|
+
// Count files that match pattern [0-9]*.md
|
|
854
|
+
return files.filter(file => /^\d+\.md$/.test(file)).length;
|
|
855
|
+
} catch (error) {
|
|
856
|
+
return 0;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Get full path to epic directory
|
|
862
|
+
*
|
|
863
|
+
* @param {string} epicName - Epic directory name
|
|
864
|
+
* @returns {string} Full path to epic directory
|
|
865
|
+
*/
|
|
866
|
+
getEpicPath(epicName) {
|
|
867
|
+
const path = require('path');
|
|
868
|
+
return path.join(process.cwd(), this.options.epicsDir, epicName);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* Get full path to epic.md file
|
|
873
|
+
*
|
|
874
|
+
* @param {string} epicName - Epic directory name
|
|
875
|
+
* @returns {string} Full path to epic.md file
|
|
876
|
+
*/
|
|
877
|
+
getEpicFilePath(epicName) {
|
|
878
|
+
const path = require('path');
|
|
879
|
+
return path.join(this.getEpicPath(epicName), 'epic.md');
|
|
880
|
+
}
|
|
607
881
|
}
|
|
608
882
|
|
|
609
883
|
module.exports = EpicService;
|