jettypod 4.4.21 → 4.4.22
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/jettypod.js +11 -11
- package/lib/decisions/index.js +490 -0
- package/lib/git-hooks/git-hooks.feature +30 -0
- package/lib/git-hooks/index.js +94 -0
- package/lib/git-hooks/post-commit +59 -0
- package/lib/git-hooks/post-merge +71 -0
- package/lib/git-hooks/pre-commit +28 -0
- package/lib/git-hooks/simple-steps.js +53 -0
- package/lib/git-hooks/simple-test.feature +10 -0
- package/lib/git-hooks/steps.js +196 -0
- package/lib/mode-prompts/index.js +95 -0
- package/lib/mode-prompts/simple-steps.js +44 -0
- package/lib/mode-prompts/simple-test.feature +9 -0
- package/lib/update-command/index.js +181 -0
- package/lib/work-commands/bug-workflow-display.feature +22 -0
- package/lib/work-commands/index.js +1603 -0
- package/lib/work-commands/simple-steps.js +69 -0
- package/lib/work-commands/stable-tests.feature +57 -0
- package/lib/work-commands/steps.js +1233 -0
- package/lib/work-commands/work-commands.feature +13 -0
- package/lib/work-commands/worktree-management.feature +63 -0
- package/lib/work-tracking/index.js +2396 -0
- package/lib/work-tracking/mode-required.feature +111 -0
- package/lib/work-tracking/work-set-mode.feature +70 -0
- package/lib/work-tracking/work-start-mode.feature +83 -0
- package/package.json +1 -1
|
@@ -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
|
+
};
|