jettypod 4.4.8 ā 4.4.10
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/claude-hooks/enforce-skill-activation.js +225 -0
- package/jettypod.js +12 -0
- package/lib/planning-status.js +68 -0
- package/package.json +1 -1
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Claude Code PreToolUse Hook
|
|
4
|
+
*
|
|
5
|
+
* Prevents bypassing the feature-planning skill by detecting direct chore
|
|
6
|
+
* creation under unplanned features.
|
|
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', () => {
|
|
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
|
+
// Check for chore creation under a parent feature
|
|
29
|
+
const parentId = extractParentFeatureId(command);
|
|
30
|
+
if (!parentId) {
|
|
31
|
+
// Not a chore creation with --parent, allow
|
|
32
|
+
allow();
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Check if feature-planning skill is active
|
|
37
|
+
// Look for skill markers in the session or transcript
|
|
38
|
+
if (isSkillActive(hookInput)) {
|
|
39
|
+
allow();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Query database to check if feature is planned
|
|
44
|
+
checkFeaturePlanned(cwd, parentId)
|
|
45
|
+
.then(isPlanned => {
|
|
46
|
+
if (isPlanned) {
|
|
47
|
+
allow();
|
|
48
|
+
} else {
|
|
49
|
+
deny(
|
|
50
|
+
'Cannot create chores under unplanned feature',
|
|
51
|
+
'Invoke the feature-planning skill first to plan this feature before creating chores.'
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
.catch(err => {
|
|
56
|
+
// On error, allow (fail open) but log
|
|
57
|
+
console.error('Hook error:', err.message);
|
|
58
|
+
allow();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
} catch (err) {
|
|
62
|
+
// If we can't parse input, allow the action (fail open)
|
|
63
|
+
console.error('Hook error:', err.message);
|
|
64
|
+
allow();
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Extract parent feature ID from chore creation command
|
|
70
|
+
* @param {string} command - The bash command
|
|
71
|
+
* @returns {number|null} The parent feature ID or null
|
|
72
|
+
*/
|
|
73
|
+
function extractParentFeatureId(command) {
|
|
74
|
+
// Match: jettypod work create chore "..." "..." --parent=123
|
|
75
|
+
// or: jettypod work create chore "..." "..." --parent 123
|
|
76
|
+
const match = command.match(/work\s+create\s+chore\s+.*--parent[=\s]+(\d+)/);
|
|
77
|
+
return match ? parseInt(match[1], 10) : null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check if feature-planning skill is currently active
|
|
82
|
+
* @param {Object} hookInput - The hook input object
|
|
83
|
+
* @returns {boolean} True if skill is active
|
|
84
|
+
*/
|
|
85
|
+
function isSkillActive(hookInput) {
|
|
86
|
+
// Check for skill activation markers
|
|
87
|
+
// The skill system may set active_skill in context
|
|
88
|
+
if (hookInput.active_skill === 'feature-planning') {
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Check transcript for recent skill activation
|
|
93
|
+
const transcriptPath = hookInput.transcript_path;
|
|
94
|
+
if (transcriptPath && fs.existsSync(transcriptPath)) {
|
|
95
|
+
try {
|
|
96
|
+
const transcript = fs.readFileSync(transcriptPath, 'utf8');
|
|
97
|
+
// Look for recent feature-planning skill activation
|
|
98
|
+
// The skill adds markers like "feature-planning skill is active"
|
|
99
|
+
const lines = transcript.split('\n').slice(-100); // Last 100 lines
|
|
100
|
+
for (const line of lines) {
|
|
101
|
+
if (line.includes('feature-planning') &&
|
|
102
|
+
(line.includes('skill is active') || line.includes('skill activated'))) {
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} catch (err) {
|
|
107
|
+
// Ignore read errors
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Check if a feature has been planned (has scenario_file and discovery_rationale)
|
|
116
|
+
* @param {string} cwd - Current working directory
|
|
117
|
+
* @param {number} featureId - The feature ID to check
|
|
118
|
+
* @returns {Promise<boolean>} True if feature is planned
|
|
119
|
+
*/
|
|
120
|
+
async function checkFeaturePlanned(cwd, featureId) {
|
|
121
|
+
// Find the database path
|
|
122
|
+
const dbPath = findDatabasePath(cwd);
|
|
123
|
+
if (!dbPath) {
|
|
124
|
+
// No database found, allow (not a jettypod project)
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Use better-sqlite3 for synchronous queries if available,
|
|
129
|
+
// otherwise fall back to spawning sqlite3 CLI
|
|
130
|
+
try {
|
|
131
|
+
const sqlite3 = require('better-sqlite3');
|
|
132
|
+
const db = sqlite3(dbPath, { readonly: true });
|
|
133
|
+
const row = db.prepare(
|
|
134
|
+
`SELECT scenario_file, discovery_rationale FROM work_items WHERE id = ? AND type = 'feature'`
|
|
135
|
+
).get(featureId);
|
|
136
|
+
db.close();
|
|
137
|
+
|
|
138
|
+
if (!row) return false;
|
|
139
|
+
const hasScenario = Boolean(row.scenario_file && row.scenario_file.trim());
|
|
140
|
+
const hasRationale = Boolean(row.discovery_rationale && row.discovery_rationale.trim());
|
|
141
|
+
return hasScenario && hasRationale;
|
|
142
|
+
} catch (err) {
|
|
143
|
+
// better-sqlite3 not available, use CLI
|
|
144
|
+
return checkFeaturePlannedCLI(dbPath, featureId);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Check if feature is planned using sqlite3 CLI
|
|
150
|
+
* @param {string} dbPath - Path to database
|
|
151
|
+
* @param {number} featureId - Feature ID
|
|
152
|
+
* @returns {Promise<boolean>} True if planned
|
|
153
|
+
*/
|
|
154
|
+
function checkFeaturePlannedCLI(dbPath, featureId) {
|
|
155
|
+
return new Promise((resolve) => {
|
|
156
|
+
const { spawnSync } = require('child_process');
|
|
157
|
+
const result = spawnSync('sqlite3', [
|
|
158
|
+
dbPath,
|
|
159
|
+
`SELECT scenario_file, discovery_rationale FROM work_items WHERE id = ${featureId} AND type = 'feature'`
|
|
160
|
+
], { encoding: 'utf-8' });
|
|
161
|
+
|
|
162
|
+
if (result.error || result.status !== 0) {
|
|
163
|
+
resolve(true); // On error, allow
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const output = result.stdout.trim();
|
|
168
|
+
if (!output) {
|
|
169
|
+
resolve(false); // Feature not found
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// SQLite output is pipe-delimited
|
|
174
|
+
const [scenarioFile, rationale] = output.split('|');
|
|
175
|
+
const hasScenario = Boolean(scenarioFile && scenarioFile.trim());
|
|
176
|
+
const hasRationale = Boolean(rationale && rationale.trim());
|
|
177
|
+
resolve(hasScenario && hasRationale);
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Find the jettypod database path
|
|
183
|
+
* @param {string} cwd - Starting directory
|
|
184
|
+
* @returns {string|null} Database path or null
|
|
185
|
+
*/
|
|
186
|
+
function findDatabasePath(cwd) {
|
|
187
|
+
let dir = cwd;
|
|
188
|
+
while (dir !== path.dirname(dir)) {
|
|
189
|
+
const dbPath = path.join(dir, '.jettypod', 'work.db');
|
|
190
|
+
if (fs.existsSync(dbPath)) {
|
|
191
|
+
return dbPath;
|
|
192
|
+
}
|
|
193
|
+
dir = path.dirname(dir);
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Allow the action
|
|
200
|
+
*/
|
|
201
|
+
function allow() {
|
|
202
|
+
console.log(JSON.stringify({
|
|
203
|
+
hookSpecificOutput: {
|
|
204
|
+
hookEventName: "PreToolUse",
|
|
205
|
+
permissionDecision: "allow"
|
|
206
|
+
}
|
|
207
|
+
}));
|
|
208
|
+
process.exit(0);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Deny the action with explanation
|
|
213
|
+
*/
|
|
214
|
+
function deny(message, suggestion) {
|
|
215
|
+
const reason = `ā ${message}\n\nš” Hint: ${suggestion}`;
|
|
216
|
+
|
|
217
|
+
console.log(JSON.stringify({
|
|
218
|
+
hookSpecificOutput: {
|
|
219
|
+
hookEventName: "PreToolUse",
|
|
220
|
+
permissionDecision: "deny",
|
|
221
|
+
permissionDecisionReason: reason
|
|
222
|
+
}
|
|
223
|
+
}));
|
|
224
|
+
process.exit(0);
|
|
225
|
+
}
|
package/jettypod.js
CHANGED
|
@@ -756,6 +756,14 @@ async function initializeProject() {
|
|
|
756
756
|
console.log('š Claude Code hook installed');
|
|
757
757
|
}
|
|
758
758
|
|
|
759
|
+
// Install enforce-skill-activation hook
|
|
760
|
+
const enforceHookSource = path.join(__dirname, 'claude-hooks', 'enforce-skill-activation.js');
|
|
761
|
+
const enforceHookDest = path.join('.jettypod', 'hooks', 'enforce-skill-activation.js');
|
|
762
|
+
if (fs.existsSync(enforceHookSource)) {
|
|
763
|
+
fs.copyFileSync(enforceHookSource, enforceHookDest);
|
|
764
|
+
fs.chmodSync(enforceHookDest, 0o755);
|
|
765
|
+
}
|
|
766
|
+
|
|
759
767
|
// Create Claude Code settings
|
|
760
768
|
if (!fs.existsSync('.claude')) {
|
|
761
769
|
fs.mkdirSync('.claude', { recursive: true });
|
|
@@ -776,6 +784,10 @@ async function initializeProject() {
|
|
|
776
784
|
{
|
|
777
785
|
matcher: 'Write',
|
|
778
786
|
hooks: [{ type: 'command', command: '.jettypod/hooks/protect-claude-md.js' }]
|
|
787
|
+
},
|
|
788
|
+
{
|
|
789
|
+
matcher: 'Bash',
|
|
790
|
+
hooks: [{ type: 'command', command: '.jettypod/hooks/enforce-skill-activation.js' }]
|
|
779
791
|
}
|
|
780
792
|
]
|
|
781
793
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Planning status helpers for checking if features have been through feature-planning
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { getDb } = require('./database');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Check if a feature has been planned (has scenario_file and discovery_rationale)
|
|
9
|
+
* @param {number} featureId - The work item ID to check
|
|
10
|
+
* @returns {Promise<boolean>} True if feature has been planned
|
|
11
|
+
*/
|
|
12
|
+
function isFeaturePlanned(featureId) {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const db = getDb();
|
|
15
|
+
|
|
16
|
+
db.get(
|
|
17
|
+
`SELECT scenario_file, discovery_rationale
|
|
18
|
+
FROM work_items
|
|
19
|
+
WHERE id = ? AND type = 'feature'`,
|
|
20
|
+
[featureId],
|
|
21
|
+
(err, row) => {
|
|
22
|
+
if (err) {
|
|
23
|
+
reject(err);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!row) {
|
|
28
|
+
// Feature doesn't exist
|
|
29
|
+
resolve(false);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Feature is planned if both fields are non-null and non-empty
|
|
34
|
+
const hasScenario = Boolean(row.scenario_file && row.scenario_file.trim());
|
|
35
|
+
const hasRationale = Boolean(row.discovery_rationale && row.discovery_rationale.trim());
|
|
36
|
+
|
|
37
|
+
resolve(hasScenario && hasRationale);
|
|
38
|
+
}
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Get the parent feature ID for a chore
|
|
45
|
+
* @param {string} command - The jettypod command being executed
|
|
46
|
+
* @returns {number|null} The parent feature ID or null if not a chore creation
|
|
47
|
+
*/
|
|
48
|
+
function extractParentFeatureId(command) {
|
|
49
|
+
// Match: jettypod work create chore "..." "..." --parent=123
|
|
50
|
+
const match = command.match(/work\s+create\s+chore\s+.*--parent[=\s]+(\d+)/);
|
|
51
|
+
return match ? parseInt(match[1], 10) : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Check if a file path looks like a feature scenario file
|
|
56
|
+
* @param {string} filePath - The file path to check
|
|
57
|
+
* @returns {boolean} True if it's a .feature file in the features directory
|
|
58
|
+
*/
|
|
59
|
+
function isFeatureFile(filePath) {
|
|
60
|
+
if (!filePath) return false;
|
|
61
|
+
return filePath.includes('/features/') && filePath.endsWith('.feature');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = {
|
|
65
|
+
isFeaturePlanned,
|
|
66
|
+
extractParentFeatureId,
|
|
67
|
+
isFeatureFile
|
|
68
|
+
};
|