moflo 4.8.43 → 4.8.44

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,767 @@
1
+ /**
2
+ * MoFlo Epic Command
3
+ * Epic orchestrator that sequences GitHub issues through /flo workflows.
4
+ *
5
+ * Accepts either a GitHub epic issue number or a YAML feature definition.
6
+ * When given an issue number, fetches the epic from GitHub and extracts
7
+ * child stories automatically. When given a YAML file, uses the explicit
8
+ * story definitions with dependency ordering.
9
+ *
10
+ * Usage:
11
+ * flo epic run 42 Execute an epic from GitHub
12
+ * flo epic run 42 --dry-run Show execution plan from GitHub epic
13
+ * flo epic run feature.yaml Execute a YAML feature definition
14
+ * flo epic run feature.yaml --dry-run Show execution plan
15
+ * flo epic status <feature-id> Check progress
16
+ * flo epic reset <feature-id> Reset for re-run
17
+ */
18
+ import { spawn, execSync } from 'child_process';
19
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
20
+ import { join, resolve, dirname } from 'path';
21
+ // ═══════════════════════════════════════════════════════════════════════════════
22
+ // Constants
23
+ // ═══════════════════════════════════════════════════════════════════════════════
24
+ const STORY_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
25
+ // ═══════════════════════════════════════════════════════════════════════════════
26
+ // YAML Parsing (js-yaml optional — fallback to simple parser)
27
+ // ═══════════════════════════════════════════════════════════════════════════════
28
+ async function parseYaml(content) {
29
+ try {
30
+ const yaml = await import('js-yaml');
31
+ return yaml.load(content);
32
+ }
33
+ catch {
34
+ // Fallback: try JSON (YAML is a superset of JSON)
35
+ try {
36
+ return JSON.parse(content);
37
+ }
38
+ catch {
39
+ throw new Error('Failed to parse feature file. Install js-yaml (`npm i js-yaml`) or use JSON format.');
40
+ }
41
+ }
42
+ }
43
+ // ═══════════════════════════════════════════════════════════════════════════════
44
+ // Validation (inline zod-like validation, no external dependency required)
45
+ // ═══════════════════════════════════════════════════════════════════════════════
46
+ function validateFeatureDefinition(raw) {
47
+ if (!raw || typeof raw !== 'object') {
48
+ throw new Error('Feature definition must be an object');
49
+ }
50
+ const obj = raw;
51
+ if (!obj.feature || typeof obj.feature !== 'object') {
52
+ throw new Error('Feature definition must have a "feature" key');
53
+ }
54
+ const f = obj.feature;
55
+ const errors = [];
56
+ // Required string fields
57
+ for (const field of ['id', 'name', 'description', 'repository', 'base_branch']) {
58
+ if (!f[field] || typeof f[field] !== 'string') {
59
+ errors.push(`feature.${field} is required and must be a string`);
60
+ }
61
+ }
62
+ // Repository must exist and be a git repo
63
+ if (typeof f.repository === 'string') {
64
+ if (!existsSync(f.repository)) {
65
+ errors.push(`Repository path does not exist: "${f.repository}"`);
66
+ }
67
+ else if (!existsSync(join(f.repository, '.git'))) {
68
+ errors.push(`Repository path is not a git repo: "${f.repository}"`);
69
+ }
70
+ }
71
+ // Stories
72
+ if (!Array.isArray(f.stories) || f.stories.length === 0) {
73
+ errors.push('feature.stories must be a non-empty array');
74
+ }
75
+ else {
76
+ const storyIds = new Set();
77
+ const issueNumbers = new Set();
78
+ for (let i = 0; i < f.stories.length; i++) {
79
+ const s = f.stories[i];
80
+ if (!s.id || typeof s.id !== 'string')
81
+ errors.push(`stories[${i}].id is required`);
82
+ if (!s.name || typeof s.name !== 'string')
83
+ errors.push(`stories[${i}].name is required`);
84
+ if (typeof s.issue !== 'number' || s.issue <= 0)
85
+ errors.push(`stories[${i}].issue must be a positive number`);
86
+ if (typeof s.id === 'string') {
87
+ if (storyIds.has(s.id))
88
+ errors.push(`Duplicate story ID: "${s.id}"`);
89
+ storyIds.add(s.id);
90
+ }
91
+ if (typeof s.issue === 'number') {
92
+ if (issueNumbers.has(s.issue))
93
+ errors.push(`Duplicate issue number: ${s.issue}`);
94
+ issueNumbers.add(s.issue);
95
+ }
96
+ // Validate depends_on references
97
+ if (Array.isArray(s.depends_on)) {
98
+ for (const dep of s.depends_on) {
99
+ if (typeof dep !== 'string')
100
+ errors.push(`stories[${i}].depends_on must contain strings`);
101
+ }
102
+ }
103
+ }
104
+ // Validate depends_on references exist (second pass)
105
+ for (const s of f.stories) {
106
+ if (s.depends_on) {
107
+ for (const dep of s.depends_on) {
108
+ if (!storyIds.has(dep)) {
109
+ errors.push(`Story "${s.id}" depends on "${dep}" which does not exist`);
110
+ }
111
+ }
112
+ }
113
+ }
114
+ }
115
+ // Review
116
+ if (!f.review || typeof f.review !== 'object') {
117
+ errors.push('feature.review is required');
118
+ }
119
+ else {
120
+ const r = f.review;
121
+ if (typeof r.enabled !== 'boolean')
122
+ errors.push('review.enabled must be a boolean');
123
+ if (!Array.isArray(r.focus_areas))
124
+ errors.push('review.focus_areas must be an array');
125
+ if (!r.output || typeof r.output !== 'string')
126
+ errors.push('review.output is required');
127
+ if (typeof r.fail_on_critical !== 'boolean')
128
+ errors.push('review.fail_on_critical must be a boolean');
129
+ }
130
+ if (errors.length > 0) {
131
+ throw new Error(`Invalid feature definition:\n${errors.map((e) => ` - ${e}`).join('\n')}`);
132
+ }
133
+ // Check for circular dependencies
134
+ resolveExecutionOrder(f.stories);
135
+ return raw;
136
+ }
137
+ // ═══════════════════════════════════════════════════════════════════════════════
138
+ // Topological Sort (Kahn's Algorithm)
139
+ // ═══════════════════════════════════════════════════════════════════════════════
140
+ function resolveExecutionOrder(stories) {
141
+ const ids = stories.map((s) => s.id);
142
+ const inDegree = new Map();
143
+ const adjacency = new Map();
144
+ for (const id of ids) {
145
+ inDegree.set(id, 0);
146
+ adjacency.set(id, []);
147
+ }
148
+ for (const story of stories) {
149
+ if (story.depends_on) {
150
+ for (const dep of story.depends_on) {
151
+ adjacency.get(dep)?.push(story.id);
152
+ inDegree.set(story.id, (inDegree.get(story.id) || 0) + 1);
153
+ }
154
+ }
155
+ }
156
+ const queue = [];
157
+ for (const [id, degree] of inDegree.entries()) {
158
+ if (degree === 0)
159
+ queue.push(id);
160
+ }
161
+ const order = [];
162
+ const groups = [];
163
+ while (queue.length > 0) {
164
+ const currentLevel = [...queue];
165
+ groups.push(currentLevel);
166
+ queue.length = 0;
167
+ for (const id of currentLevel) {
168
+ order.push(id);
169
+ for (const neighbor of adjacency.get(id) || []) {
170
+ const newDegree = (inDegree.get(neighbor) || 1) - 1;
171
+ inDegree.set(neighbor, newDegree);
172
+ if (newDegree === 0)
173
+ queue.push(neighbor);
174
+ }
175
+ }
176
+ }
177
+ if (order.length !== ids.length) {
178
+ const remaining = ids.filter((id) => !order.includes(id));
179
+ throw new Error(`Circular dependency detected involving: ${remaining.join(', ')}`);
180
+ }
181
+ return { order, independent_groups: groups };
182
+ }
183
+ // ═══════════════════════════════════════════════════════════════════════════════
184
+ // State Management (JSON file)
185
+ // ═══════════════════════════════════════════════════════════════════════════════
186
+ function getStatePath(repoPath) {
187
+ return join(repoPath, '.claude-epic', 'state.json');
188
+ }
189
+ function loadState(repoPath) {
190
+ const statePath = getStatePath(repoPath);
191
+ if (existsSync(statePath)) {
192
+ return JSON.parse(readFileSync(statePath, 'utf-8'));
193
+ }
194
+ return { features: {} };
195
+ }
196
+ function saveState(repoPath, state) {
197
+ const statePath = getStatePath(repoPath);
198
+ const dir = dirname(statePath);
199
+ if (!existsSync(dir)) {
200
+ mkdirSync(dir, { recursive: true });
201
+ }
202
+ writeFileSync(statePath, JSON.stringify(state, null, 2));
203
+ }
204
+ function detectRepoFromGit() {
205
+ try {
206
+ const url = execSync('gh repo view --json nameWithOwner -q .nameWithOwner', {
207
+ stdio: ['pipe', 'pipe', 'pipe'],
208
+ }).toString().trim();
209
+ return url || null;
210
+ }
211
+ catch {
212
+ return null;
213
+ }
214
+ }
215
+ function fetchGitHubIssue(issueNumber) {
216
+ const output = execSync(`gh issue view ${issueNumber} --json number,title,body,labels,state`, { stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim();
217
+ return JSON.parse(output);
218
+ }
219
+ function extractStoriesFromEpic(issue) {
220
+ const stories = [];
221
+ const body = issue.body || '';
222
+ // Pattern 1: Checklist-linked issues — - [ ] #123 or - [x] #123
223
+ const checklistPattern = /^[\s]*-\s*\[[ x]\]\s*#(\d+)/gm;
224
+ let match;
225
+ while ((match = checklistPattern.exec(body)) !== null) {
226
+ const num = parseInt(match[1], 10);
227
+ if (!stories.some((s) => s.issue === num)) {
228
+ stories.push({ id: `story-${num}`, name: `Issue #${num}`, issue: num });
229
+ }
230
+ }
231
+ // Pattern 2: Numbered issue references — 1. #123 or 1. Title (#123)
232
+ const numberedPattern = /^\s*\d+\.\s*(?:.*?)#(\d+)/gm;
233
+ while ((match = numberedPattern.exec(body)) !== null) {
234
+ const num = parseInt(match[1], 10);
235
+ if (!stories.some((s) => s.issue === num)) {
236
+ stories.push({ id: `story-${num}`, name: `Issue #${num}`, issue: num });
237
+ }
238
+ }
239
+ // Pattern 3: Bare issue references in Stories/Tasks sections
240
+ const sectionPattern = /##\s*(?:Stories|Tasks)\s*\n([\s\S]*?)(?=\n##\s|\n*$)/i;
241
+ const sectionMatch = sectionPattern.exec(body);
242
+ if (sectionMatch) {
243
+ const sectionBody = sectionMatch[1];
244
+ const refPattern = /#(\d+)/g;
245
+ while ((match = refPattern.exec(sectionBody)) !== null) {
246
+ const num = parseInt(match[1], 10);
247
+ if (!stories.some((s) => s.issue === num)) {
248
+ stories.push({ id: `story-${num}`, name: `Issue #${num}`, issue: num });
249
+ }
250
+ }
251
+ }
252
+ // Enrich story names from GitHub if we have stories
253
+ for (const story of stories) {
254
+ try {
255
+ const storyIssue = fetchGitHubIssue(story.issue);
256
+ story.name = storyIssue.title;
257
+ }
258
+ catch {
259
+ // Keep the default name if fetch fails
260
+ }
261
+ }
262
+ return stories;
263
+ }
264
+ function isEpicIssue(issue) {
265
+ const epicLabels = ['epic', 'tracking', 'parent', 'umbrella'];
266
+ if (issue.labels.some((l) => epicLabels.includes(l.name.toLowerCase())))
267
+ return true;
268
+ const body = issue.body || '';
269
+ if (/##\s*(?:Stories|Tasks)/i.test(body))
270
+ return true;
271
+ if (/^[\s]*-\s*\[[ x]\]\s*#\d+/m.test(body))
272
+ return true;
273
+ if (/^\s*\d+\.\s*(?:.*?)#\d+/m.test(body))
274
+ return true;
275
+ return false;
276
+ }
277
+ function buildFeatureFromEpic(issue, repoPath, baseBranch) {
278
+ const stories = extractStoriesFromEpic(issue);
279
+ if (stories.length === 0) {
280
+ throw new Error(`Issue #${issue.number} doesn't appear to be an epic (no linked stories found).\n` +
281
+ `Expected: checklist items (- [ ] #123), numbered references (1. #123), or a ## Stories section.`);
282
+ }
283
+ return {
284
+ feature: {
285
+ id: `epic-${issue.number}`,
286
+ name: issue.title,
287
+ description: `Auto-generated from GitHub epic #${issue.number}`,
288
+ repository: repoPath,
289
+ base_branch: baseBranch,
290
+ auto_merge: true,
291
+ stories,
292
+ review: {
293
+ enabled: false,
294
+ focus_areas: [],
295
+ output: '',
296
+ fail_on_critical: false,
297
+ },
298
+ },
299
+ };
300
+ }
301
+ // ═══════════════════════════════════════════════════════════════════════════════
302
+ // Feature Loading (YAML or GitHub)
303
+ // ═══════════════════════════════════════════════════════════════════════════════
304
+ async function loadFeatureDefinition(yamlPath) {
305
+ const absPath = resolve(yamlPath);
306
+ if (!existsSync(absPath)) {
307
+ throw new Error(`Feature file not found: ${absPath}`);
308
+ }
309
+ const content = readFileSync(absPath, 'utf-8');
310
+ const raw = await parseYaml(content);
311
+ return validateFeatureDefinition(raw);
312
+ }
313
+ async function loadFeatureFromIssue(issueNumber) {
314
+ console.log(`[epic] Fetching issue #${issueNumber} from GitHub...`);
315
+ const issue = fetchGitHubIssue(issueNumber);
316
+ if (!isEpicIssue(issue)) {
317
+ throw new Error(`Issue #${issueNumber} ("${issue.title}") is not an epic.\n` +
318
+ `To orchestrate it, add child stories as checklist items (- [ ] #123) or a ## Stories section.\n` +
319
+ `For a single issue, use /flo ${issueNumber} instead.`);
320
+ }
321
+ const repoPath = process.cwd();
322
+ let baseBranch = 'main';
323
+ try {
324
+ baseBranch = execSync('gh repo view --json defaultBranchRef -q .defaultBranchRef.name', {
325
+ stdio: ['pipe', 'pipe', 'pipe'],
326
+ }).toString().trim() || 'main';
327
+ }
328
+ catch { /* use default */ }
329
+ const featureDef = buildFeatureFromEpic(issue, repoPath, baseBranch);
330
+ console.log(`[epic] Epic: ${issue.title}`);
331
+ console.log(`[epic] Stories found: ${featureDef.feature.stories.length}`);
332
+ for (const s of featureDef.feature.stories) {
333
+ console.log(` - #${s.issue}: ${s.name}`);
334
+ }
335
+ console.log('');
336
+ return featureDef;
337
+ }
338
+ // ═══════════════════════════════════════════════════════════════════════════════
339
+ // GitHub Helpers
340
+ // ═══════════════════════════════════════════════════════════════════════════════
341
+ function findPrForIssue(issue, repoPath) {
342
+ try {
343
+ const output = execSync(`gh pr list --state all --search "Closes #${issue}" --json number,url --limit 1`, { cwd: repoPath, stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim();
344
+ const prs = JSON.parse(output);
345
+ if (prs.length > 0) {
346
+ return { number: prs[0].number, url: prs[0].url };
347
+ }
348
+ // Fallback: search by issue number in title
349
+ const output2 = execSync(`gh pr list --state all --search "#${issue}" --json number,url --limit 1`, { cwd: repoPath, stdio: ['pipe', 'pipe', 'pipe'] }).toString().trim();
350
+ const prs2 = JSON.parse(output2);
351
+ if (prs2.length > 0) {
352
+ return { number: prs2[0].number, url: prs2[0].url };
353
+ }
354
+ return null;
355
+ }
356
+ catch {
357
+ return null;
358
+ }
359
+ }
360
+ // ═══════════════════════════════════════════════════════════════════════════════
361
+ // Story Runner
362
+ // ═══════════════════════════════════════════════════════════════════════════════
363
+ function runClaudeSession(command, cwd, timeoutMs, onOutput) {
364
+ return new Promise((resolve, reject) => {
365
+ const startTime = Date.now();
366
+ const args = ['-p', command, '--model', 'opus', '--verbose'];
367
+ const child = spawn('claude', args, {
368
+ cwd,
369
+ stdio: ['pipe', 'pipe', 'pipe'],
370
+ env: { ...process.env },
371
+ shell: true,
372
+ windowsHide: true,
373
+ });
374
+ let stdout = '';
375
+ let stderr = '';
376
+ let timedOut = false;
377
+ const timer = setTimeout(() => {
378
+ timedOut = true;
379
+ child.kill('SIGTERM');
380
+ }, timeoutMs);
381
+ child.stdout.on('data', (data) => {
382
+ const chunk = data.toString();
383
+ stdout += chunk;
384
+ onOutput?.(chunk);
385
+ });
386
+ child.stderr.on('data', (data) => {
387
+ stderr += data.toString();
388
+ });
389
+ child.on('close', (code) => {
390
+ clearTimeout(timer);
391
+ const durationMs = Date.now() - startTime;
392
+ if (timedOut) {
393
+ resolve({ success: false, output: stdout, durationMs, error: `Timed out after ${timeoutMs}ms` });
394
+ return;
395
+ }
396
+ if (code !== 0) {
397
+ resolve({ success: false, output: stdout, durationMs, error: `Claude exited with code ${code}: ${stderr.substring(0, 500)}` });
398
+ return;
399
+ }
400
+ resolve({ success: true, output: stdout, durationMs, error: null });
401
+ });
402
+ child.on('error', (error) => {
403
+ clearTimeout(timer);
404
+ reject(new Error(`Failed to spawn Claude: ${error.message}`));
405
+ });
406
+ });
407
+ }
408
+ // ═══════════════════════════════════════════════════════════════════════════════
409
+ // Formatting Helpers
410
+ // ═══════════════════════════════════════════════════════════════════════════════
411
+ function formatDuration(ms) {
412
+ const seconds = Math.floor(ms / 1000);
413
+ const minutes = Math.floor(seconds / 60);
414
+ const remaining = seconds % 60;
415
+ return minutes > 0 ? `${minutes}m ${remaining}s` : `${remaining}s`;
416
+ }
417
+ function pad(str, len) {
418
+ return str.length >= len ? str.substring(0, len) : str + ' '.repeat(len - str.length);
419
+ }
420
+ // ═══════════════════════════════════════════════════════════════════════════════
421
+ // Subcommand: run
422
+ // ═══════════════════════════════════════════════════════════════════════════════
423
+ async function runFeature(source, dryRun, verbose) {
424
+ // Detect whether source is a GitHub issue number or a YAML file path
425
+ const isIssueNumber = /^\d+$/.test(source.trim());
426
+ const featureDef = isIssueNumber
427
+ ? await loadFeatureFromIssue(parseInt(source, 10))
428
+ : await loadFeatureDefinition(source);
429
+ const feature = featureDef.feature;
430
+ const autoMerge = feature.auto_merge !== false;
431
+ const plan = resolveExecutionOrder(feature.stories);
432
+ // ── Dry run ───────────────────────────────────────────────────────────
433
+ if (dryRun) {
434
+ console.log('');
435
+ console.log('+-------------------------------------------------------------+');
436
+ console.log(`| DRY RUN: ${pad(feature.name, 50)}|`);
437
+ console.log(`| Base: ${pad(feature.base_branch, 53)}|`);
438
+ console.log(`| Auto-merge: ${pad(autoMerge ? 'yes' : 'no', 47)}|`);
439
+ console.log('+-------------------------------------------------------------+');
440
+ console.log('| Stories (via /flo): |');
441
+ for (let i = 0; i < plan.order.length; i++) {
442
+ const story = feature.stories.find((s) => s.id === plan.order[i]);
443
+ const deps = story.depends_on?.length ? ` -> after ${story.depends_on.join(', ')}` : '';
444
+ const flags = story.flo_flags || '-sw';
445
+ const line = `${i + 1}. /flo ${story.issue} ${flags}${deps}`;
446
+ console.log(`| ${pad(line, 57)}|`);
447
+ console.log(`| ${pad(story.name.substring(0, 55), 55)}|`);
448
+ }
449
+ console.log('+-------------------------------------------------------------+');
450
+ console.log(`| Review: ${pad(feature.review.enabled ? 'enabled' : 'disabled', 51)}|`);
451
+ console.log('+-------------------------------------------------------------+');
452
+ console.log('');
453
+ return { success: true };
454
+ }
455
+ // ── Initialize state ──────────────────────────────────────────────────
456
+ const state = loadState(feature.repository);
457
+ if (!state.features[feature.id]) {
458
+ state.features[feature.id] = {
459
+ id: feature.id,
460
+ name: feature.name,
461
+ status: 'pending',
462
+ started_at: null,
463
+ completed_at: null,
464
+ stories: {},
465
+ };
466
+ for (const storyId of plan.order) {
467
+ const storyDef = feature.stories.find((s) => s.id === storyId);
468
+ state.features[feature.id].stories[storyId] = {
469
+ id: storyId,
470
+ name: storyDef.name,
471
+ status: 'pending',
472
+ started_at: null,
473
+ completed_at: null,
474
+ duration_ms: 0,
475
+ pr_url: null,
476
+ pr_number: null,
477
+ merged: false,
478
+ error: null,
479
+ };
480
+ }
481
+ }
482
+ state.features[feature.id].status = 'running';
483
+ state.features[feature.id].started_at = new Date().toISOString();
484
+ saveState(feature.repository, state);
485
+ // ── Execute stories ───────────────────────────────────────────────────
486
+ const results = [];
487
+ let failed = false;
488
+ for (const storyId of plan.order) {
489
+ const storyDef = feature.stories.find((s) => s.id === storyId);
490
+ const storyState = state.features[feature.id].stories[storyId];
491
+ // Skip already-passed stories (resume support)
492
+ if (storyState && storyState.status === 'passed') {
493
+ console.log(`[skip] ${storyId} (#${storyDef.issue}) -- already passed`);
494
+ results.push({
495
+ story_id: storyId,
496
+ issue: storyDef.issue,
497
+ status: 'passed',
498
+ started_at: storyState.started_at || '',
499
+ completed_at: storyState.completed_at || '',
500
+ duration_ms: storyState.duration_ms,
501
+ pr_url: storyState.pr_url,
502
+ pr_number: storyState.pr_number,
503
+ merged: storyState.merged,
504
+ error: null,
505
+ });
506
+ continue;
507
+ }
508
+ // Check dependencies
509
+ if (storyDef.depends_on?.length) {
510
+ const unmet = storyDef.depends_on.filter((dep) => !results.some((r) => r.story_id === dep && r.status === 'passed'));
511
+ if (unmet.length > 0) {
512
+ console.log(`[skip] ${storyId} -- unmet dependencies: ${unmet.join(', ')}`);
513
+ state.features[feature.id].stories[storyId].status = 'skipped';
514
+ state.features[feature.id].stories[storyId].error = `Unmet deps: ${unmet.join(', ')}`;
515
+ saveState(feature.repository, state);
516
+ continue;
517
+ }
518
+ }
519
+ // ── Run the story ─────────────────────────────────────────────────
520
+ const startedAt = new Date().toISOString();
521
+ const flags = storyDef.flo_flags || '-sw';
522
+ console.log('');
523
+ console.log(`=== Starting story: ${storyId} (#${storyDef.issue}) ===`);
524
+ console.log(` ${storyDef.name}`);
525
+ console.log(` Command: /flo ${storyDef.issue} ${flags}`);
526
+ console.log('');
527
+ // Update state to running
528
+ state.features[feature.id].stories[storyId].status = 'running';
529
+ state.features[feature.id].stories[storyId].started_at = startedAt;
530
+ saveState(feature.repository, state);
531
+ // Pull latest main
532
+ try {
533
+ execSync(`git checkout ${feature.base_branch} && git pull origin ${feature.base_branch}`, {
534
+ cwd: feature.repository,
535
+ stdio: 'pipe',
536
+ });
537
+ }
538
+ catch {
539
+ console.log('[warn] Failed to pull base branch -- continuing anyway');
540
+ }
541
+ // Spawn claude
542
+ const command = `/flo ${storyDef.issue} ${flags}`.trim();
543
+ const runResult = await runClaudeSession(command, feature.repository, STORY_TIMEOUT_MS, verbose ? (text) => process.stdout.write(text) : undefined);
544
+ if (!runResult.success) {
545
+ console.log(`[FAIL] ${storyId}: ${runResult.error}`);
546
+ state.features[feature.id].stories[storyId].status = 'failed';
547
+ state.features[feature.id].stories[storyId].completed_at = new Date().toISOString();
548
+ state.features[feature.id].stories[storyId].duration_ms = runResult.durationMs;
549
+ state.features[feature.id].stories[storyId].error = runResult.error;
550
+ saveState(feature.repository, state);
551
+ results.push({
552
+ story_id: storyId, issue: storyDef.issue, status: 'failed',
553
+ started_at: startedAt, completed_at: new Date().toISOString(),
554
+ duration_ms: runResult.durationMs, pr_url: null, pr_number: null,
555
+ merged: false, error: runResult.error,
556
+ });
557
+ failed = true;
558
+ break;
559
+ }
560
+ // Find the PR
561
+ const prInfo = findPrForIssue(storyDef.issue, feature.repository);
562
+ if (!prInfo) {
563
+ console.log(`[FAIL] ${storyId}: No PR found after /flo completed`);
564
+ state.features[feature.id].stories[storyId].status = 'failed';
565
+ state.features[feature.id].stories[storyId].completed_at = new Date().toISOString();
566
+ state.features[feature.id].stories[storyId].duration_ms = runResult.durationMs;
567
+ state.features[feature.id].stories[storyId].error = 'No PR created by /flo';
568
+ saveState(feature.repository, state);
569
+ results.push({
570
+ story_id: storyId, issue: storyDef.issue, status: 'failed',
571
+ started_at: startedAt, completed_at: new Date().toISOString(),
572
+ duration_ms: runResult.durationMs, pr_url: null, pr_number: null,
573
+ merged: false, error: 'No PR created by /flo',
574
+ });
575
+ failed = true;
576
+ break;
577
+ }
578
+ console.log(`[ok] PR found: #${prInfo.number} (${prInfo.url})`);
579
+ // Auto-merge
580
+ let merged = false;
581
+ if (autoMerge) {
582
+ try {
583
+ execSync(`gh pr merge ${prInfo.number} --squash --delete-branch`, {
584
+ cwd: feature.repository,
585
+ stdio: 'pipe',
586
+ });
587
+ merged = true;
588
+ console.log(`[ok] PR #${prInfo.number} merged`);
589
+ // Pull merged changes
590
+ execSync(`git checkout ${feature.base_branch} && git pull origin ${feature.base_branch}`, {
591
+ cwd: feature.repository,
592
+ stdio: 'pipe',
593
+ });
594
+ }
595
+ catch (e) {
596
+ console.log(`[warn] Failed to merge PR #${prInfo.number}: ${String(e)}`);
597
+ }
598
+ }
599
+ // Update state
600
+ state.features[feature.id].stories[storyId].status = 'passed';
601
+ state.features[feature.id].stories[storyId].completed_at = new Date().toISOString();
602
+ state.features[feature.id].stories[storyId].duration_ms = runResult.durationMs;
603
+ state.features[feature.id].stories[storyId].pr_url = prInfo.url;
604
+ state.features[feature.id].stories[storyId].pr_number = prInfo.number;
605
+ state.features[feature.id].stories[storyId].merged = merged;
606
+ saveState(feature.repository, state);
607
+ results.push({
608
+ story_id: storyId, issue: storyDef.issue, status: 'passed',
609
+ started_at: startedAt, completed_at: new Date().toISOString(),
610
+ duration_ms: runResult.durationMs, pr_url: prInfo.url, pr_number: prInfo.number,
611
+ merged, error: null,
612
+ });
613
+ console.log(`=== Story completed: ${storyId} (${formatDuration(runResult.durationMs)}) ===`);
614
+ }
615
+ // ── Finalize ──────────────────────────────────────────────────────────
616
+ state.features[feature.id].status = failed ? 'failed' : 'completed';
617
+ state.features[feature.id].completed_at = new Date().toISOString();
618
+ saveState(feature.repository, state);
619
+ // ── Summary ───────────────────────────────────────────────────────────
620
+ printSummary(feature, results, plan.order);
621
+ return { success: !failed };
622
+ }
623
+ // ═══════════════════════════════════════════════════════════════════════════════
624
+ // Subcommand: status
625
+ // ═══════════════════════════════════════════════════════════════════════════════
626
+ function showStatus(featureId) {
627
+ // Search for state file in cwd
628
+ const cwd = process.cwd();
629
+ const state = loadState(cwd);
630
+ const featureState = state.features[featureId];
631
+ if (!featureState) {
632
+ console.log(`No state found for feature "${featureId}"`);
633
+ console.log(`Looked in: ${getStatePath(cwd)}`);
634
+ return { success: false };
635
+ }
636
+ console.log('');
637
+ console.log(`Feature: ${featureState.name} (${featureState.id})`);
638
+ console.log(`Status: ${featureState.status}`);
639
+ console.log(`Started: ${featureState.started_at || '-'}`);
640
+ console.log('');
641
+ console.log(`${pad('Story', 22)} ${pad('Status', 10)} ${pad('Duration', 10)} ${pad('PR', 15)} Error`);
642
+ console.log(`${'-'.repeat(22)} ${'-'.repeat(10)} ${'-'.repeat(10)} ${'-'.repeat(15)} ${'─'.repeat(20)}`);
643
+ for (const [, story] of Object.entries(featureState.stories)) {
644
+ const duration = story.duration_ms > 0 ? formatDuration(story.duration_ms) : '-';
645
+ const pr = story.pr_number ? `#${story.pr_number}${story.merged ? ' (merged)' : ''}` : '-';
646
+ const error = story.error ? story.error.substring(0, 30) : '';
647
+ console.log(`${pad(story.id, 22)} ${pad(story.status, 10)} ${pad(duration, 10)} ${pad(pr, 15)} ${error}`);
648
+ }
649
+ console.log('');
650
+ return { success: true };
651
+ }
652
+ // ═══════════════════════════════════════════════════════════════════════════════
653
+ // Subcommand: reset
654
+ // ═══════════════════════════════════════════════════════════════════════════════
655
+ function resetFeature(featureId) {
656
+ const cwd = process.cwd();
657
+ const state = loadState(cwd);
658
+ if (!state.features[featureId]) {
659
+ console.log(`No state found for feature "${featureId}"`);
660
+ return { success: false };
661
+ }
662
+ delete state.features[featureId];
663
+ saveState(cwd, state);
664
+ console.log(`Reset state for feature "${featureId}"`);
665
+ return { success: true };
666
+ }
667
+ // ═══════════════════════════════════════════════════════════════════════════════
668
+ // Summary Output
669
+ // ═══════════════════════════════════════════════════════════════════════════════
670
+ function printSummary(feature, results, order) {
671
+ const featureStatus = results.some((r) => r.status === 'failed') ? 'FAILED' : 'COMPLETED';
672
+ let totalDuration = 0;
673
+ console.log('');
674
+ console.log('+---------------------------------------------------------------------+');
675
+ console.log(`| Feature: ${pad(feature.name, 58)}|`);
676
+ console.log(`| Status: ${pad(featureStatus, 59)}|`);
677
+ console.log('+----------------------+--------+----------+----------+---------------+');
678
+ console.log('| Story | Issue | Status | Duration | PR |');
679
+ console.log('+----------------------+--------+----------+----------+---------------+');
680
+ for (const storyId of order) {
681
+ const r = results.find((s) => s.story_id === storyId);
682
+ const story = feature.stories.find((s) => s.id === storyId);
683
+ const status = r?.status || 'pending';
684
+ const icon = status === 'passed' ? '[ok]' : status === 'failed' ? '[!!]' : status === 'skipped' ? '[--]' : '[..]';
685
+ const duration = r ? formatDuration(r.duration_ms) : '-';
686
+ const pr = r?.pr_number ? `#${r.pr_number}${r.merged ? ' ok' : ''}` : '-';
687
+ if (r)
688
+ totalDuration += r.duration_ms;
689
+ console.log(`| ${pad(storyId.substring(0, 20), 20)} | #${pad(String(story.issue), 5)} | ${icon} ${pad(status.substring(0, 6), 4)} | ${pad(duration, 8)} | ${pad(pr, 13)} |`);
690
+ }
691
+ console.log('+----------------------+--------+----------+----------+---------------+');
692
+ console.log(`| Total: ${pad(formatDuration(totalDuration), 61)}|`);
693
+ console.log('+---------------------------------------------------------------------+');
694
+ console.log('');
695
+ }
696
+ // ═══════════════════════════════════════════════════════════════════════════════
697
+ // Command Definition
698
+ // ═══════════════════════════════════════════════════════════════════════════════
699
+ const epicCommand = {
700
+ name: 'epic',
701
+ description: 'Epic orchestrator — sequences GitHub epics or YAML features through /flo workflows',
702
+ options: [],
703
+ examples: [
704
+ { command: 'flo epic run 42', description: 'Execute a GitHub epic (auto-detects stories)' },
705
+ { command: 'flo epic run 42 --dry-run', description: 'Show execution plan from GitHub epic' },
706
+ { command: 'flo epic run feature.yaml', description: 'Execute a YAML feature definition' },
707
+ { command: 'flo epic run feature.yaml --dry-run', description: 'Show execution plan without running' },
708
+ { command: 'flo epic run feature.yaml --verbose', description: 'Execute with Claude output streaming' },
709
+ { command: 'flo epic status my-feature', description: 'Check progress of a feature' },
710
+ { command: 'flo epic reset my-feature', description: 'Reset feature state for re-run' },
711
+ ],
712
+ action: async (ctx) => {
713
+ const subcommand = ctx.args?.[0];
714
+ if (!subcommand) {
715
+ console.log('Usage: flo epic <command> [args] [flags]');
716
+ console.log('');
717
+ console.log('Commands:');
718
+ console.log(' run <issue | yaml> Execute a GitHub epic or YAML feature');
719
+ console.log(' status <feature-id> Check feature progress');
720
+ console.log(' reset <feature-id> Reset feature state for re-run');
721
+ console.log('');
722
+ console.log('Examples:');
723
+ console.log(' flo epic run 42 Fetch epic #42 from GitHub, run stories');
724
+ console.log(' flo epic run feature.yaml Execute from YAML with dependencies');
725
+ console.log('');
726
+ console.log('Flags:');
727
+ console.log(' --dry-run Show execution plan without running');
728
+ console.log(' --verbose Stream Claude output to terminal');
729
+ console.log(' --no-merge Skip auto-merge after each story');
730
+ return { success: true };
731
+ }
732
+ switch (subcommand) {
733
+ case 'run': {
734
+ const source = ctx.args[1];
735
+ if (!source) {
736
+ console.log('Usage: flo epic run <issue-number | feature.yaml> [--dry-run] [--verbose]');
737
+ return { success: false, message: 'Missing issue number or feature YAML path' };
738
+ }
739
+ const dryRun = ctx.flags['dry-run'] === true || ctx.flags['dryRun'] === true;
740
+ const verbose = ctx.flags['verbose'] === true;
741
+ return runFeature(source, dryRun, verbose);
742
+ }
743
+ case 'status': {
744
+ const featureId = ctx.args[1];
745
+ if (!featureId) {
746
+ console.log('Usage: flo epic status <feature-id>');
747
+ return { success: false, message: 'Missing feature ID' };
748
+ }
749
+ return showStatus(featureId);
750
+ }
751
+ case 'reset': {
752
+ const featureId = ctx.args[1];
753
+ if (!featureId) {
754
+ console.log('Usage: flo epic reset <feature-id>');
755
+ return { success: false, message: 'Missing feature ID' };
756
+ }
757
+ return resetFeature(featureId);
758
+ }
759
+ default:
760
+ console.log(`Unknown subcommand: ${subcommand}`);
761
+ console.log('Available: run, status, reset');
762
+ return { success: false, message: `Unknown subcommand: ${subcommand}` };
763
+ }
764
+ },
765
+ };
766
+ export default epicCommand;
767
+ //# sourceMappingURL=epic.js.map