jettypod 4.2.11 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,88 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ /**
4
+ * Post-Checkout Hook
5
+ *
6
+ * 1. Prevents checking out default branch in JettyPod worktrees
7
+ * 2. Imports database snapshots after checkout
8
+ */
9
+
3
10
  const { importAll } = require('../lib/db-import');
11
+ const { execSync } = require('child_process');
4
12
 
5
13
  (async () => {
14
+ const cwd = process.cwd();
15
+
16
+ // FIRST: Check if we're in a JettyPod worktree and prevent default branch checkout
17
+ if (cwd.includes('.jettypod-work')) {
18
+ try {
19
+ // Get the branch we just checked out
20
+ const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', {
21
+ encoding: 'utf8',
22
+ stdio: ['pipe', 'pipe', 'pipe']
23
+ }).trim();
24
+
25
+ // Detect default branch
26
+ let defaultBranch;
27
+ try {
28
+ // Try to get the default branch from git config
29
+ defaultBranch = execSync('git symbolic-ref refs/remotes/origin/HEAD', {
30
+ encoding: 'utf8',
31
+ stdio: ['pipe', 'pipe', 'pipe']
32
+ }).trim().replace('refs/remotes/origin/', '');
33
+ } catch {
34
+ // Fallback: check which common branch names exist
35
+ try {
36
+ execSync('git rev-parse --verify main', { stdio: ['pipe', 'pipe', 'pipe'] });
37
+ defaultBranch = 'main';
38
+ } catch {
39
+ try {
40
+ execSync('git rev-parse --verify master', { stdio: ['pipe', 'pipe', 'pipe'] });
41
+ defaultBranch = 'master';
42
+ } catch {
43
+ // Can't determine default branch - skip check
44
+ defaultBranch = null;
45
+ }
46
+ }
47
+ }
48
+
49
+ // Check if we checked out the default branch
50
+ if (defaultBranch && currentBranch === defaultBranch) {
51
+ console.error('');
52
+ console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
53
+ console.error('❌ ERROR: Cannot checkout default branch in worktree');
54
+ console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
55
+ console.error('');
56
+ console.error(`You are in a JettyPod worktree for isolated work.`);
57
+ console.error(`Checking out ${defaultBranch} in a worktree bypasses the merge workflow.`);
58
+ console.error('');
59
+ console.error('To merge your changes:');
60
+ console.error(' 1. Commit your changes: git add . && git commit -m "..."');
61
+ console.error(' 2. Use JettyPod merge: jettypod work merge');
62
+ console.error('');
63
+ console.error('This ensures proper worktree cleanup and database updates.');
64
+ console.error('');
65
+ console.error('Reverting checkout...');
66
+ console.error('');
67
+
68
+ // Revert to previous branch
69
+ try {
70
+ execSync('git checkout -', { stdio: 'inherit' });
71
+ } catch (revertErr) {
72
+ console.error('Failed to revert checkout. You may need to manually switch branches.');
73
+ }
74
+
75
+ process.exit(1);
76
+ }
77
+ } catch (err) {
78
+ // If we can't determine the branch, allow the checkout
79
+ // (better to be permissive than block legitimate operations)
80
+ }
81
+ }
82
+
83
+ // SECOND: Import database snapshots
6
84
  try {
7
- // Import JSON snapshots into databases after checkout
8
85
  await importAll();
9
-
10
- // Exit successfully - checkout should not be blocked
11
86
  process.exit(0);
12
87
  } catch (err) {
13
88
  // Log error but don't block checkout
package/hooks/pre-push ADDED
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Pre-Push Hook
5
+ *
6
+ * Prevents pushing directly from JettyPod worktrees.
7
+ * Forces use of `jettypod work merge` to ensure proper cleanup.
8
+ */
9
+
10
+ const cwd = process.cwd();
11
+
12
+ // Check if we're in a JettyPod worktree
13
+ if (cwd.includes('.jettypod-work')) {
14
+ console.error('');
15
+ console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
16
+ console.error('❌ ERROR: Cannot push directly from worktree');
17
+ console.error('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
18
+ console.error('');
19
+ console.error('You are in a JettyPod worktree for isolated work.');
20
+ console.error('Pushing directly bypasses the merge workflow.');
21
+ console.error('');
22
+ console.error('To merge and push your changes:');
23
+ console.error(' 1. Commit your changes: git add . && git commit -m "..."');
24
+ console.error(' 2. Use JettyPod merge: jettypod work merge');
25
+ console.error('');
26
+ console.error('This ensures proper worktree cleanup and database updates.');
27
+ console.error('');
28
+
29
+ process.exit(1);
30
+ }
31
+
32
+ // Allow pushes from main repo
33
+ process.exit(0);
package/jettypod.js CHANGED
@@ -1153,7 +1153,11 @@ switch (command) {
1153
1153
  } else if (subcommand === 'merge') {
1154
1154
  const workCommands = require('./features/work-commands/index.js');
1155
1155
  try {
1156
- await workCommands.mergeWork();
1156
+ // Parse merge flags from args
1157
+ const withTransition = args.includes('--with-transition');
1158
+ const releaseLock = args.includes('--release-lock');
1159
+
1160
+ await workCommands.mergeWork({ withTransition, releaseLock });
1157
1161
  } catch (err) {
1158
1162
  console.error(`Error: ${err.message}`);
1159
1163
  process.exit(1);
package/lib/claudemd.js CHANGED
@@ -65,11 +65,19 @@ function updateCurrentWork(currentWork, mode) {
65
65
 
66
66
  // Write to session file (gitignored) instead of CLAUDE.md
67
67
  // This prevents merge conflicts and stale context on main branch
68
+ // Only write session file if we're in a worktree (not root directory)
68
69
  if (currentMode !== null) {
69
- writeSessionFile(currentWork, currentMode, {
70
- epicId: currentWork.epic_id,
71
- epicTitle: currentWork.epic_title
72
- });
70
+ try {
71
+ writeSessionFile(currentWork, currentMode, {
72
+ epicId: currentWork.epic_id,
73
+ epicTitle: currentWork.epic_title
74
+ });
75
+ } catch (err) {
76
+ // Skip if not in worktree - validation will throw error
77
+ if (!err.message.includes('Cannot create session.md in root directory')) {
78
+ throw err; // Re-throw unexpected errors
79
+ }
80
+ }
73
81
  }
74
82
  }
75
83
 
@@ -1,7 +1,6 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const { getDb } = require('./database');
4
- const { getSessionByWorktreePath, getAllActiveSessions } = require('./worktree-sessions');
5
4
 
6
5
  /**
7
6
  * Get path to current-work.json file
@@ -12,35 +11,62 @@ function getCurrentWorkPath() {
12
11
  }
13
12
 
14
13
  /**
15
- * Get current work item from worktree session database
14
+ * Get current work item based on status='in_progress'
15
+ * Extracts work item ID from worktree branch name if in a worktree,
16
+ * otherwise returns first in_progress item
16
17
  * @returns {Object|null} Current work item or null if not set
17
18
  */
18
19
  async function getCurrentWork() {
19
20
  const worktreePath = process.cwd();
20
21
  const db = getDb();
22
+ const { execSync } = require('child_process');
23
+
24
+ // Try to extract work item ID from branch name (worktree naming: "feature/work-123-some-title" or "123-some-title")
25
+ let workItemId = null;
26
+ try {
27
+ const branchName = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim();
28
+ // Match either "feature/work-123-" or "123-" at start
29
+ const match = branchName.match(/(?:feature\/work-|^)(\d+)-/);
30
+ if (match) {
31
+ workItemId = parseInt(match[1]);
32
+ }
33
+ } catch (err) {
34
+ // Not in a git repo or other git error - that's ok
35
+ }
21
36
 
22
- // Query worktree session and join with work_items
37
+ // Query for in_progress work item
23
38
  let row;
24
39
  try {
25
40
  row = await new Promise((resolve, reject) => {
26
- db.get(
27
- `SELECT
28
- wi.id, wi.title, wi.type, wi.status, wi.parent_id, wi.epic_id,
29
- parent.title as parent_title,
30
- epic.title as epic_title
31
- FROM worktree_sessions ws
32
- JOIN work_items wi ON ws.work_item_id = wi.id
33
- LEFT JOIN work_items parent ON wi.parent_id = parent.id
34
- LEFT JOIN work_items epic ON wi.epic_id = epic.id
35
- WHERE ws.worktree_path = ?`,
36
- [worktreePath],
37
- (err, row) => {
38
- if (err) {
39
- return reject(err);
40
- }
41
- resolve(row);
41
+ const query = workItemId
42
+ ? // If we have an ID from branch name, get that specific item
43
+ `SELECT
44
+ wi.id, wi.title, wi.type, wi.status, wi.parent_id, wi.epic_id,
45
+ parent.title as parent_title,
46
+ epic.title as epic_title
47
+ FROM work_items wi
48
+ LEFT JOIN work_items parent ON wi.parent_id = parent.id
49
+ LEFT JOIN work_items epic ON wi.epic_id = epic.id
50
+ WHERE wi.id = ? AND wi.status = 'in_progress'`
51
+ : // Otherwise get any in_progress item (for main branch)
52
+ `SELECT
53
+ wi.id, wi.title, wi.type, wi.status, wi.parent_id, wi.epic_id,
54
+ parent.title as parent_title,
55
+ epic.title as epic_title
56
+ FROM work_items wi
57
+ LEFT JOIN work_items parent ON wi.parent_id = parent.id
58
+ LEFT JOIN work_items epic ON wi.epic_id = epic.id
59
+ WHERE wi.status = 'in_progress'
60
+ LIMIT 1`;
61
+
62
+ const params = workItemId ? [workItemId] : [];
63
+
64
+ db.get(query, params, (err, row) => {
65
+ if (err) {
66
+ return reject(err);
42
67
  }
43
- );
68
+ resolve(row);
69
+ });
44
70
  });
45
71
  } catch (dbErr) {
46
72
  // Handle database errors gracefully - log but don't crash
@@ -58,53 +84,6 @@ async function getCurrentWork() {
58
84
  return row;
59
85
  }
60
86
 
61
- // No session found - check if we should warn about main branch
62
- try {
63
- const allSessions = await getAllActiveSessions();
64
- if (allSessions.length > 0) {
65
- // Get all work items in ONE query using IN clause
66
- const ids = allSessions.map(s => s.work_item_id);
67
- const placeholders = ids.map(() => '?').join(',');
68
-
69
- let workItems;
70
- try {
71
- workItems = await new Promise((resolve, reject) => {
72
- db.all(
73
- `SELECT id, title FROM work_items WHERE id IN (${placeholders})`,
74
- ids,
75
- (err, rows) => {
76
- if (err) reject(err);
77
- else resolve(rows || []);
78
- }
79
- );
80
- });
81
- } catch (workItemErr) {
82
- // Database error getting work items - log but continue
83
- console.error('Database error getting work items:', workItemErr.message);
84
- workItems = [];
85
- }
86
-
87
- // Create map of id -> title
88
- const titleMap = {};
89
- for (const wi of workItems) {
90
- titleMap[wi.id] = wi.title;
91
- }
92
-
93
- // Display warnings
94
- console.warn('No active work in main branch');
95
- console.warn('\nActive work in other worktrees:');
96
- for (const session of allSessions) {
97
- const title = titleMap[session.work_item_id];
98
- if (title) {
99
- console.warn(` • ${title} (${session.worktree_path})`);
100
- }
101
- }
102
- }
103
- } catch (listErr) {
104
- // Ignore listing errors - gracefully fail
105
- console.error('Error listing active sessions:', listErr.message);
106
- }
107
-
108
87
  return null;
109
88
  }
110
89
 
@@ -136,41 +115,75 @@ function validateWorkItem(workItem) {
136
115
  }
137
116
 
138
117
  /**
139
- * Set current work item in worktree session database
118
+ * Set current work item by updating status to in_progress
140
119
  * @param {Object} workItem - Work item to set as current
141
120
  * @throws {Error} If workItem is invalid
142
121
  */
143
122
  async function setCurrentWork(workItem) {
144
123
  validateWorkItem(workItem);
145
124
 
146
- const { createOrUpdateSession } = require('./worktree-sessions');
147
- const { execSync } = require('child_process');
148
- const worktreePath = process.cwd();
125
+ const db = getDb();
149
126
 
150
- // Get current branch name
151
- let branchName;
127
+ // Update work item status to in_progress
152
128
  try {
153
- branchName = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim();
129
+ await new Promise((resolve, reject) => {
130
+ db.run(
131
+ 'UPDATE work_items SET status = ? WHERE id = ?',
132
+ ['in_progress', workItem.id],
133
+ (err) => {
134
+ if (err) {
135
+ return reject(new Error(`Failed to set work item status: ${err.message}`));
136
+ }
137
+ resolve();
138
+ }
139
+ );
140
+ });
154
141
  } catch (err) {
155
- branchName = 'unknown';
142
+ throw new Error(`Failed to set current work: ${err.message}`);
156
143
  }
157
-
158
- // Create or update worktree session in database
159
- await createOrUpdateSession(worktreePath, workItem.id, branchName);
160
144
  }
161
145
 
162
146
  /**
163
- * Clear current work item from worktree session database
164
- * @throws {Error} If session cannot be deleted
147
+ * Clear current work item by extracting ID from branch and setting status to backlog
148
+ * @throws {Error} If work item cannot be found or updated
165
149
  */
166
150
  async function clearCurrentWork() {
167
- const { deleteSession } = require('./worktree-sessions');
168
- const worktreePath = process.cwd();
151
+ const { execSync } = require('child_process');
152
+ const db = getDb();
169
153
 
154
+ // Extract work item ID from branch name
155
+ let workItemId = null;
170
156
  try {
171
- await deleteSession(worktreePath);
157
+ const branchName = execSync('git rev-parse --abbrev-ref HEAD', { encoding: 'utf-8' }).trim();
158
+ // Match either "feature/work-123-" or "123-" at start
159
+ const match = branchName.match(/(?:feature\/work-|^)(\d+)-/);
160
+ if (match) {
161
+ workItemId = parseInt(match[1]);
162
+ }
163
+ } catch (err) {
164
+ throw new Error('Cannot determine work item from branch name');
165
+ }
166
+
167
+ if (!workItemId) {
168
+ throw new Error('Cannot clear current work: no work item ID in branch name');
169
+ }
170
+
171
+ // Update work item status back to backlog
172
+ try {
173
+ await new Promise((resolve, reject) => {
174
+ db.run(
175
+ 'UPDATE work_items SET status = ? WHERE id = ?',
176
+ ['backlog', workItemId],
177
+ (err) => {
178
+ if (err) {
179
+ return reject(new Error(`Failed to clear work item status: ${err.message}`));
180
+ }
181
+ resolve();
182
+ }
183
+ );
184
+ });
172
185
  } catch (err) {
173
- throw new Error(`Failed to clear current work session: ${err.message}`);
186
+ throw new Error(`Failed to clear current work: ${err.message}`);
174
187
  }
175
188
  }
176
189
 
package/lib/merge-lock.js CHANGED
@@ -36,11 +36,20 @@ async function acquireMergeLock(db, workItemId, instanceId = null, options = {})
36
36
 
37
37
  const maxWait = options.maxWait || 60000; // 1 minute default
38
38
  const pollInterval = options.pollInterval || 1500; // 1.5 seconds
39
+ const staleThreshold = options.staleThreshold || 120000; // 2 minutes (reduced from 5 minutes)
39
40
  const startTime = Date.now();
40
41
 
41
42
  // Generate instance identifier
42
43
  const lockedBy = instanceId || `${os.hostname()}-${process.pid}`;
43
44
 
45
+ // Proactive cleanup of stale locks before attempting acquisition
46
+ try {
47
+ await cleanupStaleLocks(db, staleThreshold);
48
+ } catch (cleanupErr) {
49
+ // Log but don't fail - cleanup is best-effort
50
+ console.warn(`Warning: Failed to cleanup stale locks: ${cleanupErr.message}`);
51
+ }
52
+
44
53
  while (Date.now() - startTime < maxWait) {
45
54
  // Check if lock exists
46
55
  const existingLock = await checkExistingLock(db);
@@ -102,6 +111,27 @@ function insertLock(db, workItemId, lockedBy) {
102
111
  });
103
112
  }
104
113
 
114
+ /**
115
+ * Clean up stale locks that are older than the threshold
116
+ *
117
+ * @param {Object} db - SQLite database connection
118
+ * @param {number} staleThresholdMs - Age threshold in milliseconds (default: 120000 = 2 minutes)
119
+ * @returns {Promise<void>}
120
+ */
121
+ function cleanupStaleLocks(db, staleThresholdMs = 120000) {
122
+ return new Promise((resolve, reject) => {
123
+ db.run(
124
+ `DELETE FROM merge_locks
125
+ WHERE (julianday('now') - julianday(locked_at)) * 86400000 > ?`,
126
+ [staleThresholdMs],
127
+ (err) => {
128
+ if (err) return reject(err);
129
+ resolve();
130
+ }
131
+ );
132
+ });
133
+ }
134
+
105
135
  /**
106
136
  * Create lock handle object with release function
107
137
  *
@@ -19,6 +19,23 @@ function getSessionFilePath() {
19
19
  return path.join(process.cwd(), '.claude', 'session.md');
20
20
  }
21
21
 
22
+ /**
23
+ * Validate that we're in a worktree, not the root directory
24
+ * @throws {Error} If in root directory
25
+ */
26
+ function validateWorktreeLocation() {
27
+ const cwd = process.cwd();
28
+
29
+ // Check if current directory is inside .jettypod-work/
30
+ if (!cwd.includes('.jettypod-work')) {
31
+ throw new Error(
32
+ 'Cannot create session.md in root directory.\n' +
33
+ 'Session files should only be created in worktrees (.jettypod-work/).\n' +
34
+ 'Use "jettypod work start <id>" to create a worktree for this work item.'
35
+ );
36
+ }
37
+ }
38
+
22
39
  /**
23
40
  * Ensure .claude directory exists
24
41
  */
@@ -69,6 +86,9 @@ function writeSessionFile(workItem, mode, options = {}) {
69
86
  throw new Error('Work item must have a string status');
70
87
  }
71
88
 
89
+ // Validate we're in a worktree, not root directory
90
+ validateWorktreeLocation();
91
+
72
92
  ensureClaudeDir();
73
93
 
74
94
  const skillName = modeToSkillName(mode);
@@ -15,6 +15,29 @@
15
15
  const worktreeManager = require('./worktree-manager');
16
16
  const fs = require('fs');
17
17
  const path = require('path');
18
+ const { getDb } = require('./database');
19
+
20
+ /**
21
+ * Check if a worktree already exists for a work item
22
+ *
23
+ * @param {Object} db - Database connection
24
+ * @param {number} workItemId - Work item ID
25
+ * @returns {Promise<Object|null>} Existing worktree or null
26
+ */
27
+ function checkExistingWorktree(db, workItemId) {
28
+ return new Promise((resolve, reject) => {
29
+ db.get(
30
+ `SELECT * FROM worktrees
31
+ WHERE work_item_id = ?
32
+ AND status = 'active'`,
33
+ [workItemId],
34
+ (err, row) => {
35
+ if (err) return reject(err);
36
+ resolve(row || null);
37
+ }
38
+ );
39
+ });
40
+ }
18
41
 
19
42
  /**
20
43
  * Start work on a work item with graceful degradation
@@ -33,6 +56,7 @@ async function startWork(workItem, options = {}) {
33
56
  }
34
57
 
35
58
  const repoPath = options.repoPath || process.cwd();
59
+ const db = options.db || getDb();
36
60
  const result = {
37
61
  mode: null, // 'worktree' or 'main'
38
62
  path: null, // Working directory path
@@ -41,6 +65,25 @@ async function startWork(workItem, options = {}) {
41
65
  warnings: [] // Non-fatal warnings
42
66
  };
43
67
 
68
+ // Explicit check for existing worktree (defensive programming)
69
+ try {
70
+ const existingWorktree = await checkExistingWorktree(db, workItem.id);
71
+ if (existingWorktree) {
72
+ // Another instance is already working on this - fall back to main
73
+ result.mode = 'main';
74
+ result.path = repoPath;
75
+ result.error = {
76
+ message: `Work item #${workItem.id} already has active worktree at ${existingWorktree.worktree_path}`,
77
+ reason: 'duplicate_worktree_prevented'
78
+ };
79
+ result.warnings.push('Another instance may be working on this chore');
80
+ return result;
81
+ }
82
+ } catch (checkErr) {
83
+ // If check fails, continue anyway - database constraint will catch duplicates
84
+ result.warnings.push(`Warning: Could not check for existing worktree: ${checkErr.message}`);
85
+ }
86
+
44
87
  // Attempt to create worktree
45
88
  try {
46
89
  const worktree = await worktreeManager.createWorktree(workItem, options);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jettypod",
3
- "version": "4.2.11",
3
+ "version": "4.3.0",
4
4
  "description": "AI-powered development workflow manager with TDD, BDD, and automatic test generation",
5
5
  "main": "jettypod.js",
6
6
  "bin": {