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
package/apps/dashboard/lib/db.ts
CHANGED
|
@@ -277,7 +277,7 @@ export function getKanbanData(doneLimit: number = 50): KanbanData {
|
|
|
277
277
|
const epicId = cleanItem.parent_id || cleanItem.epic_id;
|
|
278
278
|
const epicTitle = epicId ? epicMap.get(epicId) || null : null;
|
|
279
279
|
inFlight.push({ ...cleanItem, epicTitle });
|
|
280
|
-
} else if (cleanItem.status === 'backlog' || cleanItem.status === 'todo') {
|
|
280
|
+
} else if (cleanItem.status === 'backlog' || cleanItem.status === 'todo' || cleanItem.status === null) {
|
|
281
281
|
const group = getGroup(backlogGroups, cleanItem);
|
|
282
282
|
group.items.push(cleanItem);
|
|
283
283
|
} else if (cleanItem.status === 'done') {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* WebSocket Server for Real-time Dashboard Updates
|
|
4
|
+
*
|
|
5
|
+
* Watches the JettyPod work.db file for changes and broadcasts
|
|
6
|
+
* update messages to all connected dashboard clients.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { WebSocketServer } = require('ws');
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
|
|
13
|
+
// Configuration
|
|
14
|
+
const PORT = process.env.WS_PORT || 8080;
|
|
15
|
+
const DB_PATH = process.env.JETTYPOD_DB_PATH || path.join(process.cwd(), '.jettypod', 'work.db');
|
|
16
|
+
|
|
17
|
+
// Track connected clients
|
|
18
|
+
const clients = new Set();
|
|
19
|
+
|
|
20
|
+
// Create WebSocket server
|
|
21
|
+
const wss = new WebSocketServer({ port: PORT });
|
|
22
|
+
|
|
23
|
+
console.log(`WebSocket server starting on port ${PORT}`);
|
|
24
|
+
console.log(`Watching database: ${DB_PATH}`);
|
|
25
|
+
|
|
26
|
+
// Handle new connections
|
|
27
|
+
wss.on('connection', (ws) => {
|
|
28
|
+
clients.add(ws);
|
|
29
|
+
console.log(`Client connected. Total clients: ${clients.size}`);
|
|
30
|
+
|
|
31
|
+
// Send initial connection confirmation
|
|
32
|
+
ws.send(JSON.stringify({ type: 'connected', timestamp: Date.now() }));
|
|
33
|
+
|
|
34
|
+
// Handle client disconnect
|
|
35
|
+
ws.on('close', () => {
|
|
36
|
+
clients.delete(ws);
|
|
37
|
+
console.log(`Client disconnected. Total clients: ${clients.size}`);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Handle errors
|
|
41
|
+
ws.on('error', (error) => {
|
|
42
|
+
console.error('WebSocket error:', error.message);
|
|
43
|
+
clients.delete(ws);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Broadcast message to all connected clients
|
|
48
|
+
function broadcast(message) {
|
|
49
|
+
const payload = JSON.stringify(message);
|
|
50
|
+
for (const client of clients) {
|
|
51
|
+
if (client.readyState === 1) { // WebSocket.OPEN
|
|
52
|
+
client.send(payload);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Watch database file for changes
|
|
58
|
+
let debounceTimer = null;
|
|
59
|
+
const DEBOUNCE_MS = 100; // Debounce rapid changes
|
|
60
|
+
|
|
61
|
+
function watchDatabase() {
|
|
62
|
+
// Check if database exists
|
|
63
|
+
if (!fs.existsSync(DB_PATH)) {
|
|
64
|
+
console.log(`Database not found at ${DB_PATH}, waiting...`);
|
|
65
|
+
// Retry in 5 seconds
|
|
66
|
+
setTimeout(watchDatabase, 5000);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
console.log(`Watching database for changes...`);
|
|
71
|
+
|
|
72
|
+
fs.watch(DB_PATH, (eventType, filename) => {
|
|
73
|
+
// Debounce to handle rapid SQLite writes
|
|
74
|
+
if (debounceTimer) {
|
|
75
|
+
clearTimeout(debounceTimer);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
debounceTimer = setTimeout(() => {
|
|
79
|
+
console.log(`Database changed: ${eventType}`);
|
|
80
|
+
broadcast({
|
|
81
|
+
type: 'db_change',
|
|
82
|
+
event: eventType,
|
|
83
|
+
timestamp: Date.now()
|
|
84
|
+
});
|
|
85
|
+
}, DEBOUNCE_MS);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Start watching
|
|
90
|
+
watchDatabase();
|
|
91
|
+
|
|
92
|
+
// Handle server shutdown gracefully
|
|
93
|
+
process.on('SIGINT', () => {
|
|
94
|
+
console.log('\nShutting down WebSocket server...');
|
|
95
|
+
wss.close(() => {
|
|
96
|
+
console.log('Server closed');
|
|
97
|
+
process.exit(0);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
console.log(`WebSocket server running on ws://localhost:${PORT}`);
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Chore Planning Guardrails Hook
|
|
4
|
+
*
|
|
5
|
+
* Blocks work start for standalone chores until:
|
|
6
|
+
* 1. A chore-planning workflow exists for the chore
|
|
7
|
+
* 2. The workflow has completed (type classified, guidance loaded)
|
|
8
|
+
*
|
|
9
|
+
* This ensures type classification and guidance loading is done before starting work.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
// Read hook input from stdin
|
|
16
|
+
let input = '';
|
|
17
|
+
process.stdin.on('data', chunk => input += chunk);
|
|
18
|
+
process.stdin.on('end', async () => {
|
|
19
|
+
try {
|
|
20
|
+
const hookInput = JSON.parse(input);
|
|
21
|
+
const { tool_name, tool_input, cwd } = hookInput;
|
|
22
|
+
|
|
23
|
+
// Only check Bash commands
|
|
24
|
+
if (tool_name !== 'Bash') {
|
|
25
|
+
allow();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const command = tool_input.command || '';
|
|
30
|
+
|
|
31
|
+
// Only check jettypod work start commands
|
|
32
|
+
if (!/jettypod\s+work\s+start/.test(command)) {
|
|
33
|
+
allow();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Extract work item ID from command
|
|
38
|
+
const idMatch = command.match(/jettypod\s+work\s+start\s+(\d+)/);
|
|
39
|
+
if (!idMatch) {
|
|
40
|
+
// No ID specified, allow
|
|
41
|
+
allow();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const workItemId = parseInt(idMatch[1]);
|
|
46
|
+
const result = await checkChorePlanningGuardrail(workItemId, cwd);
|
|
47
|
+
|
|
48
|
+
if (result.allowed) {
|
|
49
|
+
allow();
|
|
50
|
+
} else {
|
|
51
|
+
deny(result.message, result.hint);
|
|
52
|
+
}
|
|
53
|
+
} catch (err) {
|
|
54
|
+
// Fail open on errors
|
|
55
|
+
allow();
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Check if this work start should be blocked
|
|
61
|
+
*/
|
|
62
|
+
async function checkChorePlanningGuardrail(workItemId, cwd) {
|
|
63
|
+
const dbPath = findDatabasePath(cwd);
|
|
64
|
+
if (!dbPath) {
|
|
65
|
+
return { allowed: true };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const sqlite3 = require('better-sqlite3');
|
|
70
|
+
const db = sqlite3(dbPath, { readonly: true });
|
|
71
|
+
|
|
72
|
+
// Get the work item
|
|
73
|
+
const workItem = db.prepare(`
|
|
74
|
+
SELECT id, type, parent_id, title FROM work_items WHERE id = ?
|
|
75
|
+
`).get(workItemId);
|
|
76
|
+
|
|
77
|
+
if (!workItem) {
|
|
78
|
+
db.close();
|
|
79
|
+
return { allowed: true };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Only check standalone chores (chores with no parent)
|
|
83
|
+
if (workItem.type !== 'chore') {
|
|
84
|
+
db.close();
|
|
85
|
+
return { allowed: true };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// If chore has a parent, it's not standalone - allow
|
|
89
|
+
if (workItem.parent_id) {
|
|
90
|
+
db.close();
|
|
91
|
+
return { allowed: true };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// This is a standalone chore - check for chore-planning workflow
|
|
95
|
+
const workflow = db.prepare(`
|
|
96
|
+
SELECT id, status, step_reached
|
|
97
|
+
FROM skill_executions
|
|
98
|
+
WHERE work_item_id = ? AND skill_name = 'chore-planning'
|
|
99
|
+
ORDER BY id DESC
|
|
100
|
+
LIMIT 1
|
|
101
|
+
`).get(workItemId);
|
|
102
|
+
|
|
103
|
+
db.close();
|
|
104
|
+
|
|
105
|
+
// If no workflow exists, block
|
|
106
|
+
if (!workflow) {
|
|
107
|
+
return {
|
|
108
|
+
allowed: false,
|
|
109
|
+
message: 'Cannot start standalone chore: type must be classified first',
|
|
110
|
+
hint: 'Use the chore-planning skill to classify the chore type and load guidance.'
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// If workflow is not completed, block
|
|
115
|
+
if (workflow.status !== 'completed') {
|
|
116
|
+
return {
|
|
117
|
+
allowed: false,
|
|
118
|
+
message: 'Cannot start standalone chore: type must be classified first',
|
|
119
|
+
hint: 'Complete the chore-planning skill to classify the chore type.'
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Workflow completed, allow
|
|
124
|
+
return { allowed: true };
|
|
125
|
+
|
|
126
|
+
} catch (err) {
|
|
127
|
+
// Fail open
|
|
128
|
+
return { allowed: true };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Find the jettypod database path
|
|
134
|
+
*/
|
|
135
|
+
function findDatabasePath(cwd) {
|
|
136
|
+
let dir = cwd || process.cwd();
|
|
137
|
+
while (dir !== path.dirname(dir)) {
|
|
138
|
+
const dbPath = path.join(dir, '.jettypod', 'work.db');
|
|
139
|
+
if (fs.existsSync(dbPath)) {
|
|
140
|
+
return dbPath;
|
|
141
|
+
}
|
|
142
|
+
dir = path.dirname(dir);
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Allow the action
|
|
149
|
+
*/
|
|
150
|
+
function allow() {
|
|
151
|
+
console.log(JSON.stringify({
|
|
152
|
+
hookSpecificOutput: {
|
|
153
|
+
hookEventName: "PreToolUse",
|
|
154
|
+
permissionDecision: "allow"
|
|
155
|
+
}
|
|
156
|
+
}));
|
|
157
|
+
process.exit(0);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Deny the action with explanation
|
|
162
|
+
*/
|
|
163
|
+
function deny(message, hint) {
|
|
164
|
+
const reason = `ā ${message}\n\nš” Hint: ${hint}`;
|
|
165
|
+
|
|
166
|
+
console.log(JSON.stringify({
|
|
167
|
+
hookSpecificOutput: {
|
|
168
|
+
hookEventName: "PreToolUse",
|
|
169
|
+
permissionDecision: "deny",
|
|
170
|
+
permissionDecisionReason: reason
|
|
171
|
+
}
|
|
172
|
+
}));
|
|
173
|
+
process.exit(0);
|
|
174
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Epic Planning Guardrails Hook
|
|
4
|
+
*
|
|
5
|
+
* Blocks feature creation under an epic until:
|
|
6
|
+
* 1. An epic-planning workflow exists for the epic
|
|
7
|
+
* 2. The workflow has reached step 6 (feature creation step)
|
|
8
|
+
*
|
|
9
|
+
* This ensures feature brainstorming is completed before creating features.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
// Read hook input from stdin
|
|
16
|
+
let input = '';
|
|
17
|
+
process.stdin.on('data', chunk => input += chunk);
|
|
18
|
+
process.stdin.on('end', async () => {
|
|
19
|
+
try {
|
|
20
|
+
const hookInput = JSON.parse(input);
|
|
21
|
+
const { tool_name, tool_input, cwd } = hookInput;
|
|
22
|
+
|
|
23
|
+
// Only check Bash commands
|
|
24
|
+
if (tool_name !== 'Bash') {
|
|
25
|
+
allow();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const command = tool_input.command || '';
|
|
30
|
+
|
|
31
|
+
// Only check jettypod work create feature commands with --parent
|
|
32
|
+
if (!/jettypod\s+work\s+create\s+feature/.test(command)) {
|
|
33
|
+
allow();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Extract parent ID from command
|
|
38
|
+
const parentMatch = command.match(/--parent[=\s]+(\d+)/);
|
|
39
|
+
if (!parentMatch) {
|
|
40
|
+
// No parent specified - standalone feature, allow
|
|
41
|
+
allow();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const parentId = parseInt(parentMatch[1]);
|
|
46
|
+
const result = await checkEpicPlanningGuardrail(parentId, cwd);
|
|
47
|
+
|
|
48
|
+
if (result.allowed) {
|
|
49
|
+
allow();
|
|
50
|
+
} else {
|
|
51
|
+
deny(result.message, result.hint);
|
|
52
|
+
}
|
|
53
|
+
} catch (err) {
|
|
54
|
+
// Fail open on errors
|
|
55
|
+
allow();
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Check if this feature creation should be blocked
|
|
61
|
+
*/
|
|
62
|
+
async function checkEpicPlanningGuardrail(parentId, cwd) {
|
|
63
|
+
const dbPath = findDatabasePath(cwd);
|
|
64
|
+
if (!dbPath) {
|
|
65
|
+
return { allowed: true };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const sqlite3 = require('better-sqlite3');
|
|
70
|
+
const db = sqlite3(dbPath, { readonly: true });
|
|
71
|
+
|
|
72
|
+
// Check if parent is an epic
|
|
73
|
+
const parent = db.prepare(`
|
|
74
|
+
SELECT id, type FROM work_items WHERE id = ?
|
|
75
|
+
`).get(parentId);
|
|
76
|
+
|
|
77
|
+
if (!parent) {
|
|
78
|
+
db.close();
|
|
79
|
+
return { allowed: true };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// If parent isn't an epic, allow (chores can be created under features without epic-planning)
|
|
83
|
+
if (parent.type !== 'epic') {
|
|
84
|
+
db.close();
|
|
85
|
+
return { allowed: true };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Parent is an epic - check for epic-planning workflow
|
|
89
|
+
const workflow = db.prepare(`
|
|
90
|
+
SELECT id, status, step_reached
|
|
91
|
+
FROM skill_executions
|
|
92
|
+
WHERE work_item_id = ? AND skill_name = 'epic-planning'
|
|
93
|
+
ORDER BY id DESC
|
|
94
|
+
LIMIT 1
|
|
95
|
+
`).get(parentId);
|
|
96
|
+
|
|
97
|
+
db.close();
|
|
98
|
+
|
|
99
|
+
// If no workflow exists, block
|
|
100
|
+
if (!workflow) {
|
|
101
|
+
return {
|
|
102
|
+
allowed: false,
|
|
103
|
+
message: 'Cannot create feature: Epic brainstorm must be confirmed first',
|
|
104
|
+
hint: 'Use the epic-planning skill to brainstorm and confirm features before creating them.'
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// If workflow is completed, allow
|
|
109
|
+
if (workflow.status === 'completed') {
|
|
110
|
+
return { allowed: true };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// If workflow hasn't reached step 6 (Create Features), block
|
|
114
|
+
// Step 3: Brainstorm Features
|
|
115
|
+
// Step 4: Architectural Decision
|
|
116
|
+
// Step 5: Build Prototypes
|
|
117
|
+
// Step 6: Create Features (user confirmed)
|
|
118
|
+
if (workflow.step_reached < 6) {
|
|
119
|
+
return {
|
|
120
|
+
allowed: false,
|
|
121
|
+
message: 'Cannot create feature: Epic brainstorm must be confirmed first',
|
|
122
|
+
hint: 'Continue epic-planning to confirm the brainstormed features before creating them.'
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Workflow is at step 6 or beyond, allow
|
|
127
|
+
return { allowed: true };
|
|
128
|
+
|
|
129
|
+
} catch (err) {
|
|
130
|
+
// Fail open
|
|
131
|
+
return { allowed: true };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Find the jettypod database path
|
|
137
|
+
*/
|
|
138
|
+
function findDatabasePath(cwd) {
|
|
139
|
+
let dir = cwd || process.cwd();
|
|
140
|
+
while (dir !== path.dirname(dir)) {
|
|
141
|
+
const dbPath = path.join(dir, '.jettypod', 'work.db');
|
|
142
|
+
if (fs.existsSync(dbPath)) {
|
|
143
|
+
return dbPath;
|
|
144
|
+
}
|
|
145
|
+
dir = path.dirname(dir);
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Allow the action
|
|
152
|
+
*/
|
|
153
|
+
function allow() {
|
|
154
|
+
console.log(JSON.stringify({
|
|
155
|
+
hookSpecificOutput: {
|
|
156
|
+
hookEventName: "PreToolUse",
|
|
157
|
+
permissionDecision: "allow"
|
|
158
|
+
}
|
|
159
|
+
}));
|
|
160
|
+
process.exit(0);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Deny the action with explanation
|
|
165
|
+
*/
|
|
166
|
+
function deny(message, hint) {
|
|
167
|
+
const reason = `ā ${message}\n\nš” Hint: ${hint}`;
|
|
168
|
+
|
|
169
|
+
console.log(JSON.stringify({
|
|
170
|
+
hookSpecificOutput: {
|
|
171
|
+
hookEventName: "PreToolUse",
|
|
172
|
+
permissionDecision: "deny",
|
|
173
|
+
permissionDecisionReason: reason
|
|
174
|
+
}
|
|
175
|
+
}));
|
|
176
|
+
process.exit(0);
|
|
177
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* External Transition Guardrails Hook
|
|
4
|
+
*
|
|
5
|
+
* Blocks production deploy commands until:
|
|
6
|
+
* 1. Project is in external state (or allow if internal)
|
|
7
|
+
* 2. All infrastructure chores from the Infrastructure Readiness epic are done
|
|
8
|
+
*
|
|
9
|
+
* This ensures infrastructure work is completed before production deployment.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
|
|
15
|
+
// Production deploy patterns - commands that deploy to production
|
|
16
|
+
const PROD_DEPLOY_PATTERNS = [
|
|
17
|
+
/vercel\s+deploy\s+--prod/,
|
|
18
|
+
/vercel\s+--prod/,
|
|
19
|
+
/railway\s+up\s+.*production/,
|
|
20
|
+
/railway\s+up\s+.*--environment\s+production/,
|
|
21
|
+
/fly\s+deploy\s+--app\s+.*-prod/,
|
|
22
|
+
/gcloud\s+app\s+deploy\s+.*--promote/,
|
|
23
|
+
/aws\s+deploy/,
|
|
24
|
+
/kubectl\s+apply.*production/,
|
|
25
|
+
/helm\s+upgrade.*production/,
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
// Read hook input from stdin
|
|
29
|
+
let input = '';
|
|
30
|
+
process.stdin.on('data', chunk => input += chunk);
|
|
31
|
+
process.stdin.on('end', async () => {
|
|
32
|
+
try {
|
|
33
|
+
const hookInput = JSON.parse(input);
|
|
34
|
+
const { tool_name, tool_input, cwd } = hookInput;
|
|
35
|
+
|
|
36
|
+
// Only check Bash commands
|
|
37
|
+
if (tool_name !== 'Bash') {
|
|
38
|
+
allow();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const command = tool_input.command || '';
|
|
43
|
+
|
|
44
|
+
// Check if this is a production deploy command
|
|
45
|
+
const isProdDeploy = PROD_DEPLOY_PATTERNS.some(pattern => pattern.test(command));
|
|
46
|
+
if (!isProdDeploy) {
|
|
47
|
+
allow();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const result = await checkExternalTransitionGuardrail(cwd);
|
|
52
|
+
|
|
53
|
+
if (result.allowed) {
|
|
54
|
+
allow();
|
|
55
|
+
} else {
|
|
56
|
+
deny(result.message, result.hint);
|
|
57
|
+
}
|
|
58
|
+
} catch (err) {
|
|
59
|
+
// Fail open on errors
|
|
60
|
+
allow();
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Check if this production deploy should be blocked
|
|
66
|
+
*/
|
|
67
|
+
async function checkExternalTransitionGuardrail(cwd) {
|
|
68
|
+
const dbPath = findDatabasePath(cwd);
|
|
69
|
+
if (!dbPath) {
|
|
70
|
+
return { allowed: true };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const sqlite3 = require('better-sqlite3');
|
|
75
|
+
const db = sqlite3(dbPath, { readonly: true });
|
|
76
|
+
|
|
77
|
+
// Check project state
|
|
78
|
+
const stateRow = db.prepare(`
|
|
79
|
+
SELECT value FROM project_config WHERE key = 'state'
|
|
80
|
+
`).get();
|
|
81
|
+
|
|
82
|
+
// If project is internal, allow (no infrastructure requirements yet)
|
|
83
|
+
if (!stateRow || stateRow.value !== 'external') {
|
|
84
|
+
db.close();
|
|
85
|
+
return { allowed: true };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Project is external - check for Infrastructure Readiness epic
|
|
89
|
+
const infraEpic = db.prepare(`
|
|
90
|
+
SELECT id FROM work_items
|
|
91
|
+
WHERE type = 'epic' AND title = 'Infrastructure Readiness'
|
|
92
|
+
LIMIT 1
|
|
93
|
+
`).get();
|
|
94
|
+
|
|
95
|
+
if (!infraEpic) {
|
|
96
|
+
// No infrastructure epic - allow (might have been created differently)
|
|
97
|
+
db.close();
|
|
98
|
+
return { allowed: true };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Check for incomplete chores under the infrastructure epic
|
|
102
|
+
const incompleteChores = db.prepare(`
|
|
103
|
+
SELECT COUNT(*) as count
|
|
104
|
+
FROM work_items
|
|
105
|
+
WHERE parent_id = ? AND type = 'chore' AND status NOT IN ('done', 'cancelled')
|
|
106
|
+
`).get(infraEpic.id);
|
|
107
|
+
|
|
108
|
+
db.close();
|
|
109
|
+
|
|
110
|
+
if (incompleteChores.count > 0) {
|
|
111
|
+
return {
|
|
112
|
+
allowed: false,
|
|
113
|
+
message: 'Cannot deploy to production: infrastructure work must be completed first',
|
|
114
|
+
hint: `Complete all infrastructure chores (${incompleteChores.count} remaining) before deploying to production.`
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// All infrastructure chores complete
|
|
119
|
+
return { allowed: true };
|
|
120
|
+
|
|
121
|
+
} catch (err) {
|
|
122
|
+
// Fail open
|
|
123
|
+
return { allowed: true };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Find the jettypod database path
|
|
129
|
+
*/
|
|
130
|
+
function findDatabasePath(cwd) {
|
|
131
|
+
let dir = cwd || process.cwd();
|
|
132
|
+
while (dir !== path.dirname(dir)) {
|
|
133
|
+
const dbPath = path.join(dir, '.jettypod', 'work.db');
|
|
134
|
+
if (fs.existsSync(dbPath)) {
|
|
135
|
+
return dbPath;
|
|
136
|
+
}
|
|
137
|
+
dir = path.dirname(dir);
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Allow the action
|
|
144
|
+
*/
|
|
145
|
+
function allow() {
|
|
146
|
+
console.log(JSON.stringify({
|
|
147
|
+
hookSpecificOutput: {
|
|
148
|
+
hookEventName: "PreToolUse",
|
|
149
|
+
permissionDecision: "allow"
|
|
150
|
+
}
|
|
151
|
+
}));
|
|
152
|
+
process.exit(0);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Deny the action with explanation
|
|
157
|
+
*/
|
|
158
|
+
function deny(message, hint) {
|
|
159
|
+
const reason = `ā ${message}\n\nš” Hint: ${hint}`;
|
|
160
|
+
|
|
161
|
+
console.log(JSON.stringify({
|
|
162
|
+
hookSpecificOutput: {
|
|
163
|
+
hookEventName: "PreToolUse",
|
|
164
|
+
permissionDecision: "deny",
|
|
165
|
+
permissionDecisionReason: reason
|
|
166
|
+
}
|
|
167
|
+
}));
|
|
168
|
+
process.exit(0);
|
|
169
|
+
}
|