jettypod 4.4.21 → 4.4.23

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,1603 @@
1
+ const { execSync } = require('child_process');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { getDb, getDbPath, getJettypodDir } = require('../../lib/database');
5
+ const { getCurrentWork, setCurrentWork, clearCurrentWork, getCurrentWorkPath } = require('../../lib/current-work');
6
+ const { VALID_STATUSES } = require('../../lib/constants');
7
+ const { updateCurrentWork } = require('../../lib/claudemd');
8
+ const { createFeatureBranchName, createOrCheckoutBranch } = require('../../lib/git');
9
+ const config = require('../../lib/config');
10
+ const { getBugWorkflowForTerminal } = require('../../lib/bug-workflow');
11
+ const worktreeFacade = require('../../lib/worktree-facade');
12
+ const worktreeManager = require('../../lib/worktree-manager');
13
+ const { getGitRoot } = require('../../lib/git-root');
14
+
15
+ /**
16
+ * Get paths to JettyPod files and directories
17
+ * @returns {Object} Paths object with jettypodDir, currentWorkPath, dbPath, claudePath
18
+ */
19
+ function getPaths() {
20
+ return {
21
+ jettypodDir: getJettypodDir(),
22
+ currentWorkPath: getCurrentWorkPath(),
23
+ dbPath: getDbPath(),
24
+ claudePath: path.join(process.cwd(), 'CLAUDE.md')
25
+ };
26
+ }
27
+
28
+ /**
29
+ * Start work on a work item
30
+ * @param {number} id - Work item ID to start
31
+ * @returns {Promise<Object>} Work item, current work, and branch name
32
+ * @throws {Error} If ID is invalid, JettyPod not initialized, database missing, or work item not found
33
+ */
34
+ async function startWork(id) {
35
+ // Input validation
36
+ if (!id || isNaN(id) || id < 1) {
37
+ return Promise.reject(new Error('Invalid work item ID'));
38
+ }
39
+
40
+ const paths = getPaths();
41
+
42
+ // Check jettypod directory exists
43
+ if (!fs.existsSync(paths.jettypodDir)) {
44
+ return Promise.reject(new Error('JettyPod not initialized. Run: jettypod init'));
45
+ }
46
+
47
+ // Check database exists
48
+ if (!fs.existsSync(paths.dbPath)) {
49
+ return Promise.reject(new Error('Work database not found. Run: jettypod init'));
50
+ }
51
+
52
+ const db = getDb();
53
+
54
+ return new Promise((resolve, reject) => {
55
+ // Get work item
56
+ db.get(`
57
+ SELECT w.*,
58
+ p.title as parent_title, p.id as parent_id, p.scenario_file as parent_scenario_file, p.mode as parent_mode,
59
+ e.title as epic_title, e.id as epic_id
60
+ FROM work_items w
61
+ LEFT JOIN work_items p ON w.parent_id = p.id
62
+ LEFT JOIN work_items e ON w.epic_id = e.id AND w.epic_id != w.id
63
+ WHERE w.id = ?
64
+ `, [id], (err, workItem) => {
65
+ if (err) {
66
+ return reject(new Error(`Database error: ${err.message}`));
67
+ }
68
+
69
+ if (!workItem) {
70
+ return reject(new Error(`Work item #${id} not found`));
71
+ }
72
+
73
+ // Prevent starting features directly - must start chores instead
74
+ if (workItem.type === 'feature') {
75
+ // Find available chores for this feature
76
+ db.all(
77
+ `SELECT id, title, status FROM work_items WHERE parent_id = ? AND type = 'chore' ORDER BY id`,
78
+ [workItem.id],
79
+ (err, chores) => {
80
+ if (err) {
81
+ return reject(new Error(`Cannot start a feature directly. Start one of its chores instead.`));
82
+ }
83
+
84
+ let errorMsg = `Cannot start feature #${id} directly.\n\n`;
85
+
86
+ if (chores && chores.length > 0) {
87
+ const todoChores = chores.filter(c => c.status === 'todo' || c.status === 'backlog');
88
+ const inProgressChores = chores.filter(c => c.status === 'in_progress');
89
+
90
+ if (inProgressChores.length > 0) {
91
+ errorMsg += `In-progress chore:\n`;
92
+ inProgressChores.forEach(c => {
93
+ errorMsg += ` jettypod work start ${c.id} # ${c.title}\n`;
94
+ });
95
+ errorMsg += `\n`;
96
+ }
97
+
98
+ if (todoChores.length > 0) {
99
+ errorMsg += `Available chores to start:\n`;
100
+ todoChores.forEach(c => {
101
+ errorMsg += ` jettypod work start ${c.id} # ${c.title}\n`;
102
+ });
103
+ } else if (inProgressChores.length === 0) {
104
+ errorMsg += `No chores available. Create chores first or check if feature is complete.`;
105
+ }
106
+ } else {
107
+ errorMsg += `This feature has no chores yet. Use the feature-planning skill to plan it:\n`;
108
+ errorMsg += ` Tell Claude: "Help me plan feature #${id}"`;
109
+ }
110
+
111
+ return reject(new Error(errorMsg));
112
+ }
113
+ );
114
+ return; // Exit early - callback will handle resolve/reject
115
+ }
116
+
117
+ // Prevent starting epics directly
118
+ if (workItem.type === 'epic') {
119
+ return reject(new Error(`Cannot start epic #${id} directly. Start a feature or chore instead.\n\nUse: jettypod backlog # to see available work items`));
120
+ }
121
+
122
+ // Update status to in_progress if currently todo
123
+ const finalStatus = workItem.status === 'todo' ? 'in_progress' : workItem.status;
124
+
125
+ const updateAndContinue = async () => {
126
+ // Create current work file
127
+ const currentWork = {
128
+ id: workItem.id,
129
+ title: workItem.title,
130
+ type: workItem.type,
131
+ status: finalStatus,
132
+ parent_id: workItem.parent_id,
133
+ parent_title: workItem.parent_title,
134
+ epic_id: workItem.epic_id,
135
+ epic_title: workItem.epic_title
136
+ };
137
+
138
+ try {
139
+ setCurrentWork(currentWork);
140
+ } catch (err) {
141
+ return reject(new Error(`Failed to write current work file: ${err.message}`));
142
+ }
143
+
144
+ // ✅ BULLETPROOF WORKTREE ARCHITECTURE (Epic #1783)
145
+ // Using transactional worktree-facade with atomic rollback and graceful degradation
146
+ let worktreeResult = null;
147
+ let worktreeId = null;
148
+
149
+ try {
150
+ const { isGitRepo } = require('../../lib/git');
151
+
152
+ if (isGitRepo()) {
153
+ // Use bulletproof worktree-facade for isolated work
154
+ worktreeResult = await worktreeFacade.startWork(workItem, {
155
+ repoPath: getGitRoot(),
156
+ db: db
157
+ });
158
+
159
+ if (worktreeResult.mode === 'worktree') {
160
+ // Worktree created successfully
161
+ worktreeId = worktreeResult.worktree.id;
162
+ console.log(`✅ Created worktree: ${worktreeResult.path}`);
163
+ console.log(`📁 IMPORTANT: Use absolute paths for file operations:`);
164
+ console.log(` ${worktreeResult.path}/lib/file.js (correct)`);
165
+ console.log(` lib/file.js (wrong - creates in main repo)`);
166
+
167
+ // Create worktree-specific session file with work context
168
+ // Session file is gitignored to prevent merge conflicts
169
+ const { writeSessionFile } = require('../../lib/session-writer');
170
+ const modeToUse = (workItem.type === 'chore' && workItem.parent_mode)
171
+ ? workItem.parent_mode
172
+ : workItem.mode;
173
+
174
+ if (modeToUse) {
175
+ try {
176
+ // Change to worktree directory to write session file there
177
+ const originalCwd = process.cwd();
178
+ process.chdir(worktreeResult.path);
179
+ writeSessionFile(currentWork, modeToUse, {
180
+ epicId: workItem.epic_id,
181
+ epicTitle: workItem.epic_title
182
+ });
183
+ process.chdir(originalCwd);
184
+ } catch (sessionError) {
185
+ // Session file is important but not critical - warn but don't fail
186
+ console.warn(`Warning: Failed to create session file: ${sessionError.message}`);
187
+ }
188
+ }
189
+ } else {
190
+ // Fell back to main repository
191
+ console.warn(`⚠️ Working in main repository (worktree creation failed)`);
192
+ if (worktreeResult.error) {
193
+ console.warn(` Reason: ${worktreeResult.error.message}`);
194
+ }
195
+ }
196
+ }
197
+ } catch (gitError) {
198
+ // Git operations unavailable - continue without worktree
199
+ console.warn(`Warning: Git operations unavailable: ${gitError.message}`);
200
+ }
201
+
202
+
203
+ // Update CLAUDE.md with work item's mode
204
+ // For chores, inherit the parent feature's mode
205
+ const modeToUse = (workItem.type === 'chore' && workItem.parent_mode)
206
+ ? workItem.parent_mode
207
+ : workItem.mode;
208
+ updateCurrentWork(currentWork, modeToUse);
209
+
210
+ // Display output
211
+ let output = `Working on: [#${workItem.id}] ${workItem.title} (${workItem.type})`;
212
+ if (workItem.parent_title) {
213
+ output = `Working on: [#${workItem.id}] ${workItem.title} (${workItem.type} of #${workItem.parent_id} ${workItem.parent_title})`;
214
+ }
215
+ console.log(output);
216
+
217
+ // Display bug workflow guidance for bugs
218
+ // Only display if workItem has a type (defensive check)
219
+ if (workItem && workItem.type === 'bug') {
220
+ try {
221
+ console.log(getBugWorkflowForTerminal());
222
+ } catch (err) {
223
+ // Silently fail if workflow display fails - don't block work start
224
+ // This is a non-critical feature
225
+ }
226
+ }
227
+
228
+ // Display epic discovery guidance for epics with needs_discovery
229
+ if (workItem && workItem.type === 'epic' && workItem.needs_discovery) {
230
+ // Check if decisions have been recorded
231
+ db.all(
232
+ `SELECT * FROM discovery_decisions WHERE work_item_id = ?`,
233
+ [workItem.id],
234
+ (err, decisions) => {
235
+ if (err || !decisions || decisions.length === 0) {
236
+ console.log('');
237
+ console.log('⚠️ This epic needs architectural discovery before building features.');
238
+ console.log('');
239
+ console.log('💬 Recommended: Talk to Claude Code');
240
+ console.log(` Say: "Let's do epic discovery for #${workItem.id}"`);
241
+ console.log('');
242
+ console.log(' Claude Code will guide you through:');
243
+ console.log(' • Suggesting 3 architectural options');
244
+ console.log(' • Building prototypes');
245
+ console.log(' • Recording your decisions');
246
+ console.log('');
247
+ }
248
+ }
249
+ );
250
+ }
251
+
252
+ // Auto-trigger feature discovery for features in discovery phase
253
+ if (workItem && workItem.type === 'feature' && workItem.phase === 'discovery') {
254
+ console.log('');
255
+ console.log('✨ Feature Discovery Mode');
256
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
257
+ console.log('');
258
+ console.log('This feature is in discovery phase. Claude Code will help you:');
259
+ console.log(' 1. Explore 3 different UX approaches');
260
+ console.log(' 2. (Optional) Build throwaway prototypes');
261
+ console.log(' 3. Choose the winning approach');
262
+ console.log(' 4. Generate BDD scenarios for the happy path');
263
+ console.log(' 5. Transition to implementation (speed mode)');
264
+ console.log('');
265
+ console.log('💬 Claude Code is ready to guide you through feature discovery.');
266
+ console.log('');
267
+ console.log('📋 The feature-planning skill will automatically activate.');
268
+ console.log('');
269
+ }
270
+
271
+ // Auto-trigger mode skills for chores in speed or stable mode
272
+ if (workItem && workItem.type === 'chore' && workItem.parent_id) {
273
+ // Validate parent feature exists
274
+ if (!workItem.parent_title) {
275
+ console.log('');
276
+ console.log('⚠️ Warning: Parent feature not found');
277
+ console.log('');
278
+ console.log(`This chore references parent feature #${workItem.parent_id}, but that feature`);
279
+ console.log('does not exist in the database.');
280
+ console.log('');
281
+ console.log('Suggestion: Check the parent_id or create the missing feature.');
282
+ console.log('');
283
+ }
284
+ // Validate scenario file exists
285
+ else if (!workItem.parent_scenario_file) {
286
+ console.log('');
287
+ console.log('⚠️ Warning: Parent feature has no scenario file');
288
+ console.log('');
289
+ console.log(`Parent feature #${workItem.parent_id} "${workItem.parent_title}" does not have`);
290
+ console.log('a scenario file set.');
291
+ console.log('');
292
+ console.log('Suggestion: Create a BDD scenario file for the feature and update scenario_file.');
293
+ console.log('');
294
+ }
295
+ else if (!fs.existsSync(path.join(process.cwd(), workItem.parent_scenario_file))) {
296
+ console.log('');
297
+ console.log('⚠️ Warning: Scenario file not found');
298
+ console.log('');
299
+ console.log(`Parent feature references scenario file: ${workItem.parent_scenario_file}`);
300
+ console.log('but the file does not exist on disk.');
301
+ console.log('');
302
+ console.log('Suggestion: Create the scenario file or update the feature.scenario_file path.');
303
+ console.log('');
304
+ }
305
+ else if (workItem.mode === 'speed') {
306
+ console.log('');
307
+ console.log('🚀 Speed Mode Skill Activated');
308
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
309
+ console.log('');
310
+ console.log('Claude Code will autonomously:');
311
+ console.log(' 1. Analyze the BDD scenario for this feature');
312
+ console.log(' 2. Analyze the codebase to understand patterns');
313
+ console.log(' 3. Propose an implementation approach');
314
+ console.log(' 4. Implement until the happy path scenario passes');
315
+ console.log(' 5. Generate stable mode chores for comprehensive testing');
316
+ console.log('');
317
+ console.log('💬 The speed-mode skill is now active.');
318
+ console.log(' Claude Code will guide you through implementation.');
319
+ console.log('');
320
+ } else if (workItem.mode === 'stable') {
321
+ console.log('');
322
+ console.log('🧪 Stable Mode Skill Activated');
323
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
324
+ console.log('');
325
+ console.log('Claude Code will autonomously:');
326
+ console.log(' 1. Analyze the BDD scenario to implement');
327
+ console.log(' 2. Review the existing speed mode implementation');
328
+ console.log(' 3. Propose comprehensive error handling approach');
329
+ console.log(' 4. Implement with proper validation and edge case coverage');
330
+ console.log(' 5. Ensure all BDD scenarios pass');
331
+ console.log('');
332
+ console.log('💬 The stable-mode skill is now active.');
333
+ console.log(' Claude Code will guide you through comprehensive testing.');
334
+ console.log('');
335
+ }
336
+ }
337
+
338
+ // Return work item, current work, and branch name (from worktree if created)
339
+ const branchName = worktreeResult && worktreeResult.worktree
340
+ ? worktreeResult.worktree.branch_name
341
+ : null;
342
+ resolve({ workItem, currentWork, branchName });
343
+ };
344
+
345
+ // Update status to in_progress if currently todo
346
+ if (workItem.status === 'todo') {
347
+ db.run(`UPDATE work_items SET status = 'in_progress' WHERE id = ?`, [id], (err) => {
348
+ if (err) {
349
+ return reject(new Error(`Failed to update status: ${err.message}`));
350
+ }
351
+
352
+ // If this is a chore, also set parent feature to in_progress
353
+ if (workItem.type === 'chore' && workItem.parent_id) {
354
+ db.run(
355
+ `UPDATE work_items SET status = 'in_progress' WHERE id = ? AND status IN ('backlog', 'todo')`,
356
+ [workItem.parent_id],
357
+ (err) => {
358
+ // Non-fatal - continue even if parent update fails
359
+ if (!err) {
360
+ // Check if we actually updated (status was backlog/todo)
361
+ db.get('SELECT status FROM work_items WHERE id = ?', [workItem.parent_id], (err, parent) => {
362
+ if (!err && parent && parent.status === 'in_progress') {
363
+ console.log(`✓ Feature #${workItem.parent_id} marked in_progress`);
364
+ }
365
+ });
366
+ }
367
+ updateAndContinue();
368
+ }
369
+ );
370
+ } else {
371
+ updateAndContinue();
372
+ }
373
+ });
374
+ } else {
375
+ // Even if chore is already in_progress, ensure parent feature is too
376
+ if (workItem.type === 'chore' && workItem.parent_id) {
377
+ db.run(
378
+ `UPDATE work_items SET status = 'in_progress' WHERE id = ? AND status IN ('backlog', 'todo')`,
379
+ [workItem.parent_id],
380
+ () => updateAndContinue()
381
+ );
382
+ } else {
383
+ updateAndContinue();
384
+ }
385
+ }
386
+ });
387
+ });
388
+ }
389
+
390
+ /**
391
+ * Stop work on current work item
392
+ * @param {string|null} newStatus - Optional status to set (e.g., 'done', 'blocked')
393
+ * @returns {Promise<Object|null>} Work item ID and status, or null if no active work
394
+ * @throws {Error} If status is invalid, database missing, or file operations fail
395
+ */
396
+ function stopWork(newStatus = null) {
397
+ const paths = getPaths();
398
+
399
+ const currentWork = getCurrentWork();
400
+ if (!currentWork) {
401
+ console.log('No active work item');
402
+ return Promise.resolve(null);
403
+ }
404
+
405
+ // Validate status if provided
406
+ if (newStatus && !VALID_STATUSES.includes(newStatus)) {
407
+ return Promise.reject(new Error(`Invalid status: ${newStatus}`));
408
+ }
409
+
410
+ return new Promise((resolve, reject) => {
411
+ if (newStatus) {
412
+ if (!fs.existsSync(paths.dbPath)) {
413
+ return reject(new Error('Work database not found'));
414
+ }
415
+
416
+ const db = getDb();
417
+
418
+ // Validate mode transition before marking as done
419
+ if (newStatus === 'done') {
420
+ validateModeTransition(db, currentWork, async (validationErr) => {
421
+ if (validationErr) {
422
+ db.close();
423
+ return reject(validationErr);
424
+ }
425
+
426
+ // Check if work item has an active worktree
427
+ const worktree = await worktreeManager.getWorktreeForWorkItem(currentWork.id, { db });
428
+ const cwd = process.cwd();
429
+ const inWorktree = cwd.includes('.jettypod-work');
430
+
431
+ if (worktree && worktree.status === 'active' && !inWorktree) {
432
+ // Work item has active worktree, but we're in main
433
+ // Run merge workflow with branch from database (no chdir needed)
434
+ db.close();
435
+ console.log(`⚠️ Work item #${currentWork.id} has an active worktree`);
436
+ console.log(` Running merge workflow for branch: ${worktree.branch_name}\n`);
437
+
438
+ try {
439
+ // Pass branch name directly - no need to chdir into worktree
440
+ const mergeResult = await mergeWork({ featureBranch: worktree.branch_name });
441
+ return resolve(mergeResult);
442
+ } catch (mergeErr) {
443
+ return reject(new Error(`Merge failed: ${mergeErr.message}`));
444
+ }
445
+ }
446
+
447
+ // Validation passed, proceed with status update
448
+ updateWorkItemStatus();
449
+ });
450
+ } else {
451
+ // Not marking as done, skip validation
452
+ updateWorkItemStatus();
453
+ }
454
+
455
+ function updateWorkItemStatus() {
456
+ db.run(`UPDATE work_items SET status = ? WHERE id = ?`, [newStatus, currentWork.id], async (err) => {
457
+ if (err) {
458
+ return reject(new Error(`Database error: ${err.message}`));
459
+ }
460
+
461
+ // No need to clear worktree session - status-based approach handles this automatically
462
+
463
+ // Get work item and worktree details from worktrees table
464
+ db.get(`SELECT * FROM work_items WHERE id = ?`, [currentWork.id], async (getErr, workItem) => {
465
+ if (getErr) {
466
+ return reject(new Error(`Failed to get work item: ${getErr.message}`));
467
+ }
468
+
469
+ // Check if there's an active worktree for this work item
470
+ const worktree = await worktreeManager.getWorktreeForWorkItem(currentWork.id, { db });
471
+
472
+ // If status is 'done' and there's a worktree, check for conflicts before merging
473
+ if (newStatus === 'done' && worktree && worktree.status === 'active') {
474
+ try {
475
+ const { isGitRepo } = require('../../lib/git');
476
+
477
+ if (isGitRepo()) {
478
+ // Get current branch from worktree
479
+ const worktreeBranch = execSync('git rev-parse --abbrev-ref HEAD', {
480
+ cwd: worktree.worktree_path,
481
+ encoding: 'utf8'
482
+ }).trim();
483
+
484
+ // Test merge to detect conflicts (without actually merging)
485
+ try {
486
+ const mergeResult = execSync(`git merge --no-commit --no-ff ${worktreeBranch}`, {
487
+ cwd: process.cwd(),
488
+ encoding: 'utf8',
489
+ stdio: 'pipe'
490
+ });
491
+
492
+ // No conflicts - complete the merge
493
+ try {
494
+ execSync(`git commit -m "Merge work item #${currentWork.id}: ${workItem.title}"`, {
495
+ cwd: process.cwd(),
496
+ stdio: 'pipe'
497
+ });
498
+ console.log(`✓ Merged branch "${worktreeBranch}" to main`);
499
+
500
+ // Cleanup: Use bulletproof worktree-facade for cleanup
501
+ try {
502
+ const cleanupResult = await worktreeFacade.stopWork(worktree.id, {
503
+ repoPath: getGitRoot(),
504
+ db: db,
505
+ deleteBranch: true // Delete branch after successful merge
506
+ });
507
+
508
+ if (cleanupResult.success) {
509
+ console.log(`✓ Removed worktree: ${worktree.worktree_path}`);
510
+ console.log(`✓ Deleted branch: ${worktreeBranch}`);
511
+ } else {
512
+ // CRITICAL: Cleanup failures must be fatal - do not swallow errors
513
+ // Orphaned worktrees prevent recreation and cause confusion
514
+ const userMsg = cleanupResult.error?.userMessage || cleanupResult.error?.message;
515
+ console.error(`\n❌ Failed to cleanup worktree: ${userMsg || 'Unknown error'}`);
516
+ console.error('This may leave orphaned worktree files.');
517
+ console.error('Please manually cleanup before continuing.\n');
518
+ process.exit(1);
519
+ }
520
+ } catch (cleanupErr) {
521
+ // CRITICAL: Cleanup failures must be fatal - do not swallow errors
522
+ console.error(`\n❌ Failed to cleanup worktree: ${cleanupErr.message}`);
523
+ console.error('This may leave orphaned worktree files.');
524
+ console.error('Please manually cleanup before continuing.\n');
525
+ process.exit(1);
526
+ }
527
+ } catch (commitErr) {
528
+ // Failed to commit - abort the merge
529
+ console.warn(`Warning: Failed to commit merge: ${commitErr.message}`);
530
+ try {
531
+ execSync('git merge --abort', { cwd: process.cwd(), stdio: 'pipe' });
532
+ } catch (abortErr) {
533
+ // Ignore abort errors
534
+ }
535
+ }
536
+ } catch (mergeErr) {
537
+ // Merge failed - could be conflicts or other error
538
+ const errorOutput = mergeErr.stdout || mergeErr.stderr || mergeErr.message || '';
539
+ const hasConflict = errorOutput.includes('CONFLICT') || errorOutput.includes('conflict');
540
+
541
+ // Check if there are unmerged files (another indicator of conflicts)
542
+ let hasUnmergedFiles = false;
543
+ try {
544
+ const statusOutput = execSync('git status', {
545
+ cwd: process.cwd(),
546
+ encoding: 'utf8',
547
+ stdio: 'pipe'
548
+ });
549
+ hasUnmergedFiles = statusOutput.includes('Unmerged paths') || statusOutput.includes('both modified');
550
+ } catch (statusErr) {
551
+ // Ignore status check errors
552
+ }
553
+
554
+ if (hasConflict || hasUnmergedFiles) {
555
+ // Abort the failed merge attempt
556
+ try {
557
+ execSync('git merge --abort', { cwd: process.cwd(), stdio: 'pipe' });
558
+ } catch (abortErr) {
559
+ // Ignore abort errors
560
+ }
561
+
562
+ // Get list of conflicting files
563
+ let conflictingFiles = [];
564
+ try {
565
+ const diffOutput = execSync('git diff --name-only --diff-filter=U', {
566
+ cwd: testMergeDir,
567
+ encoding: 'utf8'
568
+ });
569
+ conflictingFiles = diffOutput.trim().split('\n').filter(f => f);
570
+ } catch (diffErr) {
571
+ // Ignore errors getting file list
572
+ }
573
+
574
+ // Don't clear current work - keep worktree for conflict resolution
575
+ console.log(`\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
576
+ console.log(`⚠️ MERGE CONFLICT DETECTED`);
577
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`);
578
+ console.log();
579
+ console.log(`Cannot auto-merge worktree branch "${worktreeBranch}" to main.`);
580
+
581
+ if (conflictingFiles.length > 0) {
582
+ console.log();
583
+ console.log(`Conflicting files:`);
584
+ conflictingFiles.forEach(file => console.log(` - ${file}`));
585
+ }
586
+
587
+ console.log();
588
+ console.log(`Worktree preserved at: ${worktree.worktree_path}`);
589
+ console.log();
590
+ console.log(`To resolve conflicts:`);
591
+ console.log();
592
+ console.log(` 1. Switch to main and pull latest changes:`);
593
+ console.log(` cd ${process.cwd()}`);
594
+ console.log(` git pull origin main`);
595
+ console.log();
596
+ console.log(` 2. In your worktree, merge the updated main:`);
597
+ console.log(` cd ${worktree.worktree_path}`);
598
+ console.log(` git merge main`);
599
+ console.log();
600
+ console.log(` 3. Resolve conflicts in each file, then:`);
601
+ console.log(` git add <resolved-files>`);
602
+ console.log(` git commit`);
603
+ console.log();
604
+ console.log(` 4. Return to main and retry the merge:`);
605
+ console.log(` cd ${process.cwd()}`);
606
+ console.log(` git merge ${worktreeBranch}`);
607
+ console.log();
608
+ console.log(` 5. Clean up worktree after successful merge:`);
609
+ console.log(` git worktree remove "${worktree.worktree_path}"`);
610
+ console.log(` git branch -d ${worktreeBranch}`);
611
+ console.log();
612
+ console.log(`Alternative: If conflicts are complex, consider rebasing:`);
613
+ console.log(` cd ${worktree.worktree_path}`);
614
+ console.log(` git rebase main`);
615
+ console.log(` # Resolve conflicts as prompted`);
616
+ console.log(` git rebase --continue`);
617
+ console.log();
618
+ console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n`);
619
+
620
+ return resolve({ id: currentWork.id, status: newStatus, conflicts: true, conflictingFiles });
621
+ }
622
+
623
+ // Some other merge error - abort and continue
624
+ try {
625
+ execSync('git merge --abort', { cwd: process.cwd(), stdio: 'pipe' });
626
+ } catch (abortErr) {
627
+ // Ignore abort errors
628
+ }
629
+
630
+ // Log the error but don't fail the stop command
631
+ console.warn(`Warning: Merge test failed: ${errorOutput}`);
632
+ }
633
+ }
634
+ } catch (conflictCheckErr) {
635
+ console.warn(`Warning: Failed to check for conflicts: ${conflictCheckErr.message}`);
636
+ }
637
+ }
638
+
639
+ // For non-done statuses, clean up worktree if one exists (without merging)
640
+ if (newStatus !== 'done' && worktree && worktree.status === 'active') {
641
+ try {
642
+ // Clean up worktree without merging (deleteBranch=false to preserve work)
643
+ const cleanupResult = await worktreeFacade.stopWork(worktree.id, {
644
+ repoPath: getGitRoot(),
645
+ db: db,
646
+ deleteBranch: false // Keep branch for cancelled/blocked work
647
+ });
648
+
649
+ if (cleanupResult.success) {
650
+ console.log(`✓ Cleaned up worktree: ${worktree.worktree_path}`);
651
+ console.log(` Branch preserved: ${worktree.branch_name}`);
652
+ }
653
+ } catch (cleanupErr) {
654
+ console.warn(`Warning: Failed to cleanup worktree: ${cleanupErr.message}`);
655
+ }
656
+ }
657
+
658
+ // Check if this completes all stable mode chores for the parent feature
659
+ checkStableModeCompletion(db, currentWork, newStatus, (completionErr) => {
660
+ if (completionErr) {
661
+ console.warn(`Warning: ${completionErr.message}`);
662
+ }
663
+
664
+ try {
665
+ clearCurrentWork();
666
+ } catch (unlinkErr) {
667
+ return reject(new Error(`Failed to remove current work file: ${unlinkErr.message}`));
668
+ }
669
+
670
+ console.log(`Stopped work on #${currentWork.id}, status set to ${newStatus}`);
671
+ resolve({ id: currentWork.id, status: newStatus });
672
+ });
673
+ });
674
+ });
675
+ }
676
+ } else {
677
+ try {
678
+ clearCurrentWork();
679
+ } catch (err) {
680
+ return reject(new Error(`Failed to remove current work file: ${err.message}`));
681
+ }
682
+
683
+ console.log(`Stopped work on #${currentWork.id}`);
684
+ resolve({ id: currentWork.id });
685
+ }
686
+ });
687
+ }
688
+
689
+ /**
690
+ * Validate mode transition before marking work item as done
691
+ * @param {Object} db - Database connection
692
+ * @param {Object} currentWork - Current work item being completed
693
+ * @param {Function} callback - Callback function (err) => void
694
+ */
695
+ function validateModeTransition(db, currentWork, callback) {
696
+ // Input validation - ensure we have valid parameters
697
+ if (!db || typeof db.get !== 'function') {
698
+ return callback(new Error('Invalid database connection provided to validateModeTransition'));
699
+ }
700
+
701
+ if (!currentWork || typeof currentWork !== 'object') {
702
+ return callback(new Error('Invalid currentWork object provided to validateModeTransition'));
703
+ }
704
+
705
+ if (!callback || typeof callback !== 'function') {
706
+ throw new Error('Callback function is required for validateModeTransition');
707
+ }
708
+
709
+ // Only validate features (chores inherit mode from parent)
710
+ if (currentWork.type !== 'feature') {
711
+ return callback(null);
712
+ }
713
+
714
+ // Validate currentWork.id exists
715
+ if (!currentWork.id) {
716
+ return callback(new Error('Current work item has no ID'));
717
+ }
718
+
719
+ // Get feature details including mode
720
+ db.get(`
721
+ SELECT id, title, mode, type
722
+ FROM work_items
723
+ WHERE id = ?
724
+ `, [currentWork.id], (err, feature) => {
725
+ if (err) {
726
+ return callback(new Error(`Failed to get feature details: ${err.message}`));
727
+ }
728
+
729
+ if (!feature) {
730
+ return callback(new Error(`Feature #${currentWork.id} not found`));
731
+ }
732
+
733
+ // Handle null or undefined mode - allow completion if no mode set
734
+ if (!feature.mode) {
735
+ return callback(null);
736
+ }
737
+
738
+ // Check if feature is in speed mode
739
+ if (feature.mode === 'speed') {
740
+ // Check if stable mode chores exist
741
+ db.get(`
742
+ SELECT COUNT(*) as stable_chore_count
743
+ FROM work_items
744
+ WHERE parent_id = ?
745
+ AND type = 'chore'
746
+ AND mode = 'stable'
747
+ `, [feature.id], (countErr, result) => {
748
+ if (countErr) {
749
+ return callback(new Error(`Failed to check stable chores: ${countErr.message}`));
750
+ }
751
+
752
+ // Validate result object
753
+ if (!result || typeof result.stable_chore_count !== 'number') {
754
+ return callback(new Error('Invalid result from stable chore count query'));
755
+ }
756
+
757
+ // If no stable chores exist, block completion
758
+ if (result.stable_chore_count === 0) {
759
+ const error = new Error(
760
+ `❌ Cannot mark speed mode feature as complete\n\n` +
761
+ `Speed mode features must transition to stable mode before completion.\n` +
762
+ `The speed mode implementation proves the happy path works, but the feature\n` +
763
+ `is incomplete without error handling, validation, and edge case coverage.\n\n` +
764
+ `Next steps:\n` +
765
+ ` 1. Generate stable mode chores: jettypod work elevate ${feature.id} stable\n` +
766
+ ` 2. Implement stable mode chores to add error handling\n` +
767
+ ` 3. Then mark feature as complete\n\n` +
768
+ `To skip stable mode (not recommended):\n` +
769
+ ` jettypod work set-mode ${feature.id} stable --force\n` +
770
+ ` jettypod work complete ${feature.id}`
771
+ );
772
+ return callback(error);
773
+ }
774
+
775
+ // Stable chores exist, allow completion
776
+ callback(null);
777
+ });
778
+ } else if (feature.mode === 'stable') {
779
+ // Check if product is external - wrap in try-catch for config errors
780
+ let projectConfig;
781
+ let isExternal = false;
782
+
783
+ try {
784
+ projectConfig = config.read();
785
+ isExternal = projectConfig && projectConfig.project_state === 'external';
786
+ } catch (configErr) {
787
+ // If config can't be read, fail safe and don't block completion
788
+ console.warn(`Warning: Could not read project config: ${configErr.message}`);
789
+ console.warn('Assuming internal project - allowing completion without production mode');
790
+ return callback(null);
791
+ }
792
+
793
+ if (!isExternal) {
794
+ // Internal product, no production mode required
795
+ return callback(null);
796
+ }
797
+
798
+ // External product - check if production mode chores exist
799
+ db.get(`
800
+ SELECT COUNT(*) as production_chore_count
801
+ FROM work_items
802
+ WHERE parent_id = ?
803
+ AND type = 'chore'
804
+ AND mode = 'production'
805
+ `, [feature.id], (countErr, result) => {
806
+ if (countErr) {
807
+ return callback(new Error(`Failed to check production chores: ${countErr.message}`));
808
+ }
809
+
810
+ // Validate result object
811
+ if (!result || typeof result.production_chore_count !== 'number') {
812
+ return callback(new Error('Invalid result from production chore count query'));
813
+ }
814
+
815
+ // If no production chores exist, block completion
816
+ if (result.production_chore_count === 0) {
817
+ const error = new Error(
818
+ `❌ Cannot mark stable mode feature as complete for external product\n\n` +
819
+ `External products require production mode hardening before completion.\n` +
820
+ `Stable mode adds error handling, but production mode ensures the feature\n` +
821
+ `can handle real-world scale, security threats, and operational requirements.\n\n` +
822
+ `Next steps:\n` +
823
+ ` 1. Generate production mode chores: jettypod work elevate ${feature.id} production\n` +
824
+ ` 2. Implement production hardening (performance, security, monitoring)\n` +
825
+ ` 3. Then mark feature as complete\n\n` +
826
+ `To skip production mode (not recommended for external products):\n` +
827
+ ` jettypod work set-mode ${feature.id} production --force\n` +
828
+ ` jettypod work complete ${feature.id}`
829
+ );
830
+ return callback(error);
831
+ }
832
+
833
+ // Production chores exist, allow completion
834
+ callback(null);
835
+ });
836
+ } else {
837
+ // Not in speed or stable mode, no validation needed
838
+ callback(null);
839
+ }
840
+ });
841
+ }
842
+
843
+ /**
844
+ * Check if stable mode is complete for a feature and trigger production chore generation
845
+ * @param {Object} db - Database connection
846
+ * @param {Object} currentWork - Current work item that was just completed
847
+ * @param {string} newStatus - Status that was just set
848
+ * @param {Function} callback - Callback function
849
+ */
850
+ async function checkStableModeCompletion(db, currentWork, newStatus, callback) {
851
+ // Only check if:
852
+ // 1. Current work is a chore
853
+ // 2. Status was just set to 'done'
854
+ // 3. Current work has a parent (feature)
855
+ if (currentWork.type !== 'chore' || newStatus !== 'done' || !currentWork.parent_id) {
856
+ return callback(null);
857
+ }
858
+
859
+ // Get parent feature details and check its mode
860
+ db.get(`
861
+ SELECT id, title, mode, type
862
+ FROM work_items
863
+ WHERE id = ?
864
+ `, [currentWork.parent_id], async (err, parent) => {
865
+ if (err) {
866
+ return callback(new Error(`Failed to get parent feature: ${err.message}`));
867
+ }
868
+
869
+ if (!parent) {
870
+ return callback(new Error(`Parent feature #${currentWork.parent_id} not found`));
871
+ }
872
+
873
+ // Only proceed if parent is a feature in stable mode
874
+ if (parent.type !== 'feature' || parent.mode !== 'stable') {
875
+ return callback(null);
876
+ }
877
+
878
+ // Check if all stable chores for this feature are done
879
+ db.get(`
880
+ SELECT COUNT(*) as incomplete_count
881
+ FROM work_items
882
+ WHERE parent_id = ?
883
+ AND type = 'chore'
884
+ AND mode = 'stable'
885
+ AND status != 'done'
886
+ `, [parent.id], async (countErr, result) => {
887
+ if (countErr) {
888
+ return callback(new Error(`Failed to check stable chore completion: ${countErr.message}`));
889
+ }
890
+
891
+ // If all stable chores are done, stable mode is complete
892
+ if (result.incomplete_count === 0) {
893
+ console.log('');
894
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
895
+ console.log('✅ STABLE MODE COMPLETE');
896
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
897
+ console.log('');
898
+ console.log(`All stable chores for feature #${parent.id} "${parent.title}" are done.`);
899
+ console.log('');
900
+
901
+ // NOTE: Production mode transition is handled by the stable-mode skill
902
+ // The skill will:
903
+ // 1. Ask user if they want to add production scenarios
904
+ // 2. Add production scenarios to the feature file (BDD hot context)
905
+ // 3. Create production chores FROM those scenarios
906
+ // 4. Elevate feature to production mode
907
+ //
908
+ // Production chores are NOT auto-generated from git analysis.
909
+ // They come from BDD scenarios in the feature file.
910
+ }
911
+
912
+ callback(null);
913
+ });
914
+ });
915
+ }
916
+
917
+ /**
918
+ * Generate production chores and get user confirmation
919
+ * @param {Object} feature - Feature object with id and title
920
+ */
921
+ async function generateAndConfirmProductionChores(feature) {
922
+ const { analyzeImplementation, generateProductionChores } = require('../../lib/production-chore-generator');
923
+ const readline = require('readline');
924
+
925
+ console.log('🔍 Analyzing implementation for production gaps...');
926
+ console.log('');
927
+
928
+ // Analyze implementation
929
+ let analysisResult;
930
+ try {
931
+ analysisResult = await analyzeImplementation(feature.id);
932
+ } catch (analysisErr) {
933
+ console.error(`Failed to analyze implementation: ${analysisErr.message}`);
934
+ return;
935
+ }
936
+
937
+ // Display warning if no git commits found
938
+ if (analysisResult.warning) {
939
+ console.log(`⚠️ ${analysisResult.warning}`);
940
+ console.log('');
941
+ }
942
+
943
+ console.log(`✅ Analyzed ${analysisResult.filesAnalyzed.length} implementation files`);
944
+ console.log('');
945
+
946
+ // Generate production chore proposals
947
+ const proposedChores = generateProductionChores(analysisResult, feature.title);
948
+
949
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
950
+ console.log('📋 PROPOSED PRODUCTION CHORES');
951
+ console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
952
+ console.log('');
953
+ console.log('These production chores will be created:');
954
+ console.log('');
955
+
956
+ proposedChores.forEach((chore, index) => {
957
+ console.log(`${index + 1}. ${chore.title}`);
958
+ console.log(` ${chore.description.split('\n')[0]}`);
959
+ console.log('');
960
+ });
961
+
962
+ // Get user confirmation
963
+ return new Promise((resolve) => {
964
+ const rl = readline.createInterface({
965
+ input: process.stdin,
966
+ output: process.stdout
967
+ });
968
+
969
+ rl.question('Create these production chores? (yes/skip): ', async (answer) => {
970
+ rl.close();
971
+
972
+ const response = answer.trim().toLowerCase();
973
+
974
+ if (response === 'yes' || response === 'y') {
975
+ console.log('');
976
+ console.log('Creating production chores...');
977
+
978
+ try {
979
+ await createProductionChoresAndElevate(feature, proposedChores);
980
+ console.log('');
981
+ console.log(`✅ Created ${proposedChores.length} production chores. Feature elevated to production mode.`);
982
+ console.log('');
983
+ } catch (createErr) {
984
+ console.error(`Failed to create production chores: ${createErr.message}`);
985
+ }
986
+ } else {
987
+ console.log('');
988
+ console.log('⏭️ Skipped production chore creation');
989
+ console.log(' You can generate them later by talking to Claude Code');
990
+ console.log('');
991
+ }
992
+
993
+ resolve();
994
+ });
995
+ });
996
+ }
997
+
998
+ /**
999
+ * Create production chores and elevate feature to production mode (if external)
1000
+ * @param {Object} feature - Feature object
1001
+ * @param {Array} proposedChores - Array of chore proposals
1002
+ */
1003
+ async function createProductionChoresAndElevate(feature, proposedChores) {
1004
+ const { create } = require('../work-tracking');
1005
+ const projectConfig = config.read();
1006
+
1007
+ // Create production chores
1008
+ for (const chore of proposedChores) {
1009
+ await create('chore', chore.title, chore.description, feature.id, null, false);
1010
+ }
1011
+
1012
+ // Always elevate feature to production mode
1013
+ // (Feature #611 controls visibility in backlog, not mode elevation)
1014
+ const db = getDb();
1015
+ await new Promise((resolve, reject) => {
1016
+ db.run('UPDATE work_items SET mode = ? WHERE id = ?', ['production', feature.id], (err) => {
1017
+ if (err) return reject(err);
1018
+ resolve();
1019
+ });
1020
+ });
1021
+ }
1022
+
1023
+ /**
1024
+ * Clean up orphaned worktrees for completed work items
1025
+ * @param {Object} options - Cleanup options
1026
+ * @param {boolean} options.dryRun - If true, only show what would be cleaned up
1027
+ * @returns {Promise<Object>} Cleanup results
1028
+ */
1029
+ async function cleanupWorktrees(options = {}) {
1030
+ const { dryRun = false } = options;
1031
+ const db = getDb();
1032
+
1033
+ // Get all active worktrees with their work item statuses
1034
+ const query = `
1035
+ SELECT w.id as worktree_id, w.work_item_id, w.worktree_path, w.branch_name,
1036
+ wi.status as work_status, wi.title
1037
+ FROM worktrees w
1038
+ JOIN work_items wi ON w.work_item_id = wi.id
1039
+ WHERE w.status = 'active'
1040
+ AND wi.status IN ('done', 'cancelled')
1041
+ `;
1042
+
1043
+ const orphanedWorktrees = await new Promise((resolve, reject) => {
1044
+ db.all(query, (err, rows) => {
1045
+ if (err) reject(err);
1046
+ else resolve(rows || []);
1047
+ });
1048
+ });
1049
+
1050
+ if (orphanedWorktrees.length === 0) {
1051
+ return {
1052
+ success: true,
1053
+ cleaned: 0,
1054
+ message: 'No orphaned worktrees found'
1055
+ };
1056
+ }
1057
+
1058
+ console.log(`\nFound ${orphanedWorktrees.length} orphaned worktrees:\n`);
1059
+
1060
+ const results = {
1061
+ success: true,
1062
+ cleaned: 0,
1063
+ failed: 0,
1064
+ errors: []
1065
+ };
1066
+
1067
+ for (const wt of orphanedWorktrees) {
1068
+ console.log(` #${wt.work_item_id} (${wt.work_status}): ${wt.title}`);
1069
+ console.log(` Path: ${wt.worktree_path}`);
1070
+ console.log(` Branch: ${wt.branch_name}`);
1071
+
1072
+ if (dryRun) {
1073
+ console.log(` [DRY RUN] Would clean up`);
1074
+ continue;
1075
+ }
1076
+
1077
+ try {
1078
+ const cleanupResult = await worktreeFacade.stopWork(wt.worktree_id, {
1079
+ repoPath: getGitRoot(),
1080
+ db: db,
1081
+ deleteBranch: wt.work_status === 'done' // Delete branch only for done items
1082
+ });
1083
+
1084
+ if (cleanupResult.success) {
1085
+ console.log(` ✓ Cleaned up\n`);
1086
+ results.cleaned++;
1087
+ } else {
1088
+ console.log(` ⚠ Cleanup had warnings\n`);
1089
+ results.cleaned++;
1090
+ }
1091
+ } catch (err) {
1092
+ console.log(` ✗ Failed: ${err.message}\n`);
1093
+ results.failed++;
1094
+ results.errors.push({ workItemId: wt.work_item_id, error: err.message });
1095
+ }
1096
+ }
1097
+
1098
+ return results;
1099
+ }
1100
+
1101
+ // Re-export getCurrentWork from shared module for backwards compatibility
1102
+ // (used by jettypod.js)
1103
+
1104
+ /**
1105
+ * Merge current work into main branch
1106
+ * Pushes feature branch, checks out main, merges, and pushes main
1107
+ * Post-merge hook will mark work item as done and cleanup worktree
1108
+ * @param {Object} options - Merge options
1109
+ * @param {boolean} options.withTransition - Hold lock for transition phase (BDD generation)
1110
+ * @param {boolean} options.releaseLock - Release held lock only (no merge)
1111
+ * @returns {Promise<void|Object>} Returns {lockHeld: true} if withTransition, void otherwise
1112
+ * @throws {Error} If no current work, git operations fail, or not in git repo
1113
+ */
1114
+ async function mergeWork(options = {}) {
1115
+ const { withTransition = false, releaseLock = false, featureBranch = null, workItemId = null } = options;
1116
+
1117
+ // Handle lock release-only mode first (can run from anywhere)
1118
+ if (releaseLock) {
1119
+ const mergeLock = require('../../lib/merge-lock');
1120
+ const db = getDb();
1121
+
1122
+ console.log('⏳ Releasing merge lock...');
1123
+ try {
1124
+ await new Promise((resolve, reject) => {
1125
+ db.run(
1126
+ `DELETE FROM merge_locks`,
1127
+ [],
1128
+ (err) => {
1129
+ if (err) return reject(err);
1130
+ resolve();
1131
+ }
1132
+ );
1133
+ });
1134
+
1135
+ console.log('✅ Merge lock released');
1136
+ return Promise.resolve();
1137
+ } catch (err) {
1138
+ return Promise.reject(new Error(`Failed to release merge lock: ${err.message}`));
1139
+ }
1140
+ }
1141
+
1142
+ // CRITICAL: Refuse to run from inside a worktree
1143
+ // Running merge from inside the worktree that will be deleted poisons the shell session
1144
+ const cwd = process.cwd();
1145
+ if (cwd.includes('.jettypod-work')) {
1146
+ const mainRepo = getGitRoot();
1147
+ console.error('❌ Cannot merge from inside a worktree.');
1148
+ console.error('');
1149
+ console.error(' The merge will delete this worktree, breaking your shell session.');
1150
+ console.error('');
1151
+ console.error(' Run from the main repository instead:');
1152
+ console.error(` cd ${mainRepo}`);
1153
+ console.error(' jettypod work merge');
1154
+ return Promise.reject(new Error('Cannot merge from inside a worktree'));
1155
+ }
1156
+
1157
+ // Get current work - either from explicit ID or branch detection
1158
+ let currentWork;
1159
+ if (workItemId) {
1160
+ // Explicit work item ID provided - look it up
1161
+ const db = getDb();
1162
+ currentWork = await new Promise((resolve, reject) => {
1163
+ db.get(
1164
+ `SELECT wi.id, wi.title, wi.type, wi.status, wi.parent_id, wi.epic_id
1165
+ FROM work_items wi WHERE wi.id = ?`,
1166
+ [workItemId],
1167
+ (err, row) => {
1168
+ if (err) return reject(err);
1169
+ resolve(row);
1170
+ }
1171
+ );
1172
+ });
1173
+ if (!currentWork) {
1174
+ return Promise.reject(new Error(`Work item #${workItemId} not found.`));
1175
+ }
1176
+ } else {
1177
+ // No explicit ID - try branch detection
1178
+ currentWork = await getCurrentWork();
1179
+ if (!currentWork) {
1180
+ return Promise.reject(new Error(
1181
+ 'No current work detected.\n\n' +
1182
+ 'Either:\n' +
1183
+ ' 1. Run from inside a worktree, or\n' +
1184
+ ' 2. Specify work item ID: jettypod work merge <id>'
1185
+ ));
1186
+ }
1187
+ }
1188
+
1189
+ // Ensure we're in a git repo
1190
+ let gitRoot;
1191
+ try {
1192
+ gitRoot = getGitRoot();
1193
+ } catch (err) {
1194
+ return Promise.reject(new Error('Not in a git repository'));
1195
+ }
1196
+
1197
+ // Acquire merge lock to coordinate multiple instances
1198
+ const mergeLock = require('../../lib/merge-lock');
1199
+ const db = getDb();
1200
+
1201
+ console.log('⏳ Acquiring merge lock...');
1202
+ let lock;
1203
+ try {
1204
+ lock = await mergeLock.acquireMergeLock(db, currentWork.id, null, {
1205
+ maxWait: 120000, // 2 minutes
1206
+ pollInterval: 2000 // Check every 2 seconds
1207
+ });
1208
+ console.log('✅ Merge lock acquired');
1209
+ } catch (lockErr) {
1210
+ if (lockErr.message.includes('timeout')) {
1211
+ return Promise.reject(new Error(
1212
+ 'Merge lock timeout: Another instance is merging.\n' +
1213
+ 'This usually resolves in 30-60 seconds. Please retry:\n' +
1214
+ ' jettypod work merge'
1215
+ ));
1216
+ }
1217
+ return Promise.reject(new Error(`Failed to acquire merge lock: ${lockErr.message}`));
1218
+ }
1219
+
1220
+ // Wrap all merge operations in try/finally to ensure lock release
1221
+ try {
1222
+ // Get feature branch - either passed explicitly, from worktree DB, or from current CWD
1223
+ let currentBranch;
1224
+ if (featureBranch) {
1225
+ // Branch passed from caller (e.g., status transition with worktree)
1226
+ currentBranch = featureBranch;
1227
+ } else if (workItemId) {
1228
+ // Explicit work item ID - look up branch from worktrees table
1229
+ const worktreeRecord = await new Promise((resolve, reject) => {
1230
+ db.get(
1231
+ `SELECT branch_name FROM worktrees WHERE work_item_id = ? AND status = 'active'`,
1232
+ [workItemId],
1233
+ (err, row) => {
1234
+ if (err) return reject(err);
1235
+ resolve(row);
1236
+ }
1237
+ );
1238
+ });
1239
+ if (!worktreeRecord) {
1240
+ return Promise.reject(new Error(
1241
+ `No active worktree found for work item #${workItemId}.\n\n` +
1242
+ `The work item must have an active worktree to merge.`
1243
+ ));
1244
+ }
1245
+ currentBranch = worktreeRecord.branch_name;
1246
+ } else {
1247
+ // Detect from current branch in CWD
1248
+ try {
1249
+ currentBranch = execSync('git branch --show-current', {
1250
+ cwd: process.cwd(),
1251
+ encoding: 'utf8',
1252
+ stdio: 'pipe'
1253
+ }).trim();
1254
+ } catch (err) {
1255
+ return Promise.reject(new Error(`Failed to get current branch: ${err.message}`));
1256
+ }
1257
+
1258
+ if (!currentBranch) {
1259
+ return Promise.reject(new Error('Not on a branch (detached HEAD?)'));
1260
+ }
1261
+
1262
+ if (currentBranch === 'main' || currentBranch === 'master') {
1263
+ return Promise.reject(new Error('Already on main branch. Cannot merge from main to main.'));
1264
+ }
1265
+ }
1266
+
1267
+ // Check for uncommitted changes
1268
+ try {
1269
+ const statusOutput = execSync('git status --porcelain', {
1270
+ cwd: gitRoot,
1271
+ encoding: 'utf8',
1272
+ stdio: 'pipe'
1273
+ }).trim();
1274
+
1275
+ if (statusOutput) {
1276
+ // Parse the status to show what's uncommitted
1277
+ const lines = statusOutput.split('\n');
1278
+ const staged = lines.filter(line => /^[MADRC]/.test(line));
1279
+ const unstaged = lines.filter(line => /^.[MD]/.test(line));
1280
+ // Ignore untracked files - they don't affect merging
1281
+
1282
+ // Only fail on staged or unstaged changes (not untracked)
1283
+ if (staged.length > 0 || unstaged.length > 0) {
1284
+ let errorDetails = `Uncommitted changes detected in:\n ${gitRoot}\n`;
1285
+ if (staged.length > 0) {
1286
+ errorDetails += `\nStaged files (${staged.length}):\n${staged.map(l => ' ' + l).join('\n')}`;
1287
+ }
1288
+ if (unstaged.length > 0) {
1289
+ errorDetails += `\n\nUnstaged changes (${unstaged.length}):\n${unstaged.map(l => ' ' + l).join('\n')}`;
1290
+ }
1291
+
1292
+ errorDetails += '\n\nTo proceed, run:\n';
1293
+ errorDetails += ` cd ${gitRoot} && git add -A && git commit -m "your message"\n\n`;
1294
+ errorDetails += 'Or stash them:\n';
1295
+ errorDetails += ` cd ${gitRoot} && git stash\n\n`;
1296
+ errorDetails += 'Then retry:\n';
1297
+ errorDetails += ' jettypod work merge';
1298
+
1299
+ return Promise.reject(new Error(errorDetails));
1300
+ }
1301
+ }
1302
+ } catch (err) {
1303
+ return Promise.reject(new Error(`Failed to check git status: ${err.message}`));
1304
+ }
1305
+
1306
+ console.log(`Merging work item #${currentWork.id}: ${currentWork.title}`);
1307
+ console.log(`Branch: ${currentBranch}`);
1308
+
1309
+ // Step 1: Push feature branch to remote
1310
+ try {
1311
+ console.log('Pushing feature branch to remote...');
1312
+ execSync(`git push -u origin ${currentBranch}`, {
1313
+ cwd: gitRoot,
1314
+ stdio: 'inherit'
1315
+ });
1316
+ } catch (err) {
1317
+ const errorMsg = err.message.toLowerCase();
1318
+ const isNetworkError = errorMsg.includes('could not resolve host') ||
1319
+ errorMsg.includes('failed to connect') ||
1320
+ errorMsg.includes('connection timed out') ||
1321
+ errorMsg.includes('network') ||
1322
+ errorMsg.includes('connection refused');
1323
+
1324
+ if (isNetworkError) {
1325
+ return Promise.reject(new Error(
1326
+ `Network error while pushing feature branch.\n\n` +
1327
+ `To resolve:\n` +
1328
+ `1. Check network connection\n` +
1329
+ `2. Retry: jettypod work merge\n` +
1330
+ `\nIf network is working, check authentication:\n` +
1331
+ `git config --get remote.origin.url`
1332
+ ));
1333
+ }
1334
+
1335
+ return Promise.reject(new Error(`Failed to push feature branch: ${err.message}`));
1336
+ }
1337
+
1338
+ // Step 2: Detect and checkout default branch
1339
+ let defaultBranch;
1340
+ try {
1341
+ // Try to get the default branch from git config
1342
+ defaultBranch = execSync('git symbolic-ref refs/remotes/origin/HEAD', {
1343
+ cwd: gitRoot,
1344
+ encoding: 'utf8',
1345
+ stdio: ['pipe', 'pipe', 'pipe']
1346
+ }).trim().replace('refs/remotes/origin/', '');
1347
+ } catch {
1348
+ // Fallback: check which common branch names exist
1349
+ try {
1350
+ execSync('git rev-parse --verify main', { cwd: gitRoot, stdio: 'pipe' });
1351
+ defaultBranch = 'main';
1352
+ } catch {
1353
+ try {
1354
+ execSync('git rev-parse --verify master', { cwd: gitRoot, stdio: 'pipe' });
1355
+ defaultBranch = 'master';
1356
+ } catch {
1357
+ return Promise.reject(new Error('Could not detect default branch (tried main, master)'));
1358
+ }
1359
+ }
1360
+ }
1361
+
1362
+ try {
1363
+ console.log(`Checking out ${defaultBranch}...`);
1364
+ execSync(`git checkout ${defaultBranch}`, {
1365
+ cwd: gitRoot,
1366
+ stdio: 'inherit'
1367
+ });
1368
+ } catch (err) {
1369
+ return Promise.reject(new Error(`Failed to checkout ${defaultBranch}: ${err.message}`));
1370
+ }
1371
+
1372
+ // Step 3: Check for unpushed commits on local main before pulling
1373
+ // This protects work being done directly on main by other instances
1374
+ try {
1375
+ execSync('git fetch origin', { cwd: gitRoot, stdio: 'pipe' });
1376
+ const unpushed = execSync(`git log origin/${defaultBranch}..${defaultBranch} --oneline`, {
1377
+ cwd: gitRoot,
1378
+ encoding: 'utf8',
1379
+ stdio: 'pipe'
1380
+ }).trim();
1381
+
1382
+ if (unpushed) {
1383
+ const commitCount = unpushed.split('\n').length;
1384
+ return Promise.reject(new Error(
1385
+ `Local ${defaultBranch} has ${commitCount} unpushed commit(s).\n\n` +
1386
+ `This could indicate another instance is working on ${defaultBranch}.\n\n` +
1387
+ `To resolve:\n` +
1388
+ `1. Push local changes: git push origin ${defaultBranch}\n` +
1389
+ `2. Or reset to remote: git reset --hard origin/${defaultBranch}\n` +
1390
+ `3. Then retry: jettypod work merge`
1391
+ ));
1392
+ }
1393
+ } catch (err) {
1394
+ if (!err.message.includes('unpushed')) {
1395
+ return Promise.reject(new Error(`Failed to check for unpushed commits: ${err.message}`));
1396
+ }
1397
+ throw err;
1398
+ }
1399
+
1400
+ // Step 4: Pull latest default branch
1401
+ try {
1402
+ console.log(`Updating ${defaultBranch} from remote...`);
1403
+ execSync(`git pull origin ${defaultBranch}`, {
1404
+ cwd: gitRoot,
1405
+ stdio: 'inherit'
1406
+ });
1407
+ } catch (err) {
1408
+ const errorMsg = err.message.toLowerCase();
1409
+ const isNetworkError = errorMsg.includes('could not resolve host') ||
1410
+ errorMsg.includes('failed to connect') ||
1411
+ errorMsg.includes('connection timed out') ||
1412
+ errorMsg.includes('network') ||
1413
+ errorMsg.includes('connection refused');
1414
+
1415
+ if (isNetworkError) {
1416
+ // We're already on main, so need to get back to feature branch
1417
+ try {
1418
+ execSync(`git checkout ${currentBranch}`, { cwd: gitRoot, stdio: 'pipe' });
1419
+ } catch (checkoutErr) {
1420
+ // Ignore checkout errors
1421
+ }
1422
+
1423
+ return Promise.reject(new Error(
1424
+ `Network error while pulling main branch.\n\n` +
1425
+ `Current state: Switched back to ${currentBranch}\n\n` +
1426
+ `To resolve:\n` +
1427
+ `1. Check network connection\n` +
1428
+ `2. Retry: jettypod work merge`
1429
+ ));
1430
+ }
1431
+
1432
+ // For non-network errors, still try to get back to feature branch
1433
+ try {
1434
+ execSync(`git checkout ${currentBranch}`, { cwd: gitRoot, stdio: 'pipe' });
1435
+ } catch (checkoutErr) {
1436
+ // Ignore checkout errors
1437
+ }
1438
+
1439
+ return Promise.reject(new Error(`Failed to pull ${defaultBranch}: ${err.message}`));
1440
+ }
1441
+
1442
+ // Step 4: Merge feature branch into default branch
1443
+ try {
1444
+ console.log(`Merging ${currentBranch} into ${defaultBranch}...`);
1445
+ execSync(`git merge --no-ff ${currentBranch} -m "Merge work item #${currentWork.id}"`, {
1446
+ cwd: gitRoot,
1447
+ stdio: 'inherit'
1448
+ });
1449
+ } catch (err) {
1450
+ // Check if merge failed due to conflicts
1451
+ let hasConflicts = false;
1452
+ try {
1453
+ const statusOutput = execSync('git status', {
1454
+ cwd: gitRoot,
1455
+ encoding: 'utf8',
1456
+ stdio: 'pipe'
1457
+ });
1458
+ hasConflicts = statusOutput.includes('Unmerged paths') ||
1459
+ statusOutput.includes('both modified') ||
1460
+ statusOutput.includes('Merge conflict');
1461
+ } catch (statusErr) {
1462
+ // Ignore status check errors
1463
+ }
1464
+
1465
+ if (hasConflicts) {
1466
+ // Abort the merge and provide helpful error message
1467
+ try {
1468
+ execSync('git merge --abort', { cwd: gitRoot, stdio: 'pipe' });
1469
+ } catch (abortErr) {
1470
+ // Ignore abort errors
1471
+ }
1472
+
1473
+ return Promise.reject(new Error(
1474
+ `Merge conflicts detected between ${currentBranch} and ${defaultBranch}.\n\n` +
1475
+ `To resolve:\n` +
1476
+ `1. Manually merge: git checkout ${defaultBranch} && git merge ${currentBranch}\n` +
1477
+ `2. Resolve conflicts in the affected files\n` +
1478
+ `3. Stage resolved files: git add <files>\n` +
1479
+ `4. Complete merge: git commit\n` +
1480
+ `5. Push: git push origin ${defaultBranch}\n` +
1481
+ `6. Mark work as done: jettypod work status ${currentWork.id} done`
1482
+ ));
1483
+ }
1484
+
1485
+ return Promise.reject(new Error(`Failed to merge ${currentBranch}: ${err.message}`));
1486
+ }
1487
+
1488
+ // Step 5: Push default branch to remote
1489
+ try {
1490
+ console.log(`Pushing ${defaultBranch} to remote...`);
1491
+ execSync(`git push origin ${defaultBranch}`, {
1492
+ cwd: gitRoot,
1493
+ stdio: 'inherit'
1494
+ });
1495
+ } catch (err) {
1496
+ const errorMsg = err.message.toLowerCase();
1497
+ const isNetworkError = errorMsg.includes('could not resolve host') ||
1498
+ errorMsg.includes('failed to connect') ||
1499
+ errorMsg.includes('connection timed out') ||
1500
+ errorMsg.includes('network') ||
1501
+ errorMsg.includes('connection refused');
1502
+
1503
+ if (isNetworkError) {
1504
+ return Promise.reject(new Error(
1505
+ `Network error while pushing merged ${defaultBranch} branch.\n\n` +
1506
+ `Current state: ${defaultBranch} branch is merged locally but NOT pushed to remote\n` +
1507
+ `Feature branch: ${currentBranch} (already pushed)\n\n` +
1508
+ `To resolve:\n` +
1509
+ `1. Check network connection\n` +
1510
+ `2. Push ${defaultBranch} manually: git push origin ${defaultBranch}\n` +
1511
+ `3. Mark work as done: jettypod work status ${currentWork.id} done`
1512
+ ));
1513
+ }
1514
+
1515
+ return Promise.reject(new Error(`Failed to push ${defaultBranch}: ${err.message}`));
1516
+ }
1517
+
1518
+ console.log(`✓ Successfully merged work item #${currentWork.id}`);
1519
+
1520
+ if (withTransition) {
1521
+ // Hold lock for transition phase (BDD generation)
1522
+ console.log('⚠️ Merge lock held for transition phase');
1523
+ console.log(' Skills will release lock after generating stable scenarios');
1524
+ console.log(' Release lock with: jettypod work merge --release-lock');
1525
+ return Promise.resolve({ lockHeld: true });
1526
+ }
1527
+
1528
+ // Mark work item as done
1529
+ console.log('Marking work item as done...');
1530
+ await new Promise((resolve, reject) => {
1531
+ db.run(
1532
+ `UPDATE work_items SET status = 'done', completed_at = datetime('now') WHERE id = ?`,
1533
+ [currentWork.id],
1534
+ (err) => {
1535
+ if (err) return reject(err);
1536
+ resolve();
1537
+ }
1538
+ );
1539
+ });
1540
+
1541
+ // Get worktree info from database
1542
+ const worktree = await new Promise((resolve, reject) => {
1543
+ db.get(
1544
+ `SELECT id, worktree_path, branch_name FROM worktrees WHERE work_item_id = ? AND status = 'active'`,
1545
+ [currentWork.id],
1546
+ (err, row) => {
1547
+ if (err) return reject(err);
1548
+ resolve(row);
1549
+ }
1550
+ );
1551
+ });
1552
+
1553
+ // Clean up worktree if it exists
1554
+ if (worktree && worktree.worktree_path && fs.existsSync(worktree.worktree_path)) {
1555
+ console.log('Cleaning up worktree...');
1556
+ try {
1557
+ // Remove the git worktree
1558
+ execSync(`git worktree remove "${worktree.worktree_path}" --force`, {
1559
+ cwd: gitRoot,
1560
+ stdio: 'pipe'
1561
+ });
1562
+
1563
+ // Delete worktree record from database
1564
+ await new Promise((resolve, reject) => {
1565
+ db.run(
1566
+ `DELETE FROM worktrees WHERE id = ?`,
1567
+ [worktree.id],
1568
+ (err) => {
1569
+ if (err) return reject(err);
1570
+ resolve();
1571
+ }
1572
+ );
1573
+ });
1574
+
1575
+ console.log('✅ Worktree cleaned up');
1576
+ } catch (worktreeErr) {
1577
+ console.warn(`Warning: Failed to clean up worktree: ${worktreeErr.message}`);
1578
+ // Non-fatal - continue with merge success
1579
+ }
1580
+ }
1581
+
1582
+ console.log(`✅ Work item #${currentWork.id} marked as done`);
1583
+ return Promise.resolve();
1584
+ } finally {
1585
+ // Release lock unless holding for transition
1586
+ if (lock && !withTransition) {
1587
+ try {
1588
+ await lock.release();
1589
+ console.log('✅ Merge lock released');
1590
+ } catch (releaseErr) {
1591
+ console.warn(`Warning: Failed to release merge lock: ${releaseErr.message}`);
1592
+ }
1593
+ }
1594
+ }
1595
+ }
1596
+
1597
+ module.exports = {
1598
+ startWork,
1599
+ stopWork,
1600
+ getCurrentWork,
1601
+ cleanupWorktrees,
1602
+ mergeWork
1603
+ };