jettypod 4.3.0 → 4.4.1

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,232 @@
1
+ /**
2
+ * Chore Classifier
3
+ *
4
+ * Classifies chore type from title/description using keyword matching.
5
+ * Maps to the 4 chore types defined in chore-taxonomy.js
6
+ */
7
+
8
+ const { CHORE_TYPES, isValidChoreType } = require('./chore-taxonomy');
9
+
10
+ // Keywords that indicate each chore type
11
+ const TYPE_KEYWORDS = {
12
+ [CHORE_TYPES.REFACTOR]: [
13
+ 'refactor',
14
+ 'restructure',
15
+ 'reorganize',
16
+ 'extract',
17
+ 'inline',
18
+ 'rename',
19
+ 'move',
20
+ 'split',
21
+ 'consolidate',
22
+ 'simplify',
23
+ 'decouple',
24
+ 'modularize'
25
+ ],
26
+ [CHORE_TYPES.DEPENDENCY]: [
27
+ 'update',
28
+ 'upgrade',
29
+ 'bump',
30
+ 'migrate',
31
+ 'dependency',
32
+ 'dependencies',
33
+ 'package',
34
+ 'library',
35
+ 'framework',
36
+ 'version',
37
+ 'security patch',
38
+ 'vulnerability',
39
+ 'npm',
40
+ 'yarn',
41
+ 'lodash',
42
+ 'react',
43
+ 'node'
44
+ ],
45
+ [CHORE_TYPES.CLEANUP]: [
46
+ 'cleanup',
47
+ 'clean up',
48
+ 'remove',
49
+ 'delete',
50
+ 'deprecate',
51
+ 'dead code',
52
+ 'unused',
53
+ 'legacy',
54
+ 'obsolete',
55
+ 'prune',
56
+ 'trim'
57
+ ],
58
+ [CHORE_TYPES.TOOLING]: [
59
+ 'tooling',
60
+ 'ci',
61
+ 'cd',
62
+ 'pipeline',
63
+ 'build',
64
+ 'lint',
65
+ 'eslint',
66
+ 'prettier',
67
+ 'config',
68
+ 'configuration',
69
+ 'script',
70
+ 'automation',
71
+ 'github action',
72
+ 'workflow',
73
+ 'jest',
74
+ 'test setup'
75
+ ]
76
+ };
77
+
78
+ /**
79
+ * Normalize and validate input text
80
+ * @param {string} title - The chore title
81
+ * @param {string} [description] - Optional description
82
+ * @returns {string} - Normalized text for classification
83
+ * @throws {Error} - If title is invalid
84
+ */
85
+ function normalizeInput(title, description = '') {
86
+ // Validate title
87
+ if (title === null || title === undefined) {
88
+ throw new Error('Chore title is required for classification');
89
+ }
90
+ if (typeof title !== 'string') {
91
+ throw new Error(`Chore title must be a string, got ${typeof title}`);
92
+ }
93
+
94
+ const trimmedTitle = title.trim();
95
+ if (trimmedTitle.length === 0) {
96
+ throw new Error('Chore title cannot be empty or whitespace only');
97
+ }
98
+
99
+ // Normalize description (coerce to string if not)
100
+ const normalizedDesc = description != null ? String(description).trim() : '';
101
+
102
+ return `${trimmedTitle} ${normalizedDesc}`.toLowerCase();
103
+ }
104
+
105
+ /**
106
+ * Escape special regex characters in a string
107
+ * @param {string} str - String to escape
108
+ * @returns {string} - Escaped string safe for regex
109
+ */
110
+ function escapeRegex(str) {
111
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
112
+ }
113
+
114
+ /**
115
+ * Calculate scores for all chore types
116
+ * @param {string} text - Normalized text to analyze
117
+ * @returns {Object} - Scores for each type
118
+ */
119
+ function calculateScores(text) {
120
+ const scores = {};
121
+ for (const [type, keywords] of Object.entries(TYPE_KEYWORDS)) {
122
+ scores[type] = 0;
123
+ for (const keyword of keywords) {
124
+ if (text.includes(keyword.toLowerCase())) {
125
+ // Exact word match scores higher (escape special chars for safety)
126
+ const escapedKeyword = escapeRegex(keyword);
127
+ const wordBoundaryRegex = new RegExp(`\\b${escapedKeyword}\\b`, 'i');
128
+ if (wordBoundaryRegex.test(text)) {
129
+ scores[type] += 2;
130
+ } else {
131
+ scores[type] += 1;
132
+ }
133
+ }
134
+ }
135
+ }
136
+ return scores;
137
+ }
138
+
139
+ /**
140
+ * Find the winning type from scores
141
+ * @param {Object} scores - Scores for each type
142
+ * @returns {Object} - { type: string, score: number }
143
+ */
144
+ function findWinningType(scores) {
145
+ let maxScore = 0;
146
+ let classifiedType = CHORE_TYPES.REFACTOR; // Default fallback
147
+
148
+ for (const [type, score] of Object.entries(scores)) {
149
+ if (score > maxScore) {
150
+ maxScore = score;
151
+ classifiedType = type;
152
+ }
153
+ }
154
+
155
+ return { type: classifiedType, score: maxScore };
156
+ }
157
+
158
+ /**
159
+ * Classify a chore based on its title (and optionally description)
160
+ * @param {string} title - The chore title
161
+ * @param {string} [description] - Optional description for additional context
162
+ * @returns {string} - One of the 4 chore types
163
+ * @throws {Error} - If title is invalid
164
+ */
165
+ function classifyChoreType(title, description = '') {
166
+ const text = normalizeInput(title, description);
167
+ const scores = calculateScores(text);
168
+ const { type } = findWinningType(scores);
169
+ return type;
170
+ }
171
+
172
+ /**
173
+ * Determine confidence level based on score
174
+ * @param {number} score - The classification score
175
+ * @returns {string} - 'high', 'medium', or 'low'
176
+ */
177
+ function getConfidenceLevel(score) {
178
+ if (score >= 4) return 'high';
179
+ if (score >= 2) return 'medium';
180
+ return 'low';
181
+ }
182
+
183
+ /**
184
+ * Get confidence level for a classification
185
+ * @param {string} title - The chore title
186
+ * @param {string} [description] - Optional description
187
+ * @returns {object} - { type: string, confidence: 'high'|'medium'|'low', score: number }
188
+ * @throws {Error} - If title is invalid
189
+ */
190
+ function classifyWithConfidence(title, description = '') {
191
+ const text = normalizeInput(title, description);
192
+ const scores = calculateScores(text);
193
+ const { type, score } = findWinningType(scores);
194
+ const confidence = getConfidenceLevel(score);
195
+
196
+ return { type, confidence, score };
197
+ }
198
+
199
+ /**
200
+ * Get detailed classification with all type scores
201
+ * Useful for debugging or when needing transparency in classification
202
+ * @param {string} title - The chore title
203
+ * @param {string} [description] - Optional description
204
+ * @returns {object} - { type, confidence, score, allScores }
205
+ * @throws {Error} - If title is invalid
206
+ */
207
+ function classifyWithDetails(title, description = '') {
208
+ const text = normalizeInput(title, description);
209
+ const scores = calculateScores(text);
210
+ const { type, score } = findWinningType(scores);
211
+ const confidence = getConfidenceLevel(score);
212
+
213
+ return {
214
+ type,
215
+ confidence,
216
+ score,
217
+ allScores: scores
218
+ };
219
+ }
220
+
221
+ module.exports = {
222
+ classifyChoreType,
223
+ classifyWithConfidence,
224
+ classifyWithDetails,
225
+ TYPE_KEYWORDS,
226
+ // Expose helpers for testing
227
+ normalizeInput,
228
+ escapeRegex,
229
+ calculateScores,
230
+ findWinningType,
231
+ getConfidenceLevel
232
+ };
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Chore Type Taxonomy
3
+ * Defines the 4 chore types and their workflow guidance for the chore-planning and chore-mode skills.
4
+ */
5
+
6
+ /**
7
+ * Chore types
8
+ * @constant {Object}
9
+ */
10
+ const CHORE_TYPES = Object.freeze({
11
+ REFACTOR: 'refactor',
12
+ DEPENDENCY: 'dependency',
13
+ CLEANUP: 'cleanup',
14
+ TOOLING: 'tooling'
15
+ });
16
+
17
+ /**
18
+ * Valid chore type values as array
19
+ * @constant {Array<string>}
20
+ */
21
+ const VALID_CHORE_TYPES = Object.freeze(Object.values(CHORE_TYPES));
22
+
23
+ /**
24
+ * Guidance for each chore type
25
+ * @constant {Object}
26
+ */
27
+ const CHORE_TYPE_GUIDANCE = Object.freeze({
28
+ [CHORE_TYPES.REFACTOR]: {
29
+ scope: [
30
+ 'Define clear boundaries - what code is being restructured',
31
+ 'Identify all callers/dependents of code being changed',
32
+ 'Ensure behavior remains unchanged (refactor, not rewrite)',
33
+ 'Consider breaking into smaller refactors if scope is large'
34
+ ],
35
+ verification: [
36
+ 'All existing tests pass without modification',
37
+ 'No new functionality added (that requires new tests)',
38
+ 'Code review confirms behavior preservation',
39
+ 'Performance is not degraded'
40
+ ],
41
+ testHandling: {
42
+ required: true,
43
+ approach: 'Run all tests for affected modules before and after. Update test file paths/imports if moved. Do NOT change test assertions - if tests fail, the refactor broke behavior.'
44
+ }
45
+ },
46
+ [CHORE_TYPES.DEPENDENCY]: {
47
+ scope: [
48
+ 'Identify which packages are being updated',
49
+ 'Check changelogs for breaking changes',
50
+ 'Note any deprecated APIs that need migration',
51
+ 'Consider update strategy: one at a time vs batch'
52
+ ],
53
+ verification: [
54
+ 'All tests pass after update',
55
+ 'Application builds successfully',
56
+ 'No new deprecation warnings (or documented)',
57
+ 'Security vulnerabilities addressed (if security update)'
58
+ ],
59
+ testHandling: {
60
+ required: false,
61
+ approach: 'Run full test suite to catch regressions. No new tests needed unless migrating to new API patterns. Document any test changes needed due to library API changes.'
62
+ }
63
+ },
64
+ [CHORE_TYPES.CLEANUP]: {
65
+ scope: [
66
+ 'Define what is being cleaned (dead code, unused files, etc.)',
67
+ 'Verify code is actually unused (grep for references)',
68
+ 'Set clear boundaries to avoid scope creep',
69
+ 'Consider impact on git history/blame'
70
+ ],
71
+ verification: [
72
+ 'All tests still pass',
73
+ 'No broken imports or references',
74
+ 'Application runs correctly',
75
+ 'Removed code was actually unused'
76
+ ],
77
+ testHandling: {
78
+ required: false,
79
+ approach: 'Run existing tests to ensure nothing breaks. Remove tests only if they test deleted code. No new tests needed for cleanup work.'
80
+ }
81
+ },
82
+ [CHORE_TYPES.TOOLING]: {
83
+ scope: [
84
+ 'Define what tooling is being changed (CI, build, dev environment)',
85
+ 'Document current behavior before changes',
86
+ 'Consider impact on team workflows',
87
+ 'Plan rollback strategy if changes cause issues'
88
+ ],
89
+ verification: [
90
+ 'CI pipeline passes',
91
+ 'Build completes successfully',
92
+ 'Dev environment works for all team members',
93
+ 'No regression in build times or developer experience'
94
+ ],
95
+ testHandling: {
96
+ required: false,
97
+ approach: 'Verify tooling changes work via manual testing or CI runs. Add integration tests only if tooling is complex. Focus on verification over unit testing for infrastructure.'
98
+ }
99
+ }
100
+ });
101
+
102
+ /**
103
+ * Normalize a chore type string (lowercase, trimmed)
104
+ * @param {string} type - Type to normalize
105
+ * @returns {string|null} Normalized type or null if input is invalid
106
+ */
107
+ function normalizeChoreType(type) {
108
+ if (type === null || type === undefined) {
109
+ return null;
110
+ }
111
+ if (typeof type !== 'string') {
112
+ return null;
113
+ }
114
+ return type.toLowerCase().trim();
115
+ }
116
+
117
+ /**
118
+ * Check if a chore type is valid (case-insensitive)
119
+ * @param {string} type - Type to validate
120
+ * @returns {boolean} True if type is valid
121
+ */
122
+ function isValidChoreType(type) {
123
+ const normalized = normalizeChoreType(type);
124
+ if (normalized === null) {
125
+ return false;
126
+ }
127
+ return VALID_CHORE_TYPES.includes(normalized);
128
+ }
129
+
130
+ /**
131
+ * Get guidance for a chore type
132
+ * @param {string} type - Chore type (case-insensitive)
133
+ * @returns {Object} Guidance object with scope, verification, and testHandling
134
+ * @throws {Error} If type is null, undefined, or invalid
135
+ */
136
+ function getGuidance(type) {
137
+ if (type === null || type === undefined) {
138
+ throw new Error(
139
+ 'Chore type is required. Valid types: refactor, dependency, cleanup, tooling'
140
+ );
141
+ }
142
+
143
+ const normalized = normalizeChoreType(type);
144
+
145
+ if (!normalized || !VALID_CHORE_TYPES.includes(normalized)) {
146
+ throw new Error(
147
+ `Invalid chore type: "${type}". Valid types: refactor, dependency, cleanup, tooling`
148
+ );
149
+ }
150
+
151
+ return CHORE_TYPE_GUIDANCE[normalized];
152
+ }
153
+
154
+ /**
155
+ * Get all chore types
156
+ * @returns {Array<string>} Array of valid chore types
157
+ */
158
+ function getChoreTypes() {
159
+ return [...VALID_CHORE_TYPES];
160
+ }
161
+
162
+ module.exports = {
163
+ CHORE_TYPES,
164
+ VALID_CHORE_TYPES,
165
+ CHORE_TYPE_GUIDANCE,
166
+ normalizeChoreType,
167
+ isValidChoreType,
168
+ getGuidance,
169
+ getChoreTypes,
170
+ // Alias for step definitions
171
+ types: CHORE_TYPES
172
+ };
@@ -8,16 +8,41 @@
8
8
  const fs = require('fs');
9
9
  const path = require('path');
10
10
  const { execSync } = require('child_process');
11
+ const safeDelete = require('./safe-delete');
12
+
13
+ /**
14
+ * Get the global backup directory path (in home directory)
15
+ * @returns {string} Path to global backup directory
16
+ */
17
+ function getGlobalBackupDir() {
18
+ const os = require('os');
19
+ return path.join(os.homedir(), '.jettypod-backups');
20
+ }
21
+
22
+ /**
23
+ * Get project-specific identifier for global backups
24
+ * @param {string} gitRoot - Path to git repository root
25
+ * @returns {string} Project identifier
26
+ */
27
+ function getProjectId(gitRoot) {
28
+ // Use the directory name and a hash of the full path for uniqueness
29
+ const dirname = path.basename(gitRoot);
30
+ const hash = require('crypto').createHash('md5').update(gitRoot).digest('hex').slice(0, 8);
31
+ return `${dirname}-${hash}`;
32
+ }
11
33
 
12
34
  /**
13
35
  * Create a backup of the .jettypod directory
14
36
  *
15
37
  * @param {string} gitRoot - Absolute path to git repository root
16
38
  * @param {string} reason - Human-readable reason for backup (e.g., "cleanup-worktree-1234")
39
+ * @param {Object} options - Backup options
40
+ * @param {boolean} options.global - Store backup in home directory (default: false)
17
41
  * @returns {Promise<Object>} Result with success status and backup path
18
42
  */
19
- async function createBackup(gitRoot, reason = 'unknown') {
43
+ async function createBackup(gitRoot, reason = 'unknown', options = {}) {
20
44
  const jettypodPath = path.join(gitRoot, '.jettypod');
45
+ const useGlobal = options.global || false;
21
46
 
22
47
  // Verify .jettypod exists
23
48
  if (!fs.existsSync(jettypodPath)) {
@@ -28,8 +53,17 @@ async function createBackup(gitRoot, reason = 'unknown') {
28
53
  };
29
54
  }
30
55
 
56
+ // Determine backup location
57
+ let backupBaseDir;
58
+ if (useGlobal) {
59
+ const globalDir = getGlobalBackupDir();
60
+ const projectId = getProjectId(gitRoot);
61
+ backupBaseDir = path.join(globalDir, projectId);
62
+ } else {
63
+ backupBaseDir = path.join(gitRoot, '.git', 'jettypod-backups');
64
+ }
65
+
31
66
  // Create backup directory if it doesn't exist
32
- const backupBaseDir = path.join(gitRoot, '.git', 'jettypod-backups');
33
67
  if (!fs.existsSync(backupBaseDir)) {
34
68
  fs.mkdirSync(backupBaseDir, { recursive: true });
35
69
  }
@@ -89,7 +123,16 @@ async function cleanupOldBackups(backupDir, keepCount = 10) {
89
123
  // Delete old backups
90
124
  const toDelete = backups.slice(keepCount);
91
125
  for (const backup of toDelete) {
92
- fs.rmSync(backup.path, { recursive: true, force: true });
126
+ // SAFETY: Validate backup path before deletion
127
+ const resolvedPath = path.resolve(backup.path);
128
+ const isValidBackupPath = (resolvedPath.includes('.git/jettypod-backups') ||
129
+ resolvedPath.includes('.jettypod-backups')) &&
130
+ backup.name.startsWith('jettypod-');
131
+ if (!isValidBackupPath) {
132
+ console.warn(`Warning: Skipping suspicious backup path: ${backup.path}`);
133
+ continue;
134
+ }
135
+ fs.rmSync(backup.path, { recursive: true });
93
136
  }
94
137
  } catch (err) {
95
138
  // Non-fatal - just log warning
@@ -98,12 +141,77 @@ async function cleanupOldBackups(backupDir, keepCount = 10) {
98
141
  }
99
142
 
100
143
  /**
101
- * List available backups
144
+ * List available backups from both local and global locations
102
145
  *
103
146
  * @param {string} gitRoot - Absolute path to git repository root
104
- * @returns {Array} List of backup objects with name, path, timestamp
147
+ * @param {Object} options - Options
148
+ * @param {boolean} options.includeGlobal - Include global backups (default: true)
149
+ * @returns {Array} List of backup objects with name, path, timestamp, location
105
150
  */
106
- function listBackups(gitRoot) {
151
+ function listBackups(gitRoot, options = {}) {
152
+ const includeGlobal = options.includeGlobal !== false;
153
+ const allBackups = [];
154
+
155
+ // Local backups
156
+ const localDir = path.join(gitRoot, '.git', 'jettypod-backups');
157
+ if (fs.existsSync(localDir)) {
158
+ try {
159
+ const localBackups = fs.readdirSync(localDir)
160
+ .filter(name => name.startsWith('jettypod-'))
161
+ .map(name => {
162
+ const backupPath = path.join(localDir, name);
163
+ const stat = fs.statSync(backupPath);
164
+ return {
165
+ name: name,
166
+ path: backupPath,
167
+ created: stat.mtime,
168
+ size: getDirectorySize(backupPath),
169
+ location: 'local'
170
+ };
171
+ });
172
+ allBackups.push(...localBackups);
173
+ } catch (err) {
174
+ console.error(`Error reading local backups: ${err.message}`);
175
+ }
176
+ }
177
+
178
+ // Global backups
179
+ if (includeGlobal) {
180
+ const globalDir = getGlobalBackupDir();
181
+ const projectId = getProjectId(gitRoot);
182
+ const projectBackupDir = path.join(globalDir, projectId);
183
+
184
+ if (fs.existsSync(projectBackupDir)) {
185
+ try {
186
+ const globalBackups = fs.readdirSync(projectBackupDir)
187
+ .filter(name => name.startsWith('jettypod-'))
188
+ .map(name => {
189
+ const backupPath = path.join(projectBackupDir, name);
190
+ const stat = fs.statSync(backupPath);
191
+ return {
192
+ name: name,
193
+ path: backupPath,
194
+ created: stat.mtime,
195
+ size: getDirectorySize(backupPath),
196
+ location: 'global'
197
+ };
198
+ });
199
+ allBackups.push(...globalBackups);
200
+ } catch (err) {
201
+ console.error(`Error reading global backups: ${err.message}`);
202
+ }
203
+ }
204
+ }
205
+
206
+ // Sort all backups by date, newest first
207
+ return allBackups.sort((a, b) => b.created - a.created);
208
+ }
209
+
210
+ /**
211
+ * List available backups (legacy function for backwards compatibility)
212
+ * @deprecated Use listBackups with options instead
213
+ */
214
+ function listBackupsLegacy(gitRoot) {
107
215
  const backupBaseDir = path.join(gitRoot, '.git', 'jettypod-backups');
108
216
 
109
217
  if (!fs.existsSync(backupBaseDir)) {
@@ -182,7 +290,13 @@ async function restoreBackup(gitRoot, backupName = 'latest') {
182
290
 
183
291
  // Remove current .jettypod
184
292
  if (fs.existsSync(jettypodPath)) {
185
- fs.rmSync(jettypodPath, { recursive: true, force: true });
293
+ // SAFETY: Validate jettypod path before deletion
294
+ const resolvedPath = path.resolve(jettypodPath);
295
+ const resolvedGitRoot = path.resolve(gitRoot);
296
+ if (!resolvedPath.startsWith(resolvedGitRoot) || !resolvedPath.endsWith('.jettypod')) {
297
+ throw new Error(`SAFETY: Refusing to delete ${jettypodPath} - not a valid .jettypod directory`);
298
+ }
299
+ fs.rmSync(jettypodPath, { recursive: true });
186
300
  }
187
301
 
188
302
  // Copy backup to .jettypod
@@ -234,5 +348,7 @@ function getDirectorySize(dirPath) {
234
348
  module.exports = {
235
349
  createBackup,
236
350
  listBackups,
237
- restoreBackup
351
+ restoreBackup,
352
+ getGlobalBackupDir,
353
+ getProjectId
238
354
  };