jettypod 4.4.61 ā 4.4.63
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/feature-planning-guardrails.js +401 -0
- package/claude-hooks/global-guardrails.js +74 -28
- 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/lib/work-commands/index.js +15 -26
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Stable Mode Guardrails Hook
|
|
4
|
+
*
|
|
5
|
+
* Blocks merge of the last stable-mode chore until all BDD scenarios pass.
|
|
6
|
+
* This ensures error handling coverage is complete before completing stable mode.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { spawnSync } = require('child_process');
|
|
12
|
+
|
|
13
|
+
// Test mode: allows tests to control BDD pass/fail state
|
|
14
|
+
const TEST_MODE = process.env.STABLE_GUARDRAIL_TEST_MODE === '1';
|
|
15
|
+
const TEST_BDD_PASS = process.env.STABLE_GUARDRAIL_BDD_PASS === '1';
|
|
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 } = hookInput;
|
|
24
|
+
|
|
25
|
+
// Only check Bash commands
|
|
26
|
+
if (tool_name !== 'Bash') {
|
|
27
|
+
allow();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const command = tool_input.command || '';
|
|
32
|
+
|
|
33
|
+
// Only check jettypod work merge commands
|
|
34
|
+
if (!/jettypod\s+work\s+merge/.test(command)) {
|
|
35
|
+
allow();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Extract chore ID from command
|
|
40
|
+
const choreIdMatch = command.match(/jettypod\s+work\s+merge\s+(\d+)/);
|
|
41
|
+
if (!choreIdMatch) {
|
|
42
|
+
// No explicit ID - allow (might be merging current work)
|
|
43
|
+
allow();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const choreId = parseInt(choreIdMatch[1]);
|
|
48
|
+
const result = await checkStableModeGuardrail(choreId, cwd);
|
|
49
|
+
|
|
50
|
+
if (result.allowed) {
|
|
51
|
+
allow();
|
|
52
|
+
} else {
|
|
53
|
+
deny(result.message, result.hint);
|
|
54
|
+
}
|
|
55
|
+
} catch (err) {
|
|
56
|
+
// Fail open on errors
|
|
57
|
+
allow();
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Check if this merge should be blocked
|
|
63
|
+
*/
|
|
64
|
+
async function checkStableModeGuardrail(choreId, cwd) {
|
|
65
|
+
const dbPath = findDatabasePath(cwd);
|
|
66
|
+
if (!dbPath) {
|
|
67
|
+
return { allowed: true };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const sqlite3 = require('better-sqlite3');
|
|
72
|
+
const db = sqlite3(dbPath, { readonly: true });
|
|
73
|
+
|
|
74
|
+
// Get the chore and its parent feature
|
|
75
|
+
const chore = db.prepare(`
|
|
76
|
+
SELECT wi.id, wi.parent_id, parent.mode, parent.type as parent_type, parent.scenario_file
|
|
77
|
+
FROM work_items wi
|
|
78
|
+
LEFT JOIN work_items parent ON wi.parent_id = parent.id
|
|
79
|
+
WHERE wi.id = ?
|
|
80
|
+
`).get(choreId);
|
|
81
|
+
|
|
82
|
+
if (!chore) {
|
|
83
|
+
db.close();
|
|
84
|
+
return { allowed: true };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// If no parent or parent isn't a feature, allow
|
|
88
|
+
if (!chore.parent_id || chore.parent_type !== 'feature') {
|
|
89
|
+
db.close();
|
|
90
|
+
return { allowed: true };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// If feature mode isn't 'stable', allow
|
|
94
|
+
if (chore.mode !== 'stable') {
|
|
95
|
+
db.close();
|
|
96
|
+
return { allowed: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Feature is in stable mode - check if this is the last chore
|
|
100
|
+
const incompleteChores = db.prepare(`
|
|
101
|
+
SELECT COUNT(*) as count
|
|
102
|
+
FROM work_items
|
|
103
|
+
WHERE parent_id = ? AND type = 'chore' AND status NOT IN ('done', 'cancelled')
|
|
104
|
+
`).get(chore.parent_id);
|
|
105
|
+
|
|
106
|
+
// If there are other incomplete chores, allow this merge
|
|
107
|
+
if (incompleteChores.count > 1) {
|
|
108
|
+
db.close();
|
|
109
|
+
return { allowed: true };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// This is the last stable-mode chore - check if BDD tests pass
|
|
113
|
+
const scenarioFile = chore.scenario_file;
|
|
114
|
+
db.close();
|
|
115
|
+
|
|
116
|
+
// If no scenario file, allow (nothing to test)
|
|
117
|
+
if (!scenarioFile) {
|
|
118
|
+
return { allowed: true };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Run BDD tests to check if they pass
|
|
122
|
+
const testsPass = await runBddTests(scenarioFile, cwd);
|
|
123
|
+
|
|
124
|
+
if (testsPass) {
|
|
125
|
+
return { allowed: true };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Tests are failing - block!
|
|
129
|
+
return {
|
|
130
|
+
allowed: false,
|
|
131
|
+
message: 'Cannot merge last stable-mode chore: BDD scenarios must pass',
|
|
132
|
+
hint: 'Run tests and fix failures before merging. All error handling scenarios must pass.'
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
} catch (err) {
|
|
136
|
+
// Fail open
|
|
137
|
+
return { allowed: true };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Run BDD tests and return whether they pass
|
|
143
|
+
*/
|
|
144
|
+
async function runBddTests(scenarioFile, cwd) {
|
|
145
|
+
// In test mode, use environment variable to control pass/fail
|
|
146
|
+
if (TEST_MODE) {
|
|
147
|
+
return TEST_BDD_PASS;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const scenarioPath = path.resolve(cwd, scenarioFile);
|
|
152
|
+
|
|
153
|
+
// Check if scenario file exists
|
|
154
|
+
if (!fs.existsSync(scenarioPath)) {
|
|
155
|
+
// No scenario file = pass (nothing to test)
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Run cucumber-js with the scenario file
|
|
160
|
+
const result = spawnSync('npx', ['cucumber-js', scenarioPath, '--format', 'summary'], {
|
|
161
|
+
cwd: cwd,
|
|
162
|
+
encoding: 'utf-8',
|
|
163
|
+
timeout: 60000 // 60 second timeout
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// Check exit code - 0 means all tests passed
|
|
167
|
+
return result.status === 0;
|
|
168
|
+
} catch (err) {
|
|
169
|
+
// On error running tests, fail open (allow merge)
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Find the jettypod database path
|
|
176
|
+
*/
|
|
177
|
+
function findDatabasePath(cwd) {
|
|
178
|
+
let dir = cwd || process.cwd();
|
|
179
|
+
while (dir !== path.dirname(dir)) {
|
|
180
|
+
const dbPath = path.join(dir, '.jettypod', 'work.db');
|
|
181
|
+
if (fs.existsSync(dbPath)) {
|
|
182
|
+
return dbPath;
|
|
183
|
+
}
|
|
184
|
+
dir = path.dirname(dir);
|
|
185
|
+
}
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Allow the action
|
|
191
|
+
*/
|
|
192
|
+
function allow() {
|
|
193
|
+
console.log(JSON.stringify({
|
|
194
|
+
hookSpecificOutput: {
|
|
195
|
+
hookEventName: "PreToolUse",
|
|
196
|
+
permissionDecision: "allow"
|
|
197
|
+
}
|
|
198
|
+
}));
|
|
199
|
+
process.exit(0);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Deny the action with explanation
|
|
204
|
+
*/
|
|
205
|
+
function deny(message, hint) {
|
|
206
|
+
const reason = `ā ${message}\n\nš” Hint: ${hint}`;
|
|
207
|
+
|
|
208
|
+
console.log(JSON.stringify({
|
|
209
|
+
hookSpecificOutput: {
|
|
210
|
+
hookEventName: "PreToolUse",
|
|
211
|
+
permissionDecision: "deny",
|
|
212
|
+
permissionDecisionReason: reason
|
|
213
|
+
}
|
|
214
|
+
}));
|
|
215
|
+
process.exit(0);
|
|
216
|
+
}
|