flight-rules 0.13.9 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,679 @@
1
+ import * as p from '@clack/prompts';
2
+ import pc from 'picocolors';
3
+ import { spawn } from 'child_process';
4
+ import { existsSync, readFileSync, writeFileSync, copyFileSync, readdirSync } from 'fs';
5
+ import { join, basename, dirname, resolve } from 'path';
6
+ import { ensureDir } from '../utils/files.js';
7
+ import { isInteractive } from '../utils/interactive.js';
8
+ // ── Git helpers ──────────────────────────────────────────────────────────
9
+ /**
10
+ * Run a git command and return the result
11
+ */
12
+ export async function runGitCommand(args, cwd) {
13
+ return new Promise((resolve) => {
14
+ const git = spawn('git', args, {
15
+ stdio: ['pipe', 'pipe', 'pipe'],
16
+ cwd,
17
+ });
18
+ let output = '';
19
+ let error = '';
20
+ git.stdout?.on('data', (data) => {
21
+ output += data.toString();
22
+ });
23
+ git.stderr?.on('data', (data) => {
24
+ error += data.toString();
25
+ });
26
+ git.on('close', (code) => {
27
+ resolve({ success: code === 0, output: output.trim(), error: error.trim() });
28
+ });
29
+ git.on('error', (err) => {
30
+ resolve({ success: false, output: '', error: err.message });
31
+ });
32
+ });
33
+ }
34
+ /**
35
+ * Check if git working directory is clean
36
+ */
37
+ export async function isGitClean(cwd) {
38
+ const result = await runGitCommand(['status', '--porcelain'], cwd);
39
+ return result.success && result.output === '';
40
+ }
41
+ /**
42
+ * Check if the current directory is inside a git worktree (not the main working tree)
43
+ */
44
+ export async function isInsideWorktree(cwd) {
45
+ const result = await runGitCommand(['rev-parse', '--is-inside-work-tree'], cwd);
46
+ if (!result.success)
47
+ return false;
48
+ // Check if this is the main worktree or a linked worktree
49
+ const gitDir = await runGitCommand(['rev-parse', '--git-dir'], cwd);
50
+ if (!gitDir.success)
51
+ return false;
52
+ // Linked worktrees have a .git file (not directory) pointing to .git/worktrees/<name>
53
+ return gitDir.output.includes('/worktrees/');
54
+ }
55
+ /**
56
+ * Get list of git worktrees
57
+ */
58
+ export async function listGitWorktrees(cwd) {
59
+ const result = await runGitCommand(['worktree', 'list', '--porcelain'], cwd);
60
+ if (!result.success)
61
+ return [];
62
+ const worktrees = [];
63
+ let current = null;
64
+ for (const line of result.output.split('\n')) {
65
+ if (line.startsWith('worktree ')) {
66
+ if (current)
67
+ worktrees.push(current);
68
+ current = { path: line.slice(9), branch: '', bare: false };
69
+ }
70
+ else if (line.startsWith('branch ') && current) {
71
+ current.branch = line.slice(7).replace('refs/heads/', '');
72
+ }
73
+ else if (line === 'bare' && current) {
74
+ current.bare = true;
75
+ }
76
+ }
77
+ if (current)
78
+ worktrees.push(current);
79
+ return worktrees;
80
+ }
81
+ /**
82
+ * Get the number of commits the base branch is ahead of the session branch point
83
+ */
84
+ export async function getCommitsAhead(baseBranch, sessionBranch, cwd) {
85
+ const result = await runGitCommand(['rev-list', '--count', `${sessionBranch}..${baseBranch}`], cwd);
86
+ if (!result.success)
87
+ return 0;
88
+ return parseInt(result.output, 10) || 0;
89
+ }
90
+ /**
91
+ * Get the current branch name
92
+ */
93
+ export async function getCurrentBranch(cwd) {
94
+ const result = await runGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
95
+ return result.success ? result.output : 'main';
96
+ }
97
+ // ── Manifest helpers ─────────────────────────────────────────────────────
98
+ /**
99
+ * Get the sessions directory path for a project
100
+ */
101
+ export function getSessionsDir(projectDir) {
102
+ const projectName = basename(resolve(projectDir));
103
+ return join(dirname(resolve(projectDir)), `${projectName}-sessions`);
104
+ }
105
+ /**
106
+ * Get the manifest file path
107
+ */
108
+ export function getManifestPath(projectDir) {
109
+ return join(getSessionsDir(projectDir), '.manifest.json');
110
+ }
111
+ /**
112
+ * Read the session manifest, returning null if not found or invalid
113
+ */
114
+ export function readSessionManifest(projectDir) {
115
+ const manifestPath = getManifestPath(projectDir);
116
+ if (!existsSync(manifestPath))
117
+ return null;
118
+ try {
119
+ const content = readFileSync(manifestPath, 'utf-8');
120
+ return JSON.parse(content);
121
+ }
122
+ catch {
123
+ return null;
124
+ }
125
+ }
126
+ /**
127
+ * Write the session manifest
128
+ */
129
+ export function writeSessionManifest(projectDir, manifest) {
130
+ const manifestPath = getManifestPath(projectDir);
131
+ ensureDir(dirname(manifestPath));
132
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
133
+ }
134
+ /**
135
+ * Create an empty manifest for a project
136
+ */
137
+ export function createEmptyManifest(projectDir) {
138
+ return {
139
+ version: 1,
140
+ project: basename(resolve(projectDir)),
141
+ sessions: [],
142
+ };
143
+ }
144
+ /**
145
+ * Add a session to the manifest
146
+ */
147
+ export function addSessionToManifest(projectDir, session) {
148
+ let manifest = readSessionManifest(projectDir);
149
+ if (!manifest) {
150
+ manifest = createEmptyManifest(projectDir);
151
+ }
152
+ manifest.sessions.push(session);
153
+ writeSessionManifest(projectDir, manifest);
154
+ return manifest;
155
+ }
156
+ /**
157
+ * Remove a session from the manifest by ID
158
+ */
159
+ export function removeSessionFromManifest(projectDir, sessionId) {
160
+ const manifest = readSessionManifest(projectDir);
161
+ if (!manifest)
162
+ return null;
163
+ manifest.sessions = manifest.sessions.filter((s) => s.id !== sessionId);
164
+ writeSessionManifest(projectDir, manifest);
165
+ return manifest;
166
+ }
167
+ // ── Env file helpers ─────────────────────────────────────────────────────
168
+ const COMMON_ENV_FILES = [
169
+ '.env',
170
+ '.env.local',
171
+ '.env.development.local',
172
+ ];
173
+ /**
174
+ * Copy common environment files from source to destination
175
+ */
176
+ export function copyEnvFiles(sourceDir, destDir) {
177
+ const copied = [];
178
+ for (const envFile of COMMON_ENV_FILES) {
179
+ const srcPath = join(sourceDir, envFile);
180
+ if (existsSync(srcPath)) {
181
+ copyFileSync(srcPath, join(destDir, envFile));
182
+ copied.push(envFile);
183
+ }
184
+ }
185
+ return copied;
186
+ }
187
+ // ── Time helpers ─────────────────────────────────────────────────────────
188
+ /**
189
+ * Format a relative time string from an ISO date
190
+ */
191
+ export function formatRelativeTime(isoDate) {
192
+ const now = Date.now();
193
+ const then = new Date(isoDate).getTime();
194
+ const diffMs = now - then;
195
+ const minutes = Math.floor(diffMs / 60000);
196
+ if (minutes < 1)
197
+ return 'just now';
198
+ if (minutes < 60)
199
+ return `${minutes}m ago`;
200
+ const hours = Math.floor(minutes / 60);
201
+ if (hours < 24)
202
+ return `${hours}h ago`;
203
+ const days = Math.floor(hours / 24);
204
+ if (days === 1)
205
+ return '1 day ago';
206
+ return `${days} days ago`;
207
+ }
208
+ // ── Subcommands ──────────────────────────────────────────────────────────
209
+ /**
210
+ * Create a new parallel session
211
+ */
212
+ export async function parallelCreate(sessionName, goals = []) {
213
+ const cwd = process.cwd();
214
+ const sessionsDir = getSessionsDir(cwd);
215
+ const worktreePath = join(sessionsDir, sessionName);
216
+ const branchName = `session/${sessionName}`;
217
+ // Check if we're inside a worktree already
218
+ if (await isInsideWorktree(cwd)) {
219
+ p.log.error("You're already inside a parallel session worktree.");
220
+ p.log.info('Start new parallel sessions from the main project directory.');
221
+ return;
222
+ }
223
+ // Check for existing session with same name
224
+ const manifest = readSessionManifest(cwd);
225
+ if (manifest?.sessions.some((s) => s.id === sessionName)) {
226
+ p.log.error(`A session named '${sessionName}' already exists.`);
227
+ p.log.info('Use `flight-rules parallel status` to see active sessions.');
228
+ return;
229
+ }
230
+ // Check if worktree path already exists
231
+ if (existsSync(worktreePath)) {
232
+ p.log.error(`Worktree path already exists: ${worktreePath}`);
233
+ p.log.info('Use `flight-rules parallel cleanup` to remove orphaned worktrees.');
234
+ return;
235
+ }
236
+ // Check for uncommitted changes
237
+ const clean = await isGitClean(cwd);
238
+ if (!clean) {
239
+ if (!isInteractive()) {
240
+ p.log.error('Git working directory is not clean.');
241
+ p.log.info('Commit or stash your changes before creating a parallel session.');
242
+ return;
243
+ }
244
+ const proceed = await p.confirm({
245
+ message: 'You have uncommitted changes. Parallel sessions branch from HEAD. Proceed anyway?',
246
+ initialValue: false,
247
+ });
248
+ if (p.isCancel(proceed) || !proceed) {
249
+ p.log.info('Cancelled. Commit or stash your changes first.');
250
+ return;
251
+ }
252
+ }
253
+ // Check if branch already exists
254
+ const branchCheck = await runGitCommand(['rev-parse', '--verify', branchName]);
255
+ if (branchCheck.success) {
256
+ p.log.error(`Branch '${branchName}' already exists.`);
257
+ p.log.info('Choose a different session name or delete the existing branch.');
258
+ return;
259
+ }
260
+ // Create sessions directory
261
+ ensureDir(sessionsDir);
262
+ // Create worktree with new branch
263
+ p.log.info(`Creating worktree at ${pc.cyan(worktreePath)}...`);
264
+ const worktreeResult = await runGitCommand([
265
+ 'worktree', 'add', worktreePath, '-b', branchName,
266
+ ]);
267
+ if (!worktreeResult.success) {
268
+ p.log.error(`Failed to create worktree: ${worktreeResult.error}`);
269
+ return;
270
+ }
271
+ p.log.success(`Worktree created on branch ${pc.cyan(branchName)}`);
272
+ // Copy env files
273
+ const copiedEnvFiles = copyEnvFiles(cwd, worktreePath);
274
+ if (copiedEnvFiles.length > 0) {
275
+ p.log.success(`Copied env files: ${copiedEnvFiles.join(', ')}`);
276
+ }
277
+ // Register in manifest
278
+ const session = {
279
+ id: sessionName,
280
+ branch: branchName,
281
+ worktree: worktreePath,
282
+ startedAt: new Date().toISOString(),
283
+ goals,
284
+ status: 'active',
285
+ };
286
+ addSessionToManifest(cwd, session);
287
+ p.log.success('Session registered in manifest');
288
+ // Display navigation instructions
289
+ console.log();
290
+ p.log.message(pc.dim('─'.repeat(50)));
291
+ p.log.message(`To work in this session, open a new terminal and run:`);
292
+ console.log();
293
+ p.log.message(` ${pc.cyan(`cd ${worktreePath}`)}`);
294
+ p.log.message(` ${pc.cyan('claude')}`);
295
+ console.log();
296
+ p.log.message(pc.dim('─'.repeat(50)));
297
+ }
298
+ /**
299
+ * Show status of all parallel sessions
300
+ */
301
+ export async function parallelStatus() {
302
+ const cwd = process.cwd();
303
+ const manifest = readSessionManifest(cwd);
304
+ if (!manifest || manifest.sessions.length === 0) {
305
+ p.log.info('No active parallel sessions.');
306
+ p.log.info('Create one with: flight-rules parallel create <name>');
307
+ return;
308
+ }
309
+ // Get actual worktrees for cross-reference
310
+ const worktrees = await listGitWorktrees(cwd);
311
+ const worktreePaths = new Set(worktrees.map((w) => w.path));
312
+ console.log();
313
+ p.log.message(pc.bold('Active Parallel Sessions'));
314
+ p.log.message(pc.dim('─'.repeat(60)));
315
+ for (const session of manifest.sessions) {
316
+ const exists = existsSync(session.worktree) && worktreePaths.has(session.worktree);
317
+ const statusIcon = exists ? pc.green('●') : pc.red('●');
318
+ const orphanTag = exists ? '' : pc.red(' (orphaned)');
319
+ const age = formatRelativeTime(session.startedAt);
320
+ p.log.message(` ${statusIcon} ${pc.cyan(session.id)}${orphanTag}`);
321
+ p.log.message(` Branch: ${session.branch}`);
322
+ p.log.message(` Started: ${age}`);
323
+ if (session.goals.length > 0) {
324
+ p.log.message(` Goals: ${session.goals.join(', ')}`);
325
+ }
326
+ p.log.message('');
327
+ }
328
+ p.log.message(pc.dim('─'.repeat(60)));
329
+ // Show main directory status
330
+ const mainClean = await isGitClean(cwd);
331
+ const mainBranch = await getCurrentBranch(cwd);
332
+ p.log.message(`Main directory: ${cwd} (${mainBranch}, ${mainClean ? 'clean' : 'dirty'})`);
333
+ // Check for orphaned worktrees not in manifest
334
+ const manifestPaths = new Set(manifest.sessions.map((s) => s.worktree));
335
+ const sessionsDir = getSessionsDir(cwd);
336
+ const unmanagedWorktrees = worktrees.filter((w) => w.path.startsWith(sessionsDir) && !manifestPaths.has(w.path));
337
+ if (unmanagedWorktrees.length > 0) {
338
+ console.log();
339
+ p.log.warn('Found worktrees not in manifest:');
340
+ for (const wt of unmanagedWorktrees) {
341
+ p.log.message(` ${pc.yellow(wt.path)} (${wt.branch})`);
342
+ }
343
+ p.log.info('Run `flight-rules parallel cleanup` to resolve.');
344
+ }
345
+ }
346
+ /**
347
+ * Clean up orphaned parallel sessions
348
+ */
349
+ export async function parallelCleanup(options = {}) {
350
+ const cwd = process.cwd();
351
+ const manifest = readSessionManifest(cwd);
352
+ const interactive = isInteractive() && !options.force;
353
+ if (!manifest || manifest.sessions.length === 0) {
354
+ p.log.info('No sessions in manifest. Nothing to clean up.');
355
+ return;
356
+ }
357
+ // Get actual worktrees
358
+ const worktrees = await listGitWorktrees(cwd);
359
+ const worktreePaths = new Set(worktrees.map((w) => w.path));
360
+ // Find orphaned manifest entries (worktree no longer exists)
361
+ const orphanedEntries = manifest.sessions.filter((s) => !existsSync(s.worktree) || !worktreePaths.has(s.worktree));
362
+ if (orphanedEntries.length === 0) {
363
+ p.log.success('No orphaned sessions found. Everything is clean.');
364
+ return;
365
+ }
366
+ p.log.info(`Found ${orphanedEntries.length} orphaned session(s):`);
367
+ for (const entry of orphanedEntries) {
368
+ p.log.message(` ${pc.yellow(entry.id)} — worktree missing at ${entry.worktree}`);
369
+ }
370
+ let removeCount = 0;
371
+ for (const entry of orphanedEntries) {
372
+ let shouldRemove = true;
373
+ if (interactive) {
374
+ const action = await p.confirm({
375
+ message: `Remove orphaned session '${entry.id}' from manifest and delete branch '${entry.branch}'?`,
376
+ initialValue: true,
377
+ });
378
+ if (p.isCancel(action)) {
379
+ p.log.info('Cleanup cancelled.');
380
+ return;
381
+ }
382
+ shouldRemove = action;
383
+ }
384
+ if (shouldRemove) {
385
+ // Remove from manifest
386
+ removeSessionFromManifest(cwd, entry.id);
387
+ // Try to delete the branch
388
+ const branchResult = await runGitCommand(['branch', '-D', entry.branch]);
389
+ if (branchResult.success) {
390
+ p.log.success(`Removed session '${entry.id}' and deleted branch '${entry.branch}'`);
391
+ }
392
+ else {
393
+ p.log.success(`Removed session '${entry.id}' from manifest`);
394
+ p.log.info(`Branch '${entry.branch}' may need manual cleanup: ${branchResult.error}`);
395
+ }
396
+ removeCount++;
397
+ }
398
+ }
399
+ if (removeCount > 0) {
400
+ p.log.success(`Cleaned up ${removeCount} orphaned session(s).`);
401
+ }
402
+ // Clean up empty sessions directory
403
+ const sessionsDir = getSessionsDir(cwd);
404
+ if (existsSync(sessionsDir)) {
405
+ const remaining = readSessionManifest(cwd);
406
+ if (!remaining || remaining.sessions.length === 0) {
407
+ const entries = readdirSync(sessionsDir);
408
+ // Only .manifest.json remains
409
+ if (entries.length <= 1 && entries.every((e) => e === '.manifest.json')) {
410
+ p.log.info('Sessions directory is empty and can be removed manually.');
411
+ }
412
+ }
413
+ }
414
+ }
415
+ /**
416
+ * Remove a specific parallel session with merge workflow
417
+ */
418
+ export async function parallelRemove(sessionName, options = {}) {
419
+ const cwd = process.cwd();
420
+ const manifest = readSessionManifest(cwd);
421
+ const interactive = isInteractive() && !options.force;
422
+ if (!manifest) {
423
+ p.log.error('No session manifest found.');
424
+ return;
425
+ }
426
+ const session = manifest.sessions.find((s) => s.id === sessionName);
427
+ if (!session) {
428
+ p.log.error(`Session '${sessionName}' not found.`);
429
+ p.log.info('Run `flight-rules parallel status` to see active sessions.');
430
+ return;
431
+ }
432
+ // Check if worktree still exists
433
+ if (!existsSync(session.worktree)) {
434
+ p.log.warn(`Worktree at ${session.worktree} no longer exists.`);
435
+ removeSessionFromManifest(cwd, sessionName);
436
+ p.log.success(`Removed '${sessionName}' from manifest.`);
437
+ return;
438
+ }
439
+ // Check for uncommitted changes in the worktree
440
+ const worktreeClean = await isGitClean(session.worktree);
441
+ if (!worktreeClean) {
442
+ p.log.warn(`Session '${sessionName}' has uncommitted changes.`);
443
+ if (!interactive) {
444
+ p.log.error('Cannot remove a session with uncommitted changes in non-interactive mode.');
445
+ p.log.info('Commit or discard changes in the worktree first.');
446
+ return;
447
+ }
448
+ const proceed = await p.confirm({
449
+ message: 'Uncommitted changes will be lost. Proceed?',
450
+ initialValue: false,
451
+ });
452
+ if (p.isCancel(proceed) || !proceed) {
453
+ p.log.info('Cancelled. Commit your changes first.');
454
+ return;
455
+ }
456
+ }
457
+ let strategy = 'keep'; // safe default for non-interactive
458
+ if (interactive) {
459
+ // Check if base branch has diverged
460
+ const baseBranch = await getCurrentBranch(cwd);
461
+ const commitsAhead = await getCommitsAhead(baseBranch, session.branch, cwd);
462
+ if (commitsAhead > 0) {
463
+ p.log.warn(`The ${baseBranch} branch has ${commitsAhead} new commit(s) since this session started.`);
464
+ }
465
+ const choice = await p.select({
466
+ message: 'How would you like to integrate these changes?',
467
+ options: [
468
+ { value: 'pr', label: 'Create a PR', hint: 'recommended for review' },
469
+ { value: 'merge', label: 'Merge directly', hint: 'for small/safe changes' },
470
+ { value: 'keep', label: 'Keep branch for later', hint: "don't merge yet" },
471
+ { value: 'abandon', label: 'Abandon', hint: 'discard all changes' },
472
+ ],
473
+ });
474
+ if (p.isCancel(choice)) {
475
+ p.log.info('Cancelled.');
476
+ return;
477
+ }
478
+ strategy = choice;
479
+ }
480
+ // Execute the merge strategy
481
+ let branchDeleted = false;
482
+ switch (strategy) {
483
+ case 'pr': {
484
+ p.log.info('Pushing branch and creating PR...');
485
+ const pushResult = await runGitCommand(['push', '-u', 'origin', session.branch], session.worktree);
486
+ if (!pushResult.success) {
487
+ p.log.error(`Failed to push: ${pushResult.error}`);
488
+ p.log.info('You can push manually and create a PR later.');
489
+ // Fall through to cleanup anyway
490
+ }
491
+ else {
492
+ p.log.success(`Pushed ${pc.cyan(session.branch)} to origin`);
493
+ // Try to create PR via gh
494
+ const ghResult = await runGhPrCreate(session);
495
+ if (ghResult.success) {
496
+ p.log.success(`PR created: ${ghResult.output}`);
497
+ }
498
+ else {
499
+ p.log.warn('Could not create PR automatically.');
500
+ p.log.info(`Create one manually from branch ${pc.cyan(session.branch)}`);
501
+ }
502
+ }
503
+ break;
504
+ }
505
+ case 'merge': {
506
+ const baseBranch = await getCurrentBranch(cwd);
507
+ p.log.info(`Merging ${session.branch} into ${baseBranch}...`);
508
+ const mergeResult = await runGitCommand(['merge', session.branch], cwd);
509
+ if (!mergeResult.success) {
510
+ p.log.error(`Merge failed: ${mergeResult.error}`);
511
+ p.log.info('Resolve conflicts manually or choose a different strategy.');
512
+ return; // Don't clean up if merge failed
513
+ }
514
+ p.log.success(`Merged ${pc.cyan(session.branch)} into ${baseBranch}`);
515
+ branchDeleted = true; // We'll delete the branch after worktree removal
516
+ break;
517
+ }
518
+ case 'keep': {
519
+ // Try to push the branch to remote for safekeeping
520
+ const pushResult = await runGitCommand(['push', '-u', 'origin', session.branch], session.worktree);
521
+ if (pushResult.success) {
522
+ p.log.success(`Branch ${pc.cyan(session.branch)} pushed to origin for later.`);
523
+ }
524
+ else {
525
+ p.log.info(`Branch ${pc.cyan(session.branch)} kept locally (push failed or no remote).`);
526
+ }
527
+ break;
528
+ }
529
+ case 'abandon': {
530
+ p.log.info('Abandoning session changes...');
531
+ branchDeleted = true; // Will delete branch after worktree removal
532
+ break;
533
+ }
534
+ }
535
+ // Clean up worktree
536
+ p.log.info('Removing worktree...');
537
+ const removeResult = await runGitCommand([
538
+ 'worktree', 'remove', session.worktree, '--force',
539
+ ]);
540
+ if (!removeResult.success) {
541
+ p.log.warn(`Could not remove worktree: ${removeResult.error}`);
542
+ p.log.info(`You may need to remove it manually: rm -rf ${session.worktree}`);
543
+ }
544
+ else {
545
+ p.log.success('Worktree removed');
546
+ }
547
+ // Delete branch if appropriate
548
+ if (branchDeleted) {
549
+ const deleteResult = await runGitCommand(['branch', '-D', session.branch]);
550
+ if (deleteResult.success) {
551
+ p.log.success(`Branch ${pc.cyan(session.branch)} deleted`);
552
+ }
553
+ else {
554
+ p.log.info(`Branch ${session.branch} may need manual cleanup.`);
555
+ }
556
+ }
557
+ // Update manifest
558
+ removeSessionFromManifest(cwd, sessionName);
559
+ p.log.success(`Session '${sessionName}' removed from manifest`);
560
+ // Clean up empty sessions directory
561
+ const sessionsDir = getSessionsDir(cwd);
562
+ if (existsSync(sessionsDir)) {
563
+ const remainingManifest = readSessionManifest(cwd);
564
+ if (!remainingManifest || remainingManifest.sessions.length === 0) {
565
+ const entries = readdirSync(sessionsDir);
566
+ if (entries.length <= 1) {
567
+ p.log.info('No remaining sessions. Sessions directory can be removed.');
568
+ }
569
+ }
570
+ }
571
+ }
572
+ // ── GitHub CLI helper ────────────────────────────────────────────────────
573
+ /**
574
+ * Try to create a PR using gh CLI
575
+ */
576
+ async function runGhPrCreate(session) {
577
+ return new Promise((resolve) => {
578
+ const title = session.goals.length > 0
579
+ ? session.goals[0]
580
+ : `Parallel session: ${session.id}`;
581
+ const body = [
582
+ '## Summary',
583
+ '',
584
+ `Parallel session \`${session.id}\` started at ${session.startedAt}.`,
585
+ '',
586
+ session.goals.length > 0 ? '### Goals' : '',
587
+ ...session.goals.map((g) => `- ${g}`),
588
+ '',
589
+ '---',
590
+ `Created by \`flight-rules parallel remove --pr\``,
591
+ ].join('\n');
592
+ const gh = spawn('gh', [
593
+ 'pr', 'create',
594
+ '--title', title,
595
+ '--body', body,
596
+ ], {
597
+ stdio: ['pipe', 'pipe', 'pipe'],
598
+ cwd: session.worktree,
599
+ });
600
+ let output = '';
601
+ gh.stdout?.on('data', (data) => {
602
+ output += data.toString();
603
+ });
604
+ gh.stderr?.on('data', (data) => {
605
+ output += data.toString();
606
+ });
607
+ gh.on('close', (code) => {
608
+ resolve({ success: code === 0, output: output.trim() });
609
+ });
610
+ gh.on('error', () => {
611
+ resolve({ success: false, output: 'gh CLI not available' });
612
+ });
613
+ });
614
+ }
615
+ // ── Main entry point ─────────────────────────────────────────────────────
616
+ /**
617
+ * Main parallel command dispatcher
618
+ */
619
+ export async function parallel(subcommand, args, options = {}) {
620
+ switch (subcommand) {
621
+ case 'create': {
622
+ const name = args[0];
623
+ if (!name) {
624
+ p.log.error('Session name is required.');
625
+ p.log.info('Usage: flight-rules parallel create <name>');
626
+ return;
627
+ }
628
+ // Validate session name (alphanumeric, hyphens, underscores)
629
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
630
+ p.log.error('Session name must contain only letters, numbers, hyphens, and underscores.');
631
+ return;
632
+ }
633
+ await parallelCreate(name, args.slice(1));
634
+ break;
635
+ }
636
+ case 'status':
637
+ await parallelStatus();
638
+ break;
639
+ case 'cleanup':
640
+ await parallelCleanup(options);
641
+ break;
642
+ case 'remove': {
643
+ const name = args[0];
644
+ if (!name) {
645
+ p.log.error('Session name is required.');
646
+ p.log.info('Usage: flight-rules parallel remove <name>');
647
+ return;
648
+ }
649
+ await parallelRemove(name, options);
650
+ break;
651
+ }
652
+ default:
653
+ if (subcommand) {
654
+ p.log.error(`Unknown subcommand: ${subcommand}`);
655
+ }
656
+ showParallelHelp();
657
+ break;
658
+ }
659
+ }
660
+ function showParallelHelp() {
661
+ console.log(`
662
+ ${pc.bold('Usage:')} flight-rules parallel <subcommand> [options]
663
+
664
+ ${pc.bold('Subcommands:')}
665
+ create <name> Create a new parallel session in an isolated worktree
666
+ status Show all active parallel sessions
667
+ cleanup Detect and clean up orphaned sessions
668
+ remove <name> Remove a session (with merge workflow)
669
+
670
+ ${pc.bold('Options:')}
671
+ --force Skip confirmations (cleanup, remove)
672
+
673
+ ${pc.bold('Examples:')}
674
+ flight-rules parallel create auth-refactor
675
+ flight-rules parallel status
676
+ flight-rules parallel cleanup
677
+ flight-rules parallel remove auth-refactor
678
+ `);
679
+ }
@@ -20,6 +20,7 @@ function copyNewDocsFromTemplates(templatesDir, docsDir) {
20
20
  ensureDir(docsDir);
21
21
  ensureDir(join(docsDir, 'implementation'));
22
22
  ensureDir(join(docsDir, 'session_logs'));
23
+ ensureDir(join(docsDir, 'backlog'));
23
24
  const copied = [];
24
25
  for (const file of DOC_FILES) {
25
26
  const srcPath = join(templatesDir, file.src);