jettypod 4.4.60 → 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,
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
//
|
|
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.
|
|
1180
|
-
|
|
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
|
-
//
|
|
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.
|
|
1743
|
-
|
|
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();
|