claude-git-hooks 2.18.0 → 2.19.0

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/CLAUDE.md +12 -8
  3. package/README.md +2 -1
  4. package/bin/claude-hooks +75 -89
  5. package/lib/cli-metadata.js +301 -0
  6. package/lib/commands/analyze-diff.js +12 -10
  7. package/lib/commands/analyze.js +9 -5
  8. package/lib/commands/bump-version.js +66 -43
  9. package/lib/commands/create-pr.js +71 -34
  10. package/lib/commands/debug.js +4 -7
  11. package/lib/commands/generate-changelog.js +11 -4
  12. package/lib/commands/help.js +47 -27
  13. package/lib/commands/helpers.js +66 -43
  14. package/lib/commands/hooks.js +15 -13
  15. package/lib/commands/install.js +546 -39
  16. package/lib/commands/migrate-config.js +8 -11
  17. package/lib/commands/presets.js +6 -13
  18. package/lib/commands/setup-github.js +12 -3
  19. package/lib/commands/telemetry-cmd.js +8 -6
  20. package/lib/commands/update.js +1 -2
  21. package/lib/config.js +36 -31
  22. package/lib/hooks/pre-commit.js +34 -54
  23. package/lib/hooks/prepare-commit-msg.js +39 -58
  24. package/lib/utils/analysis-engine.js +28 -21
  25. package/lib/utils/changelog-generator.js +162 -34
  26. package/lib/utils/claude-client.js +438 -377
  27. package/lib/utils/claude-diagnostics.js +20 -10
  28. package/lib/utils/file-operations.js +51 -79
  29. package/lib/utils/file-utils.js +46 -9
  30. package/lib/utils/git-operations.js +140 -123
  31. package/lib/utils/git-tag-manager.js +24 -23
  32. package/lib/utils/github-api.js +85 -61
  33. package/lib/utils/github-client.js +12 -14
  34. package/lib/utils/installation-diagnostics.js +4 -4
  35. package/lib/utils/interactive-ui.js +29 -17
  36. package/lib/utils/logger.js +4 -1
  37. package/lib/utils/pr-metadata-engine.js +67 -33
  38. package/lib/utils/preset-loader.js +20 -62
  39. package/lib/utils/prompt-builder.js +50 -55
  40. package/lib/utils/resolution-prompt.js +33 -44
  41. package/lib/utils/sanitize.js +20 -19
  42. package/lib/utils/task-id.js +27 -40
  43. package/lib/utils/telemetry.js +29 -17
  44. package/lib/utils/version-manager.js +173 -126
  45. package/lib/utils/which-command.js +23 -12
  46. package/package.json +69 -69
@@ -46,11 +46,9 @@ class ResolutionPromptError extends Error {
46
46
  * Detailed: { message, file, line, method, severity, type, rule }
47
47
  */
48
48
  const formatBlockingIssues = (issues) => {
49
- logger.debug(
50
- 'resolution-prompt - formatBlockingIssues',
51
- 'Formatting issues',
52
- { issueCount: issues?.length || 0 }
53
- );
49
+ logger.debug('resolution-prompt - formatBlockingIssues', 'Formatting issues', {
50
+ issueCount: issues?.length || 0
51
+ });
54
52
 
55
53
  if (!Array.isArray(issues) || issues.length === 0) {
56
54
  return 'No issues found.';
@@ -94,16 +92,15 @@ const getAffectedFiles = (issues) => {
94
92
 
95
93
  // Extract unique file paths
96
94
  const filePaths = issues
97
- .map(issue => issue.file)
98
- .filter(file => file && typeof file === 'string');
95
+ .map((issue) => issue.file)
96
+ .filter((file) => file && typeof file === 'string');
99
97
 
100
98
  const uniqueFiles = [...new Set(filePaths)];
101
99
 
102
- logger.debug(
103
- 'resolution-prompt - getAffectedFiles',
104
- 'Extracted affected files',
105
- { totalIssues: issues.length, uniqueFiles: uniqueFiles.length }
106
- );
100
+ logger.debug('resolution-prompt - getAffectedFiles', 'Extracted affected files', {
101
+ totalIssues: issues.length,
102
+ uniqueFiles: uniqueFiles.length
103
+ });
107
104
 
108
105
  return uniqueFiles;
109
106
  };
@@ -117,11 +114,9 @@ const getAffectedFiles = (issues) => {
117
114
  * @returns {Promise<string>} Formatted file contents in markdown
118
115
  */
119
116
  const formatFileContents = async (filePaths) => {
120
- logger.debug(
121
- 'resolution-prompt - formatFileContents',
122
- 'Reading file contents',
123
- { fileCount: filePaths.length }
124
- );
117
+ logger.debug('resolution-prompt - formatFileContents', 'Reading file contents', {
118
+ fileCount: filePaths.length
119
+ });
125
120
 
126
121
  // Why: Use repo root for absolute paths (works on Windows/PowerShell/Git Bash)
127
122
  const repoRoot = getRepoRoot();
@@ -154,15 +149,13 @@ ${content}
154
149
  );
155
150
 
156
151
  const formatted = fileContents
157
- .filter(result => result.status === 'fulfilled')
158
- .map(result => result.value)
152
+ .filter((result) => result.status === 'fulfilled')
153
+ .map((result) => result.value)
159
154
  .join('\n');
160
155
 
161
- logger.debug(
162
- 'resolution-prompt - formatFileContents',
163
- 'File contents formatted',
164
- { successfulReads: fileContents.filter(r => r.status === 'fulfilled').length }
165
- );
156
+ logger.debug('resolution-prompt - formatFileContents', 'File contents formatted', {
157
+ successfulReads: fileContents.filter((r) => r.status === 'fulfilled').length
158
+ });
166
159
 
167
160
  return formatted;
168
161
  };
@@ -202,21 +195,18 @@ const generateResolutionPrompt = async (
202
195
 
203
196
  // Use details array (all issues) if available, fallback to blockingIssues
204
197
  // Why: analyze command provides full details, pre-commit may only have blockingIssues
205
- const allIssues = (Array.isArray(analysisResult.details) && analysisResult.details.length > 0)
206
- ? analysisResult.details
207
- : (analysisResult.blockingIssues || []);
208
-
209
- logger.debug(
210
- 'resolution-prompt - generateResolutionPrompt',
211
- 'Generating resolution prompt',
212
- {
213
- repoRoot,
214
- outputPath: absoluteOutputPath,
215
- templatePath: absoluteTemplatePath,
216
- issueCount: allIssues.length,
217
- usingDetails: Array.isArray(analysisResult.details) && analysisResult.details.length > 0
218
- }
219
- );
198
+ const allIssues =
199
+ Array.isArray(analysisResult.details) && analysisResult.details.length > 0
200
+ ? analysisResult.details
201
+ : analysisResult.blockingIssues || [];
202
+
203
+ logger.debug('resolution-prompt - generateResolutionPrompt', 'Generating resolution prompt', {
204
+ repoRoot,
205
+ outputPath: absoluteOutputPath,
206
+ templatePath: absoluteTemplatePath,
207
+ issueCount: allIssues.length,
208
+ usingDetails: Array.isArray(analysisResult.details) && analysisResult.details.length > 0
209
+ });
220
210
 
221
211
  try {
222
212
  // Load template
@@ -265,7 +255,6 @@ const generateResolutionPrompt = async (
265
255
  );
266
256
 
267
257
  return absoluteOutputPath;
268
-
269
258
  } catch (error) {
270
259
  logger.error(
271
260
  'resolution-prompt - generateResolutionPrompt',
@@ -295,10 +284,10 @@ const generateResolutionPrompt = async (
295
284
  * @returns {boolean} True if should generate prompt
296
285
  */
297
286
  const shouldGeneratePrompt = (analysisResult) => {
298
- const hasBlockingIssues = Array.isArray(analysisResult.blockingIssues) &&
299
- analysisResult.blockingIssues.length > 0;
300
- const hasDetailedIssues = Array.isArray(analysisResult.details) &&
301
- analysisResult.details.length > 0;
287
+ const hasBlockingIssues =
288
+ Array.isArray(analysisResult.blockingIssues) && analysisResult.blockingIssues.length > 0;
289
+ const hasDetailedIssues =
290
+ Array.isArray(analysisResult.details) && analysisResult.details.length > 0;
302
291
 
303
292
  logger.debug(
304
293
  'resolution-prompt - shouldGeneratePrompt',
@@ -19,11 +19,10 @@ import logger from './logger.js';
19
19
  * @param {boolean} options.stripControlChars - Remove control characters (default: true)
20
20
  * @returns {string} - Sanitized string
21
21
  */
22
- export const sanitizeInput = (input, {
23
- maxLength = 65536,
24
- allowNewlines = true,
25
- stripControlChars = true
26
- } = {}) => {
22
+ export const sanitizeInput = (
23
+ input,
24
+ { maxLength = 65536, allowNewlines = true, stripControlChars = true } = {}
25
+ ) => {
27
26
  if (typeof input !== 'string') {
28
27
  return '';
29
28
  }
@@ -62,11 +61,12 @@ export const sanitizeInput = (input, {
62
61
  * @param {string} title - Raw title
63
62
  * @returns {string} - Sanitized title
64
63
  */
65
- export const sanitizePRTitle = (title) => sanitizeInput(title, {
66
- maxLength: 256,
67
- allowNewlines: false,
68
- stripControlChars: true
69
- });
64
+ export const sanitizePRTitle = (title) =>
65
+ sanitizeInput(title, {
66
+ maxLength: 256,
67
+ allowNewlines: false,
68
+ stripControlChars: true
69
+ });
70
70
 
71
71
  /**
72
72
  * Sanitize PR body/description for GitHub API
@@ -75,11 +75,12 @@ export const sanitizePRTitle = (title) => sanitizeInput(title, {
75
75
  * @param {string} body - Raw body text
76
76
  * @returns {string} - Sanitized body
77
77
  */
78
- export const sanitizePRBody = (body) => sanitizeInput(body, {
79
- maxLength: 65536, // GitHub's limit is 65536 chars
80
- allowNewlines: true,
81
- stripControlChars: true
82
- });
78
+ export const sanitizePRBody = (body) =>
79
+ sanitizeInput(body, {
80
+ maxLength: 65536, // GitHub's limit is 65536 chars
81
+ allowNewlines: true,
82
+ stripControlChars: true
83
+ });
83
84
 
84
85
  /**
85
86
  * Sanitize array of strings (labels, reviewers)
@@ -95,10 +96,10 @@ export const sanitizeStringArray = (items, validPattern = /^[\w.-]+$/) => {
95
96
  }
96
97
 
97
98
  return items
98
- .filter(item => typeof item === 'string')
99
- .map(item => item.trim())
100
- .filter(item => item.length > 0 && item.length <= 100)
101
- .filter(item => validPattern.test(item));
99
+ .filter((item) => typeof item === 'string')
100
+ .map((item) => item.trim())
101
+ .filter((item) => item.length > 0 && item.length <= 100)
102
+ .filter((item) => validPattern.test(item));
102
103
  };
103
104
 
104
105
  /**
@@ -86,11 +86,7 @@ export const extractTaskId = (branchName, config = null) => {
86
86
  // Get first capture group
87
87
  const taskId = match[1];
88
88
  if (taskId) {
89
- logger.debug(
90
- 'task-id - extractTaskId',
91
- 'Task ID found',
92
- { branchName, taskId }
93
- );
89
+ logger.debug('task-id - extractTaskId', 'Task ID found', { branchName, taskId });
94
90
  return taskId.toUpperCase(); // Normalize to uppercase
95
91
  }
96
92
  }
@@ -114,11 +110,9 @@ export const extractTaskIdFromCurrentBranch = (config = null) => {
114
110
 
115
111
  return extractTaskId(branchName, config);
116
112
  } catch (error) {
117
- logger.debug(
118
- 'task-id - extractTaskIdFromCurrentBranch',
119
- 'Failed to get current branch',
120
- { error: error.message }
121
- );
113
+ logger.debug('task-id - extractTaskIdFromCurrentBranch', 'Failed to get current branch', {
114
+ error: error.message
115
+ });
122
116
  return null;
123
117
  }
124
118
  };
@@ -173,11 +167,10 @@ export const formatWithTaskId = (message, taskId) => {
173
167
  // Check if message already has task ID prefix (idempotent)
174
168
  const taskIdPrefix = `[${normalizedTaskId}]`;
175
169
  if (message.startsWith(taskIdPrefix)) {
176
- logger.debug(
177
- 'task-id - formatWithTaskId',
178
- 'Message already has task ID prefix',
179
- { message, taskId: normalizedTaskId }
180
- );
170
+ logger.debug('task-id - formatWithTaskId', 'Message already has task ID prefix', {
171
+ message,
172
+ taskId: normalizedTaskId
173
+ });
181
174
  return message;
182
175
  }
183
176
 
@@ -186,25 +179,21 @@ export const formatWithTaskId = (message, taskId) => {
186
179
  if (existingTaskIdMatch) {
187
180
  // Replace existing task ID with new one
188
181
  const formattedMessage = message.replace(existingTaskIdMatch[0], `${taskIdPrefix} `);
189
- logger.debug(
190
- 'task-id - formatWithTaskId',
191
- 'Replaced existing task ID',
192
- {
193
- oldTaskId: existingTaskIdMatch[1],
194
- newTaskId: normalizedTaskId,
195
- formattedMessage
196
- }
197
- );
182
+ logger.debug('task-id - formatWithTaskId', 'Replaced existing task ID', {
183
+ oldTaskId: existingTaskIdMatch[1],
184
+ newTaskId: normalizedTaskId,
185
+ formattedMessage
186
+ });
198
187
  return formattedMessage;
199
188
  }
200
189
 
201
190
  // Prepend task ID
202
191
  const formattedMessage = `${taskIdPrefix} ${message}`;
203
- logger.debug(
204
- 'task-id - formatWithTaskId',
205
- 'Added task ID prefix',
206
- { message, taskId: normalizedTaskId, formattedMessage }
207
- );
192
+ logger.debug('task-id - formatWithTaskId', 'Added task ID prefix', {
193
+ message,
194
+ taskId: normalizedTaskId,
195
+ formattedMessage
196
+ });
208
197
 
209
198
  return formattedMessage;
210
199
  };
@@ -246,7 +235,9 @@ export const promptForTaskId = async ({
246
235
  config = null
247
236
  } = {}) => {
248
237
  // Try to use /dev/tty for Unix (git hooks), fallback to stdin for Windows
249
- let input, output, usingTty = false;
238
+ let input,
239
+ output,
240
+ usingTty = false;
250
241
 
251
242
  // Check platform
252
243
  if (process.platform !== 'win32') {
@@ -281,9 +272,7 @@ export const promptForTaskId = async ({
281
272
 
282
273
  return new Promise((resolve) => {
283
274
  const askQuestion = () => {
284
- const promptMessage = allowSkip
285
- ? `${message} (press Enter to skip): `
286
- : `${message} `;
275
+ const promptMessage = allowSkip ? `${message} (press Enter to skip): ` : `${message} `;
287
276
 
288
277
  rl.question(promptMessage, (answer) => {
289
278
  const trimmedAnswer = answer.trim();
@@ -317,11 +306,9 @@ export const promptForTaskId = async ({
317
306
 
318
307
  // Valid task ID provided
319
308
  const normalizedTaskId = trimmedAnswer.toUpperCase();
320
- logger.debug(
321
- 'task-id - promptForTaskId',
322
- 'User provided task ID',
323
- { taskId: normalizedTaskId }
324
- );
309
+ logger.debug('task-id - promptForTaskId', 'User provided task ID', {
310
+ taskId: normalizedTaskId
311
+ });
325
312
  rl.close();
326
313
  if (usingTty) {
327
314
  input.close();
@@ -432,8 +419,8 @@ const getExamplesForPattern = (patternName) => {
432
419
  const examples = {
433
420
  'Jira-style': ['IX-123', 'PROJ-456', 'ABC-789'],
434
421
  'GitHub issue': ['#123', 'GH-456'],
435
- 'Linear': ['LIN-123', 'LIN-456'],
436
- 'Generic': ['TASK-123', 'BUG-456', 'FEAT-789']
422
+ Linear: ['LIN-123', 'LIN-456'],
423
+ Generic: ['TASK-123', 'BUG-456', 'FEAT-789']
437
424
  };
438
425
  return examples[patternName] || [];
439
426
  };
@@ -88,11 +88,9 @@ const getCurrentLogFile = () => {
88
88
  *
89
89
  * @returns {boolean} True if telemetry is enabled (default: true)
90
90
  */
91
- const isTelemetryEnabled = () =>
91
+ const isTelemetryEnabled = () =>
92
92
  // Enabled by default - only disabled if explicitly set to false
93
- config.system?.telemetry !== false
94
- ;
95
-
93
+ config.system?.telemetry !== false;
96
94
  /**
97
95
  * Ensure telemetry directory exists
98
96
  * Why: Create on first use
@@ -115,7 +113,7 @@ const ensureTelemetryDir = async () => {
115
113
  const appendEvent = async (event) => {
116
114
  try {
117
115
  const logFile = getCurrentLogFile();
118
- const line = `${JSON.stringify(event) }\n`;
116
+ const line = `${JSON.stringify(event)}\n`;
119
117
 
120
118
  // Append to file (create if doesn't exist)
121
119
  await fs.appendFile(logFile, line, 'utf8');
@@ -240,7 +238,7 @@ const readTelemetryEvents = async (maxDays = 7) => {
240
238
  const files = await fs.readdir(dir);
241
239
 
242
240
  // Filter to .jsonl files only
243
- const logFiles = files.filter(f => f.endsWith('.jsonl'));
241
+ const logFiles = files.filter((f) => f.endsWith('.jsonl'));
244
242
 
245
243
  // Sort by date (newest first)
246
244
  logFiles.sort().reverse();
@@ -262,7 +260,11 @@ const readTelemetryEvents = async (maxDays = 7) => {
262
260
  events.push(JSON.parse(line));
263
261
  } catch (parseError) {
264
262
  // Skip invalid lines
265
- logger.debug('telemetry - readTelemetryEvents', 'Invalid JSON line', parseError);
263
+ logger.debug(
264
+ 'telemetry - readTelemetryEvents',
265
+ 'Invalid JSON line',
266
+ parseError
267
+ );
266
268
  }
267
269
  }
268
270
  }
@@ -286,7 +288,8 @@ export const getStatistics = async (maxDays = 7) => {
286
288
  if (!isTelemetryEnabled()) {
287
289
  return {
288
290
  enabled: false,
289
- message: 'Telemetry is disabled. To enable (default), remove or set "system.telemetry: true" in .claude/config.json'
291
+ message:
292
+ 'Telemetry is disabled. To enable (default), remove or set "system.telemetry: true" in .claude/config.json'
290
293
  };
291
294
  }
292
295
 
@@ -311,14 +314,15 @@ export const getStatistics = async (maxDays = 7) => {
311
314
  let totalFilesInFailures = 0;
312
315
  let totalFilesInSuccesses = 0;
313
316
 
314
- events.forEach(event => {
317
+ events.forEach((event) => {
315
318
  if (event.type === 'json_parse_failure') {
316
319
  stats.jsonParseFailures++;
317
320
  totalFilesInFailures += event.data.fileCount || 0;
318
321
 
319
322
  // Group by batch size
320
323
  const batchSize = event.data.batchSize || 0;
321
- stats.failuresByBatchSize[batchSize] = (stats.failuresByBatchSize[batchSize] || 0) + 1;
324
+ stats.failuresByBatchSize[batchSize] =
325
+ (stats.failuresByBatchSize[batchSize] || 0) + 1;
322
326
 
323
327
  // Group by model
324
328
  const model = event.data.model || 'unknown';
@@ -327,7 +331,6 @@ export const getStatistics = async (maxDays = 7) => {
327
331
  // Group by hook
328
332
  const hook = event.data.hook || 'unknown';
329
333
  stats.failuresByHook[hook] = (stats.failuresByHook[hook] || 0) + 1;
330
-
331
334
  } else if (event.type === 'batch_success') {
332
335
  stats.batchSuccesses++;
333
336
  totalFilesInSuccesses += event.data.fileCount || 0;
@@ -340,16 +343,22 @@ export const getStatistics = async (maxDays = 7) => {
340
343
 
341
344
  // Calculate averages
342
345
  if (stats.jsonParseFailures > 0) {
343
- stats.avgFilesPerFailure = parseFloat((totalFilesInFailures / stats.jsonParseFailures).toFixed(2));
346
+ stats.avgFilesPerFailure = parseFloat(
347
+ (totalFilesInFailures / stats.jsonParseFailures).toFixed(2)
348
+ );
344
349
  }
345
350
  if (stats.batchSuccesses > 0) {
346
- stats.avgFilesPerSuccess = parseFloat((totalFilesInSuccesses / stats.batchSuccesses).toFixed(2));
351
+ stats.avgFilesPerSuccess = parseFloat(
352
+ (totalFilesInSuccesses / stats.batchSuccesses).toFixed(2)
353
+ );
347
354
  }
348
355
 
349
356
  // Calculate failure rate
350
357
  const totalAnalyses = stats.jsonParseFailures + stats.batchSuccesses;
351
358
  if (totalAnalyses > 0) {
352
- stats.failureRate = parseFloat((stats.jsonParseFailures / totalAnalyses * 100).toFixed(2));
359
+ stats.failureRate = parseFloat(
360
+ ((stats.jsonParseFailures / totalAnalyses) * 100).toFixed(2)
361
+ );
353
362
  }
354
363
 
355
364
  return stats;
@@ -379,7 +388,7 @@ export const rotateTelemetry = async (maxDays = 30) => {
379
388
  const files = await fs.readdir(dir);
380
389
 
381
390
  // Filter to .jsonl files
382
- const logFiles = files.filter(f => f.endsWith('.jsonl'));
391
+ const logFiles = files.filter((f) => f.endsWith('.jsonl'));
383
392
 
384
393
  // Calculate cutoff date
385
394
  const cutoffDate = new Date();
@@ -395,7 +404,10 @@ export const rotateTelemetry = async (maxDays = 30) => {
395
404
  if (fileDate < cutoffStr) {
396
405
  const filePath = path.join(dir, file);
397
406
  await fs.unlink(filePath);
398
- logger.debug('telemetry - rotateTelemetry', `Deleted old telemetry file: ${file}`);
407
+ logger.debug(
408
+ 'telemetry - rotateTelemetry',
409
+ `Deleted old telemetry file: ${file}`
410
+ );
399
411
  }
400
412
  }
401
413
  }
@@ -420,7 +432,7 @@ export const clearTelemetry = async () => {
420
432
 
421
433
  // Delete all .jsonl files
422
434
  const files = await fs.readdir(dir);
423
- const logFiles = files.filter(f => f.endsWith('.jsonl'));
435
+ const logFiles = files.filter((f) => f.endsWith('.jsonl'));
424
436
 
425
437
  for (const file of logFiles) {
426
438
  const filePath = path.join(dir, file);