jettypod 4.4.61 → 4.4.62

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,401 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Feature-Planning Skill Guardrails Hook
4
+ *
5
+ * Infers current workflow step from observable DB state and validates tool calls.
6
+ * Provides actionable guidance when blocking actions.
7
+ *
8
+ * Feature detection priority:
9
+ * 1. feature_state passed in (for testing)
10
+ * 2. Active test worktree in cwd
11
+ * 3. Current in_progress feature from DB
12
+ */
13
+
14
+ const fs = require('fs');
15
+ const path = require('path');
16
+
17
+ // Read hook input from stdin
18
+ let input = '';
19
+ process.stdin.on('data', chunk => input += chunk);
20
+ process.stdin.on('end', async () => {
21
+ try {
22
+ const hookInput = JSON.parse(input);
23
+ const { tool_name, tool_input, cwd, feature_state } = hookInput;
24
+
25
+ // Use provided feature_state (for testing) or detect from context
26
+ let state = feature_state;
27
+ if (!state || !state.id) {
28
+ state = await detectFeatureState(cwd);
29
+ }
30
+
31
+ // If still no feature state, allow (not in feature-planning context)
32
+ if (!state || !state.id) {
33
+ allow();
34
+ return;
35
+ }
36
+
37
+ // Validate the tool call against feature state
38
+ const result = validateToolCall(state, tool_name, tool_input, cwd);
39
+
40
+ if (result.allowed) {
41
+ allow();
42
+ } else {
43
+ deny(result.message, result.hint);
44
+ }
45
+ } catch (err) {
46
+ // On error, allow (fail open)
47
+ allow();
48
+ }
49
+ });
50
+
51
+ /**
52
+ * Detect active feature state from context
53
+ */
54
+ async function detectFeatureState(cwd) {
55
+ const dbPath = findDatabasePath(cwd);
56
+ if (!dbPath) return null;
57
+
58
+ try {
59
+ // Try to use better-sqlite3 for sync queries
60
+ const sqlite3 = require('better-sqlite3');
61
+ const db = sqlite3(dbPath, { readonly: true });
62
+
63
+ // Check for active test worktree first (we're in test-writing phase)
64
+ const testWorktree = db.prepare(`
65
+ SELECT wt.work_item_id, wt.worktree_path, wt.status
66
+ FROM worktrees wt
67
+ WHERE wt.status = 'active' AND wt.worktree_path LIKE '%tests-%'
68
+ LIMIT 1
69
+ `).get();
70
+
71
+ if (testWorktree) {
72
+ // Found active test worktree - get feature state
73
+ const feature = db.prepare(`
74
+ SELECT id, phase, scenario_file
75
+ FROM work_items
76
+ WHERE id = ?
77
+ `).get(testWorktree.work_item_id);
78
+
79
+ if (feature) {
80
+ const choreCount = db.prepare(`
81
+ SELECT COUNT(*) as count FROM work_items WHERE parent_id = ? AND type = 'chore'
82
+ `).get(feature.id).count;
83
+
84
+ db.close();
85
+ return {
86
+ id: feature.id,
87
+ phase: feature.phase || 'discovery',
88
+ scenarioFile: feature.scenario_file,
89
+ choreCount,
90
+ testWorktree: {
91
+ path: testWorktree.worktree_path,
92
+ status: testWorktree.status
93
+ }
94
+ };
95
+ }
96
+ }
97
+
98
+ // Check for in_progress feature in discovery phase
99
+ const discoveryFeature = db.prepare(`
100
+ SELECT id, phase, scenario_file
101
+ FROM work_items
102
+ WHERE type = 'feature' AND phase = 'discovery' AND status = 'in_progress'
103
+ LIMIT 1
104
+ `).get();
105
+
106
+ if (discoveryFeature) {
107
+ const choreCount = db.prepare(`
108
+ SELECT COUNT(*) as count FROM work_items WHERE parent_id = ? AND type = 'chore'
109
+ `).get(discoveryFeature.id).count;
110
+
111
+ db.close();
112
+ return {
113
+ id: discoveryFeature.id,
114
+ phase: 'discovery',
115
+ scenarioFile: discoveryFeature.scenario_file,
116
+ choreCount,
117
+ testWorktree: null
118
+ };
119
+ }
120
+
121
+ // Check for in_progress chore whose parent feature needs guardrails
122
+ const activeChore = db.prepare(`
123
+ SELECT wi.id, wi.parent_id, parent.phase, parent.scenario_file
124
+ FROM work_items wi
125
+ JOIN work_items parent ON wi.parent_id = parent.id
126
+ WHERE wi.type = 'chore' AND wi.status = 'in_progress'
127
+ AND parent.type = 'feature'
128
+ AND (parent.phase = 'discovery' OR parent.scenario_file IS NULL)
129
+ LIMIT 1
130
+ `).get();
131
+
132
+ if (activeChore) {
133
+ const choreCount = db.prepare(`
134
+ SELECT COUNT(*) as count FROM work_items WHERE parent_id = ? AND type = 'chore'
135
+ `).get(activeChore.parent_id).count;
136
+
137
+ // Check for test worktree on the feature
138
+ const featureTestWorktree = db.prepare(`
139
+ SELECT worktree_path, status FROM worktrees
140
+ WHERE work_item_id = ? AND worktree_path LIKE '%tests-%' AND status = 'active'
141
+ `).get(activeChore.parent_id);
142
+
143
+ db.close();
144
+ return {
145
+ id: activeChore.parent_id,
146
+ phase: activeChore.phase || 'discovery',
147
+ scenarioFile: activeChore.scenario_file,
148
+ choreCount,
149
+ testWorktree: featureTestWorktree ? {
150
+ path: featureTestWorktree.worktree_path,
151
+ status: featureTestWorktree.status
152
+ } : null
153
+ };
154
+ }
155
+
156
+ db.close();
157
+ return null;
158
+ } catch (err) {
159
+ // better-sqlite3 not available or error - fall back to CLI
160
+ return detectFeatureStateCLI(dbPath, cwd);
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Detect feature state using sqlite3 CLI (fallback)
166
+ */
167
+ function detectFeatureStateCLI(dbPath, cwd) {
168
+ const { spawnSync } = require('child_process');
169
+
170
+ // Check for discovery phase feature
171
+ const result = spawnSync('sqlite3', [
172
+ dbPath,
173
+ `SELECT id, phase, scenario_file FROM work_items WHERE type = 'feature' AND phase = 'discovery' AND status = 'in_progress' LIMIT 1`
174
+ ], { encoding: 'utf-8' });
175
+
176
+ if (result.stdout && result.stdout.trim()) {
177
+ const [id, phase, scenarioFile] = result.stdout.trim().split('|');
178
+ return {
179
+ id: parseInt(id),
180
+ phase: phase || 'discovery',
181
+ scenarioFile: scenarioFile || null,
182
+ choreCount: 0,
183
+ testWorktree: null
184
+ };
185
+ }
186
+
187
+ return null;
188
+ }
189
+
190
+ /**
191
+ * Find the jettypod database path
192
+ */
193
+ function findDatabasePath(cwd) {
194
+ let dir = cwd || process.cwd();
195
+ while (dir !== path.dirname(dir)) {
196
+ const dbPath = path.join(dir, '.jettypod', 'work.db');
197
+ if (fs.existsSync(dbPath)) {
198
+ return dbPath;
199
+ }
200
+ dir = path.dirname(dir);
201
+ }
202
+ return null;
203
+ }
204
+
205
+ /**
206
+ * Infer current workflow step from feature state
207
+ */
208
+ function inferStep(state) {
209
+ if (!state) return { step: 'unknown', reason: 'Feature not found' };
210
+
211
+ // Discovery phase: Steps 1-7
212
+ if (state.phase === 'discovery') {
213
+ return { step: 'discovery', reason: 'Phase is discovery' };
214
+ }
215
+
216
+ // Implementation phase: Steps 8+
217
+ if (state.phase === 'implementation') {
218
+ // Step 8D: Test worktree active - writing tests
219
+ if (state.testWorktree?.status === 'active') {
220
+ return {
221
+ step: 'test-writing',
222
+ reason: 'Test worktree active',
223
+ worktreePath: state.testWorktree.path
224
+ };
225
+ }
226
+
227
+ // Step 8E+: Tests merged (scenario_file set), ready for chore implementation
228
+ if (state.scenarioFile) {
229
+ return { step: 'ready-for-chores', reason: 'Tests merged, implementation can begin' };
230
+ }
231
+
232
+ // Step 8C done: Chores exist, need to create test worktree
233
+ if (state.choreCount > 0) {
234
+ return { step: 'pre-test-worktree', reason: 'Chores exist, waiting for test worktree' };
235
+ }
236
+
237
+ return { step: 'implementation-no-chores', reason: 'Implementation phase but no chores' };
238
+ }
239
+
240
+ return { step: 'unknown', reason: `Unknown phase: ${state.phase}` };
241
+ }
242
+
243
+ /**
244
+ * Validate a tool call against current feature-planning state
245
+ */
246
+ function validateToolCall(state, toolName, toolInput, cwd) {
247
+ const stepInfo = inferStep(state);
248
+ const featureId = state.id;
249
+
250
+ // Fail open for unknown/invalid states - allow action if we can't determine state
251
+ if (stepInfo.step === 'unknown') {
252
+ return { allowed: true };
253
+ }
254
+
255
+ // === WRITE/EDIT OPERATIONS ===
256
+ if (toolName === 'Write' || toolName === 'Edit') {
257
+ const filePath = toolInput.file_path || '';
258
+
259
+ // Check if this is a BDD file write (features/*.feature or features/**/*.steps.js)
260
+ const isBddFile = /features\/.*\.(feature|steps\.js)$/.test(filePath) ||
261
+ filePath.includes('features/') && (filePath.endsWith('.feature') || filePath.includes('.steps.'));
262
+
263
+ if (isBddFile) {
264
+ // During discovery: block all BDD writes
265
+ if (stepInfo.step === 'discovery') {
266
+ return {
267
+ allowed: false,
268
+ message: 'Cannot write BDD files during discovery phase',
269
+ hint: `Complete discovery first, then use: jettypod work tests start ${featureId}`
270
+ };
271
+ }
272
+
273
+ // During test-writing: only allow writes TO the worktree
274
+ if (stepInfo.step === 'test-writing') {
275
+ const worktreePath = stepInfo.worktreePath;
276
+ // Extract the worktree directory name (e.g., ".jettypod-work/tests-100-login")
277
+ const worktreeDirMatch = worktreePath.match(/\.jettypod-work\/tests-\d+-[^/]+/);
278
+ const worktreeDir = worktreeDirMatch ? worktreeDirMatch[0] : null;
279
+
280
+ // Check if file path is inside the worktree (handles both absolute and relative paths)
281
+ const isInWorktree = filePath.includes(worktreePath) ||
282
+ filePath.startsWith(worktreePath) ||
283
+ (worktreeDir && filePath.includes(worktreeDir));
284
+
285
+ if (!isInWorktree) {
286
+ return {
287
+ allowed: false,
288
+ message: 'BDD files must be written to the test worktree',
289
+ hint: `Write to: ${worktreePath}/features/`
290
+ };
291
+ }
292
+ // Writing to worktree is allowed
293
+ return { allowed: true };
294
+ }
295
+
296
+ // Pre-test-worktree or ready-for-chores: block direct writes
297
+ if (stepInfo.step === 'pre-test-worktree') {
298
+ return {
299
+ allowed: false,
300
+ message: 'Cannot write BDD files - create test worktree first',
301
+ hint: `Use: jettypod work tests start ${featureId}`
302
+ };
303
+ }
304
+
305
+ // Even in ready-for-chores, direct writes to features/ should go through worktree
306
+ return {
307
+ allowed: false,
308
+ message: 'BDD file writes should use a test worktree',
309
+ hint: `Use: jettypod work tests start ${featureId}`
310
+ };
311
+ }
312
+
313
+ // During discovery: allow prototype writes, block others
314
+ if (stepInfo.step === 'discovery') {
315
+ if (filePath.includes('prototypes/') || filePath.startsWith('prototypes/')) {
316
+ return { allowed: true };
317
+ }
318
+ // Allow other non-BDD writes during discovery (like CLAUDE.md updates)
319
+ return { allowed: true };
320
+ }
321
+ }
322
+
323
+ // === BASH COMMANDS ===
324
+ if (toolName === 'Bash') {
325
+ const command = toolInput.command || '';
326
+
327
+ // Block chore creation during discovery
328
+ if (/jettypod\s+work\s+create\s+chore/.test(command)) {
329
+ if (stepInfo.step === 'discovery') {
330
+ return {
331
+ allowed: false,
332
+ message: 'Cannot create chores during discovery phase',
333
+ hint: 'Get user confirmation on proposed chores first'
334
+ };
335
+ }
336
+ }
337
+
338
+ // Block work start in certain states
339
+ if (/jettypod\s+work\s+start\s+\d+/.test(command)) {
340
+ // Allow test worktree creation (work tests start)
341
+ if (/jettypod\s+work\s+tests\s+start/.test(command)) {
342
+ return { allowed: true };
343
+ }
344
+
345
+ // Block if test worktree is active (need to merge first)
346
+ if (stepInfo.step === 'test-writing') {
347
+ return {
348
+ allowed: false,
349
+ message: 'Cannot start chore while test worktree is active',
350
+ hint: `Merge tests first: jettypod work tests merge ${featureId}`
351
+ };
352
+ }
353
+
354
+ // Block if pre-test-worktree (need to write tests first)
355
+ if (stepInfo.step === 'pre-test-worktree') {
356
+ return {
357
+ allowed: false,
358
+ message: 'Cannot start chore before tests are written',
359
+ hint: `Create test worktree first: jettypod work tests start ${featureId}`
360
+ };
361
+ }
362
+ }
363
+
364
+ // Allow test worktree commands
365
+ if (/jettypod\s+work\s+tests\s+(start|merge)/.test(command)) {
366
+ return { allowed: true };
367
+ }
368
+ }
369
+
370
+ // Default: allow
371
+ return { allowed: true };
372
+ }
373
+
374
+ /**
375
+ * Allow the action
376
+ */
377
+ function allow() {
378
+ console.log(JSON.stringify({
379
+ hookSpecificOutput: {
380
+ hookEventName: "PreToolUse",
381
+ permissionDecision: "allow"
382
+ }
383
+ }));
384
+ process.exit(0);
385
+ }
386
+
387
+ /**
388
+ * Deny the action with explanation
389
+ */
390
+ function deny(message, hint) {
391
+ const reason = `❌ ${message}\n\n💡 Hint: ${hint}`;
392
+
393
+ console.log(JSON.stringify({
394
+ hookSpecificOutput: {
395
+ hookEventName: "PreToolUse",
396
+ permissionDecision: "deny",
397
+ permissionDecisionReason: reason
398
+ }
399
+ }));
400
+ process.exit(0);
401
+ }
@@ -24,6 +24,27 @@ function debug(category, data) {
24
24
  console.error(`[guardrails] ${JSON.stringify(logEntry)}`);
25
25
  }
26
26
 
27
+ /**
28
+ * Get the current git ref
29
+ */
30
+ function getCurrentRef(cwd) {
31
+ try {
32
+ const { spawnSync } = require('child_process');
33
+ const result = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
34
+ cwd: cwd || process.cwd(),
35
+ encoding: 'utf-8',
36
+ timeout: 5000
37
+ });
38
+ if (result.status === 0) {
39
+ return result.stdout.trim();
40
+ }
41
+ } catch (err) {
42
+ debug('git_ref', { error: err.message });
43
+ }
44
+ return null;
45
+ }
46
+
47
+
27
48
  // Read hook input from stdin
28
49
  let input = '';
29
50
  process.stdin.on('data', chunk => input += chunk);
@@ -83,12 +104,24 @@ function evaluateRequest(hookInput) {
83
104
  return { allowed: true };
84
105
  }
85
106
 
107
+ /**
108
+ * Strip heredoc content from command to avoid false positives.
109
+ * Heredocs contain user text that shouldn't trigger guardrails.
110
+ */
111
+ function stripHeredocContent(command) {
112
+ // Match heredoc: <<'DELIM' or <<DELIM or <<"DELIM" ... DELIM
113
+ return command.replace(/<<-?['"]?(\w+)['"]?[\s\S]*?\n\1\b/g, '<<HEREDOC_STRIPPED');
114
+ }
115
+
86
116
  /**
87
117
  * Evaluate Bash command against global rules
88
118
  */
89
- function evaluateBashCommand(command, currentBranch, cwd) {
119
+ function evaluateBashCommand(command, inputRef, cwd) {
120
+ const currentBranch = inputRef || getCurrentRef(cwd);
121
+ // Strip heredoc content to avoid false positives on user-provided text
122
+ const strippedCommand = stripHeredocContent(command);
90
123
  // BLOCKED: Force push
91
- if (/git\s+push\s+.*--force/.test(command) || /git\s+push\s+-f\b/.test(command)) {
124
+ if (/git\s+push\s+.*--force/.test(strippedCommand) || /git\s+push\s+-f\b/.test(strippedCommand)) {
92
125
  return {
93
126
  allowed: false,
94
127
  message: 'Force push is blocked',
@@ -96,8 +129,17 @@ function evaluateBashCommand(command, currentBranch, cwd) {
96
129
  };
97
130
  }
98
131
 
132
+ // BLOCKED: Hard reset (destructive)
133
+ if (/git\s+reset\s+--hard\b/.test(strippedCommand)) {
134
+ return {
135
+ allowed: false,
136
+ message: 'Hard reset is blocked',
137
+ hint: 'Hard reset destroys uncommitted work. Use git stash or git reset --soft instead.'
138
+ };
139
+ }
140
+
99
141
  // BLOCKED: Direct commit to main
100
- if (/git\s+commit\b/.test(command) && currentBranch === 'main') {
142
+ if (/git\s+commit\b/.test(strippedCommand) && currentBranch === 'main') {
101
143
  return {
102
144
  allowed: false,
103
145
  message: 'Direct commits to main are blocked',
@@ -106,7 +148,7 @@ function evaluateBashCommand(command, currentBranch, cwd) {
106
148
  }
107
149
 
108
150
  // BLOCKED: Manual worktree creation
109
- if (/git\s+worktree\s+add\b/.test(command)) {
151
+ if (/git\s+worktree\s+add\b/.test(strippedCommand)) {
110
152
  return {
111
153
  allowed: false,
112
154
  message: 'Manual worktree creation is blocked',
@@ -115,7 +157,7 @@ function evaluateBashCommand(command, currentBranch, cwd) {
115
157
  }
116
158
 
117
159
  // BLOCKED: Manual branch creation
118
- if (/git\s+checkout\s+-b\b/.test(command) || /git\s+branch\s+(?!-d|-D)/.test(command)) {
160
+ if (/git\s+checkout\s+-b\b/.test(strippedCommand) || /git\s+branch\s+(?!-d|-D)/.test(strippedCommand)) {
119
161
  return {
120
162
  allowed: false,
121
163
  message: 'Manual branch creation is blocked',
@@ -124,7 +166,7 @@ function evaluateBashCommand(command, currentBranch, cwd) {
124
166
  }
125
167
 
126
168
  // BLOCKED: Direct SQL mutation to work.db
127
- if (/sqlite3\s+.*work\.db/.test(command)) {
169
+ if (/sqlite3\s+.*work\.db/.test(strippedCommand)) {
128
170
  const sqlCommand = command.toLowerCase();
129
171
  // Allow SELECT, block mutations
130
172
  if (/\b(update|insert|delete|drop|alter|create)\b/i.test(sqlCommand)) {
@@ -136,25 +178,14 @@ function evaluateBashCommand(command, currentBranch, cwd) {
136
178
  }
137
179
  }
138
180
 
139
- // BLOCKED: Merge from inside worktree (causes CWD corruption)
140
- // Matches: jettypod work merge, jettypod work tests merge
141
- if (/jettypod\s+work\s+(tests\s+)?merge/.test(command)) {
142
- if (cwd && /\.jettypod-work\//.test(cwd)) {
143
- return {
144
- allowed: false,
145
- message: 'Cannot merge from inside a worktree - your shell CWD would be deleted, corrupting the session.',
146
- hint: 'First run: cd <main-repo-path>\nThen retry the merge command.'
147
- };
148
- }
149
- }
150
181
 
151
182
  // ALLOWED: Git read-only commands
152
- if (/git\s+(status|log|diff|show|branch\s*$|remote|fetch)\b/.test(command)) {
183
+ if (/git\s+(status|log|diff|show|branch\s*$|remote|fetch)\b/.test(strippedCommand)) {
153
184
  return { allowed: true };
154
185
  }
155
186
 
156
187
  // ALLOWED: Jettypod read-only commands
157
- if (/jettypod\s+(backlog|status|work\s+status|impact)\b/.test(command)) {
188
+ if (/jettypod\s+(backlog|status|work\s+status|impact)\b/.test(strippedCommand)) {
158
189
  return { allowed: true };
159
190
  }
160
191
 
@@ -1171,19 +1171,12 @@ async function mergeWork(options = {}) {
1171
1171
  }
1172
1172
  }
1173
1173
 
1174
- // CRITICAL: Refuse to run from inside a worktree
1175
- // Running merge from inside the worktree that will be deleted poisons the shell session
1174
+ // Auto-chdir to main repo if running from inside a worktree
1176
1175
  const cwd = process.cwd();
1177
1176
  if (cwd.includes('.jettypod-work')) {
1178
1177
  const mainRepo = getGitRoot();
1179
- console.error(' Cannot merge from inside a worktree.');
1180
- console.error('');
1181
- console.error(' The merge will delete this worktree, breaking your shell session.');
1182
- console.error('');
1183
- console.error(' Run from the main repository instead:');
1184
- console.error(` cd ${mainRepo}`);
1185
- console.error(' jettypod work merge');
1186
- return Promise.reject(new Error('Cannot merge from inside a worktree'));
1178
+ console.log('📂 Changing to main repository for merge...');
1179
+ process.chdir(mainRepo);
1187
1180
  }
1188
1181
 
1189
1182
  // Get current work - either from explicit ID or branch detection
@@ -1735,18 +1728,12 @@ async function testsMerge(featureId) {
1735
1728
  return Promise.reject(new Error('Invalid feature ID'));
1736
1729
  }
1737
1730
 
1738
- // CRITICAL: Refuse to run from inside a worktree
1731
+ // Auto-chdir to main repo if running from inside a worktree
1739
1732
  const cwd = process.cwd();
1740
1733
  if (cwd.includes('.jettypod-work')) {
1741
1734
  const mainRepo = getGitRoot();
1742
- console.error(' Cannot merge from inside a worktree.');
1743
- console.error('');
1744
- console.error(' The merge will delete this worktree, breaking your shell session.');
1745
- console.error('');
1746
- console.error(' Run from the main repository instead:');
1747
- console.error(` cd ${mainRepo}`);
1748
- console.error(` jettypod work tests merge ${featureId}`);
1749
- return Promise.reject(new Error('Cannot merge from inside a worktree'));
1735
+ console.log('📂 Changing to main repository for merge...');
1736
+ process.chdir(mainRepo);
1750
1737
  }
1751
1738
 
1752
1739
  const db = getDb();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jettypod",
3
- "version": "4.4.61",
3
+ "version": "4.4.62",
4
4
  "description": "AI-powered development workflow manager with TDD, BDD, and automatic test generation",
5
5
  "main": "jettypod.js",
6
6
  "bin": {