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.
- package/apps/dashboard/lib/db.ts +1 -1
- package/apps/ws-server/package.json +13 -0
- package/apps/ws-server/server.js +101 -0
- package/claude-hooks/chore-planning-guardrails.js +174 -0
- package/claude-hooks/epic-planning-guardrails.js +177 -0
- package/claude-hooks/external-transition-guardrails.js +169 -0
- package/claude-hooks/global-guardrails.js +61 -30
- package/claude-hooks/production-mode-guardrails.js +262 -0
- package/claude-hooks/speed-mode-guardrails.js +163 -0
- package/claude-hooks/stable-mode-guardrails.js +216 -0
- package/jettypod.js +13 -3
- package/lib/work-commands/index.js +9 -7
- package/package.json +1 -1
|
@@ -156,8 +156,15 @@ function evaluateBashCommand(command, inputRef, cwd) {
|
|
|
156
156
|
};
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
-
// BLOCKED: Manual branch creation
|
|
160
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
250
|
-
|
|
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
|
-
//
|
|
274
|
-
|
|
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
|
+
}
|