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.
- package/README.md +60 -0
- package/dist/commands/init.js +1 -0
- package/dist/commands/parallel.d.ts +104 -0
- package/dist/commands/parallel.js +679 -0
- package/dist/commands/upgrade.js +1 -0
- package/dist/index.js +24 -0
- package/package.json +1 -1
- package/payload/AGENTS.md +1 -1
- package/payload/commands/backlog.add.md +77 -0
- package/payload/commands/backlog.clarify.md +138 -0
- package/payload/commands/backlog.list.md +65 -0
- package/payload/commands/backlog.promote.md +98 -0
- package/payload/commands/dev-session.end.md +15 -0
- package/payload/commands/dev-session.start.md +19 -1
- package/payload/commands/parallel.cleanup.md +18 -0
- package/payload/commands/parallel.status.md +25 -0
|
@@ -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
|
+
}
|
package/dist/commands/upgrade.js
CHANGED
|
@@ -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);
|