jettypod 4.4.62 → 4.4.64

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.
@@ -156,8 +156,15 @@ function evaluateBashCommand(command, inputRef, cwd) {
156
156
  };
157
157
  }
158
158
 
159
- // BLOCKED: Manual branch creation
160
- if (/git\s+checkout\s+-b\b/.test(strippedCommand) || /git\s+branch\s+(?!-d|-D)/.test(strippedCommand)) {
159
+ // BLOCKED: Manual branch creation (but allow read-only git branch commands)
160
+ // git checkout -b <branch> - creates branch
161
+ // git branch <name> - creates branch (no flags)
162
+ // git branch -d/-D - deletes branch (allowed)
163
+ // git branch --show-current, -l, -a, -r, -v, --list, --merged, etc. - read-only (allowed)
164
+ const isBranchCreation =
165
+ /git\s+checkout\s+-b\b/.test(strippedCommand) ||
166
+ /git\s+branch\s+[^-\s][^\s]*\s*$/.test(strippedCommand); // git branch <name> with no flags
167
+ if (isBranchCreation) {
161
168
  return {
162
169
  allowed: false,
163
170
  message: 'Manual branch creation is blocked',
@@ -178,6 +185,19 @@ function evaluateBashCommand(command, inputRef, cwd) {
178
185
  }
179
186
  }
180
187
 
188
+ // BLOCKED: work merge or tests merge from inside a worktree
189
+ // This prevents shell CWD corruption when worktree is deleted
190
+ if (/jettypod\s+(work|tests)\s+merge\b/.test(strippedCommand)) {
191
+ const isInWorktree = cwd && cwd.includes('.jettypod-work');
192
+ if (isInWorktree) {
193
+ return {
194
+ allowed: false,
195
+ message: 'Cannot merge from inside a worktree',
196
+ hint: 'Merging deletes the worktree. Run from main repo: cd <main-repo-path> && jettypod work merge'
197
+ };
198
+ }
199
+ }
200
+
181
201
 
182
202
  // ALLOWED: Git read-only commands
183
203
  if (/git\s+(status|log|diff|show|branch\s*$|remote|fetch)\b/.test(strippedCommand)) {
@@ -215,7 +235,7 @@ function findDatabasePath(cwd) {
215
235
  * @param {string} cwd - Current working directory
216
236
  * @returns {string|null} Active worktree path or null
217
237
  */
218
- function getActiveWorktreePathFromDB(cwd) {
238
+ function getActiveWorktreePathFromDB(cwd, filePath) {
219
239
  const dbPath = findDatabasePath(cwd);
220
240
  if (!dbPath) {
221
241
  debug('db_lookup', { status: 'no_db_found', cwd });
@@ -226,12 +246,17 @@ function getActiveWorktreePathFromDB(cwd) {
226
246
  // Try better-sqlite3 first
227
247
  const sqlite3 = require('better-sqlite3');
228
248
  const db = sqlite3(dbPath, { readonly: true });
229
- const row = db.prepare(
230
- `SELECT worktree_path FROM worktrees WHERE status = 'active' LIMIT 1`
231
- ).get();
249
+ // Get ALL active worktrees, then find the one that contains our cwd
250
+ const rows = db.prepare(
251
+ `SELECT worktree_path FROM worktrees WHERE status = 'active'`
252
+ ).all();
232
253
  db.close();
233
- const result = row ? row.worktree_path : null;
234
- debug('db_lookup', { status: 'success', method: 'better-sqlite3', worktree: result });
254
+
255
+ // Find the worktree whose path is a prefix of cwd (we're inside it)
256
+ const normalizedFilePath = filePath ? path.resolve(cwd, filePath) : null;
257
+ const matchingWorktree = rows.find(row => cwd.startsWith(row.worktree_path) || (normalizedFilePath && normalizedFilePath.startsWith(row.worktree_path + path.sep)));
258
+ const result = matchingWorktree ? matchingWorktree.worktree_path : null;
259
+ debug('db_lookup', { status: 'success', method: 'better-sqlite3', worktree: result, candidates: rows.length });
235
260
  return result;
236
261
  } catch (err) {
237
262
  debug('db_lookup', { status: 'fallback', reason: err.message });
@@ -239,15 +264,20 @@ function getActiveWorktreePathFromDB(cwd) {
239
264
  const { spawnSync } = require('child_process');
240
265
  const result = spawnSync('sqlite3', [
241
266
  dbPath,
242
- `SELECT worktree_path FROM worktrees WHERE status = 'active' LIMIT 1`
267
+ `-list`,
268
+ `SELECT worktree_path FROM worktrees WHERE status = 'active'`
243
269
  ], { encoding: 'utf-8' });
244
270
 
245
271
  if (result.error || result.status !== 0) {
246
272
  debug('db_lookup', { status: 'cli_failed', error: result.error?.message || 'non-zero exit' });
247
273
  return null;
248
274
  }
249
- const worktree = result.stdout.trim() || null;
250
- debug('db_lookup', { status: 'success', method: 'sqlite3-cli', worktree });
275
+ // Parse all worktree paths and find the one containing cwd
276
+ const paths = result.stdout.trim().split('\n').filter(Boolean);
277
+ const normalizedFilePath2 = filePath ? path.resolve(cwd, filePath) : null;
278
+ const matchingPath = paths.find(p => cwd.startsWith(p) || (normalizedFilePath2 && normalizedFilePath2.startsWith(p + path.sep)));
279
+ const worktree = matchingPath || null;
280
+ debug('db_lookup', { status: 'success', method: 'sqlite3-cli', worktree, candidates: paths.length });
251
281
  return worktree;
252
282
  }
253
283
  }
@@ -257,7 +287,7 @@ function getActiveWorktreePathFromDB(cwd) {
257
287
  */
258
288
  function evaluateWriteOperation(filePath, inputWorktreePath, cwd) {
259
289
  // Query database for active worktree, fall back to input if provided
260
- const activeWorktreePath = getActiveWorktreePathFromDB(cwd) || inputWorktreePath;
290
+ const activeWorktreePath = getActiveWorktreePathFromDB(cwd, filePath) || inputWorktreePath;
261
291
 
262
292
  // Normalize paths
263
293
  const normalizedPath = path.resolve(cwd || '.', filePath);
@@ -270,24 +300,8 @@ function evaluateWriteOperation(filePath, inputWorktreePath, cwd) {
270
300
  normalizedWorktree
271
301
  });
272
302
 
273
- // BLOCKED: Protected files (skills, hooks)
274
- const protectedPatterns = [
275
- /\.claude\/skills\//i,
276
- /claude-hooks\//i,
277
- /\.jettypod\/hooks\//i
278
- ];
279
-
280
- for (const pattern of protectedPatterns) {
281
- if (pattern.test(normalizedPath) || pattern.test(filePath)) {
282
- return {
283
- allowed: false,
284
- message: 'Protected file - cannot modify',
285
- hint: 'Skill and hook files are protected. Modify them through proper channels.'
286
- };
287
- }
288
- }
289
-
290
- // Check if path is in a worktree
303
+ // Check if path is in a worktree FIRST
304
+ // This allows editing protected files (hooks, skills) in worktrees
291
305
  const isInWorktree = /\.jettypod-work\//.test(filePath) || /\.jettypod-work\//.test(normalizedPath);
292
306
 
293
307
  if (isInWorktree) {
@@ -316,6 +330,23 @@ function evaluateWriteOperation(filePath, inputWorktreePath, cwd) {
316
330
  };
317
331
  }
318
332
 
333
+ // BLOCKED: Protected files in main repo (skills, hooks)
334
+ const protectedPatterns = [
335
+ /\.claude\/skills\//i,
336
+ /claude-hooks\//i,
337
+ /\.jettypod\/hooks\//i
338
+ ];
339
+
340
+ for (const pattern of protectedPatterns) {
341
+ if (pattern.test(normalizedPath) || pattern.test(filePath)) {
342
+ return {
343
+ allowed: false,
344
+ message: 'Protected file - cannot modify',
345
+ hint: 'Skill and hook files are protected. Use jettypod work start to create a worktree first.'
346
+ };
347
+ }
348
+ }
349
+
319
350
  // BLOCKED: Write to main repo (not in worktree)
320
351
  return {
321
352
  allowed: false,
@@ -0,0 +1,262 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Production Mode Guardrails Hook
4
+ *
5
+ * Blocks merge of the last production-mode chore until:
6
+ * 1. All BDD scenarios pass
7
+ * 2. Production standards validation passes
8
+ *
9
+ * This ensures production hardening is complete before deployment.
10
+ */
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { spawnSync } = require('child_process');
15
+
16
+ // Test mode: allows tests to control pass/fail state
17
+ const TEST_MODE = process.env.PRODUCTION_GUARDRAIL_TEST_MODE === '1';
18
+ const TEST_BDD_PASS = process.env.PRODUCTION_GUARDRAIL_BDD_PASS === '1';
19
+ const TEST_STANDARDS_PASS = process.env.PRODUCTION_GUARDRAIL_STANDARDS_PASS === '1';
20
+ const TEST_HAS_STANDARDS = process.env.PRODUCTION_GUARDRAIL_HAS_STANDARDS === '1';
21
+
22
+ // Read hook input from stdin
23
+ let input = '';
24
+ process.stdin.on('data', chunk => input += chunk);
25
+ process.stdin.on('end', async () => {
26
+ try {
27
+ const hookInput = JSON.parse(input);
28
+ const { tool_name, tool_input, cwd } = hookInput;
29
+
30
+ // Only check Bash commands
31
+ if (tool_name !== 'Bash') {
32
+ allow();
33
+ return;
34
+ }
35
+
36
+ const command = tool_input.command || '';
37
+
38
+ // Only check jettypod work merge commands
39
+ if (!/jettypod\s+work\s+merge/.test(command)) {
40
+ allow();
41
+ return;
42
+ }
43
+
44
+ // Extract chore ID from command
45
+ const choreIdMatch = command.match(/jettypod\s+work\s+merge\s+(\d+)/);
46
+ if (!choreIdMatch) {
47
+ // No explicit ID - allow (might be merging current work)
48
+ allow();
49
+ return;
50
+ }
51
+
52
+ const choreId = parseInt(choreIdMatch[1]);
53
+ const result = await checkProductionModeGuardrail(choreId, cwd);
54
+
55
+ if (result.allowed) {
56
+ allow();
57
+ } else {
58
+ deny(result.message, result.hint);
59
+ }
60
+ } catch (err) {
61
+ // Fail open on errors
62
+ allow();
63
+ }
64
+ });
65
+
66
+ /**
67
+ * Check if this merge should be blocked
68
+ */
69
+ async function checkProductionModeGuardrail(choreId, cwd) {
70
+ const dbPath = findDatabasePath(cwd);
71
+ if (!dbPath) {
72
+ return { allowed: true };
73
+ }
74
+
75
+ try {
76
+ const sqlite3 = require('better-sqlite3');
77
+ const db = sqlite3(dbPath, { readonly: true });
78
+
79
+ // Get the chore and its parent feature
80
+ const chore = db.prepare(`
81
+ SELECT wi.id, wi.parent_id, parent.mode, parent.type as parent_type, parent.scenario_file
82
+ FROM work_items wi
83
+ LEFT JOIN work_items parent ON wi.parent_id = parent.id
84
+ WHERE wi.id = ?
85
+ `).get(choreId);
86
+
87
+ if (!chore) {
88
+ db.close();
89
+ return { allowed: true };
90
+ }
91
+
92
+ // If no parent or parent isn't a feature, allow
93
+ if (!chore.parent_id || chore.parent_type !== 'feature') {
94
+ db.close();
95
+ return { allowed: true };
96
+ }
97
+
98
+ // If feature mode isn't 'production', allow
99
+ if (chore.mode !== 'production') {
100
+ db.close();
101
+ return { allowed: true };
102
+ }
103
+
104
+ // Feature is in production mode - check if this is the last chore
105
+ const incompleteChores = db.prepare(`
106
+ SELECT COUNT(*) as count
107
+ FROM work_items
108
+ WHERE parent_id = ? AND type = 'chore' AND status NOT IN ('done', 'cancelled')
109
+ `).get(chore.parent_id);
110
+
111
+ // If there are other incomplete chores, allow this merge
112
+ if (incompleteChores.count > 1) {
113
+ db.close();
114
+ return { allowed: true };
115
+ }
116
+
117
+ // This is the last production-mode chore
118
+ const scenarioFile = chore.scenario_file;
119
+ db.close();
120
+
121
+ // Check 1: BDD tests must pass
122
+ if (scenarioFile) {
123
+ const testsPass = await runBddTests(scenarioFile, cwd);
124
+ if (!testsPass) {
125
+ return {
126
+ allowed: false,
127
+ message: 'Cannot merge last production-mode chore: BDD scenarios must pass',
128
+ hint: 'Run tests and fix failures before merging.'
129
+ };
130
+ }
131
+ }
132
+
133
+ // Check 2: Production standards validation must pass
134
+ const standardsPass = await validateProductionStandards(cwd);
135
+ if (!standardsPass) {
136
+ return {
137
+ allowed: false,
138
+ message: 'Cannot merge last production-mode chore: Production standards validation must pass',
139
+ hint: 'Run production standards validation and fix any gaps.'
140
+ };
141
+ }
142
+
143
+ // All checks pass
144
+ return { allowed: true };
145
+
146
+ } catch (err) {
147
+ // Fail open
148
+ return { allowed: true };
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Run BDD tests and return whether they pass
154
+ */
155
+ async function runBddTests(scenarioFile, cwd) {
156
+ // In test mode, use environment variable to control pass/fail
157
+ if (TEST_MODE) {
158
+ return TEST_BDD_PASS;
159
+ }
160
+
161
+ try {
162
+ const scenarioPath = path.resolve(cwd, scenarioFile);
163
+
164
+ // Check if scenario file exists
165
+ if (!fs.existsSync(scenarioPath)) {
166
+ return true;
167
+ }
168
+
169
+ // Run cucumber-js with the scenario file
170
+ const result = spawnSync('npx', ['cucumber-js', scenarioPath, '--format', 'summary'], {
171
+ cwd: cwd,
172
+ encoding: 'utf-8',
173
+ timeout: 60000
174
+ });
175
+
176
+ return result.status === 0;
177
+ } catch (err) {
178
+ return true;
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Validate production standards
184
+ */
185
+ async function validateProductionStandards(cwd) {
186
+ // In test mode, use environment variable to control pass/fail
187
+ if (TEST_MODE) {
188
+ // If no standards file in test mode, allow
189
+ if (!TEST_HAS_STANDARDS) {
190
+ return true;
191
+ }
192
+ return TEST_STANDARDS_PASS;
193
+ }
194
+
195
+ try {
196
+ // Check if production standards file exists
197
+ const standardsPath = path.join(cwd, '.jettypod', 'production-standards.json');
198
+ if (!fs.existsSync(standardsPath)) {
199
+ // No standards file = allow (nothing to validate)
200
+ return true;
201
+ }
202
+
203
+ // Read and validate standards
204
+ const standards = JSON.parse(fs.readFileSync(standardsPath, 'utf-8'));
205
+
206
+ // If no standards defined, allow
207
+ if (!standards.standards || standards.standards.length === 0) {
208
+ return true;
209
+ }
210
+
211
+ // Check if all standards are satisfied
212
+ // For now, we just check if the file exists and has standards
213
+ // More sophisticated validation would check each standard against implementation
214
+ return true;
215
+ } catch (err) {
216
+ return true;
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Find the jettypod database path
222
+ */
223
+ function findDatabasePath(cwd) {
224
+ let dir = cwd || process.cwd();
225
+ while (dir !== path.dirname(dir)) {
226
+ const dbPath = path.join(dir, '.jettypod', 'work.db');
227
+ if (fs.existsSync(dbPath)) {
228
+ return dbPath;
229
+ }
230
+ dir = path.dirname(dir);
231
+ }
232
+ return null;
233
+ }
234
+
235
+ /**
236
+ * Allow the action
237
+ */
238
+ function allow() {
239
+ console.log(JSON.stringify({
240
+ hookSpecificOutput: {
241
+ hookEventName: "PreToolUse",
242
+ permissionDecision: "allow"
243
+ }
244
+ }));
245
+ process.exit(0);
246
+ }
247
+
248
+ /**
249
+ * Deny the action with explanation
250
+ */
251
+ function deny(message, hint) {
252
+ const reason = `❌ ${message}\n\n💡 Hint: ${hint}`;
253
+
254
+ console.log(JSON.stringify({
255
+ hookSpecificOutput: {
256
+ hookEventName: "PreToolUse",
257
+ permissionDecision: "deny",
258
+ permissionDecisionReason: reason
259
+ }
260
+ }));
261
+ process.exit(0);
262
+ }
@@ -0,0 +1,163 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Speed Mode Guardrails Hook
4
+ *
5
+ * Blocks merge of the last speed-mode chore until stable-mode transition happens.
6
+ * This ensures the speed→stable handoff is not skipped.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ // Read hook input from stdin
13
+ let input = '';
14
+ process.stdin.on('data', chunk => input += chunk);
15
+ process.stdin.on('end', async () => {
16
+ try {
17
+ const hookInput = JSON.parse(input);
18
+ const { tool_name, tool_input, cwd } = hookInput;
19
+
20
+ // Only check Bash commands
21
+ if (tool_name !== 'Bash') {
22
+ allow();
23
+ return;
24
+ }
25
+
26
+ const command = tool_input.command || '';
27
+
28
+ // Only check jettypod work merge commands
29
+ if (!/jettypod\s+work\s+merge/.test(command)) {
30
+ allow();
31
+ return;
32
+ }
33
+
34
+ // Extract chore ID from command
35
+ const choreIdMatch = command.match(/jettypod\s+work\s+merge\s+(\d+)/);
36
+ if (!choreIdMatch) {
37
+ // No explicit ID - allow (might be merging current work)
38
+ allow();
39
+ return;
40
+ }
41
+
42
+ const choreId = parseInt(choreIdMatch[1]);
43
+ const result = await checkSpeedModeGuardrail(choreId, cwd);
44
+
45
+ if (result.allowed) {
46
+ allow();
47
+ } else {
48
+ deny(result.message, result.hint);
49
+ }
50
+ } catch (err) {
51
+ // Fail open on errors
52
+ allow();
53
+ }
54
+ });
55
+
56
+ /**
57
+ * Check if this merge should be blocked
58
+ */
59
+ async function checkSpeedModeGuardrail(choreId, cwd) {
60
+ const dbPath = findDatabasePath(cwd);
61
+ if (!dbPath) {
62
+ return { allowed: true };
63
+ }
64
+
65
+ try {
66
+ const sqlite3 = require('better-sqlite3');
67
+ const db = sqlite3(dbPath, { readonly: true });
68
+
69
+ // Get the chore and its parent feature
70
+ const chore = db.prepare(`
71
+ SELECT wi.id, wi.parent_id, parent.mode, parent.type as parent_type
72
+ FROM work_items wi
73
+ LEFT JOIN work_items parent ON wi.parent_id = parent.id
74
+ WHERE wi.id = ?
75
+ `).get(choreId);
76
+
77
+ if (!chore) {
78
+ db.close();
79
+ return { allowed: true };
80
+ }
81
+
82
+ // If no parent or parent isn't a feature, allow
83
+ if (!chore.parent_id || chore.parent_type !== 'feature') {
84
+ db.close();
85
+ return { allowed: true };
86
+ }
87
+
88
+ // If feature mode isn't 'speed', allow
89
+ if (chore.mode !== 'speed') {
90
+ db.close();
91
+ return { allowed: true };
92
+ }
93
+
94
+ // Feature is in speed mode - check if this is the last chore
95
+ const incompleteChores = db.prepare(`
96
+ SELECT COUNT(*) as count
97
+ FROM work_items
98
+ WHERE parent_id = ? AND type = 'chore' AND status NOT IN ('done', 'cancelled')
99
+ `).get(chore.parent_id);
100
+
101
+ db.close();
102
+
103
+ // If there are other incomplete chores, allow this merge
104
+ if (incompleteChores.count > 1) {
105
+ return { allowed: true };
106
+ }
107
+
108
+ // This is the last speed-mode chore - block!
109
+ return {
110
+ allowed: false,
111
+ message: 'Cannot merge last speed-mode chore while feature is in speed mode',
112
+ hint: 'Generate stable-mode chores first. Use the speed-mode skill to complete the handoff.'
113
+ };
114
+
115
+ } catch (err) {
116
+ // Fail open
117
+ return { allowed: true };
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Find the jettypod database path
123
+ */
124
+ function findDatabasePath(cwd) {
125
+ let dir = cwd || process.cwd();
126
+ while (dir !== path.dirname(dir)) {
127
+ const dbPath = path.join(dir, '.jettypod', 'work.db');
128
+ if (fs.existsSync(dbPath)) {
129
+ return dbPath;
130
+ }
131
+ dir = path.dirname(dir);
132
+ }
133
+ return null;
134
+ }
135
+
136
+ /**
137
+ * Allow the action
138
+ */
139
+ function allow() {
140
+ console.log(JSON.stringify({
141
+ hookSpecificOutput: {
142
+ hookEventName: "PreToolUse",
143
+ permissionDecision: "allow"
144
+ }
145
+ }));
146
+ process.exit(0);
147
+ }
148
+
149
+ /**
150
+ * Deny the action with explanation
151
+ */
152
+ function deny(message, hint) {
153
+ const reason = `❌ ${message}\n\n💡 Hint: ${hint}`;
154
+
155
+ console.log(JSON.stringify({
156
+ hookSpecificOutput: {
157
+ hookEventName: "PreToolUse",
158
+ permissionDecision: "deny",
159
+ permissionDecisionReason: reason
160
+ }
161
+ }));
162
+ process.exit(0);
163
+ }