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.
@@ -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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jettypod",
3
- "version": "4.4.8",
3
+ "version": "4.4.10",
4
4
  "description": "AI-powered development workflow manager with TDD, BDD, and automatic test generation",
5
5
  "main": "jettypod.js",
6
6
  "bin": {