claude-git-hooks 2.8.0 → 2.10.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.
@@ -23,6 +23,7 @@ import logger from './logger.js';
23
23
  import config from '../config.js';
24
24
  import { detectClaudeError, formatClaudeError, ClaudeErrorType, isRecoverableError } from './claude-diagnostics.js';
25
25
  import { which } from './which-command.js';
26
+ import { recordJsonParseFailure, recordBatchSuccess, rotateTelemetry } from './telemetry.js';
26
27
 
27
28
  /**
28
29
  * Custom error for Claude client failures
@@ -526,10 +527,11 @@ const executeClaudeInteractive = (prompt, { timeout = 300000 } = {}) => new Prom
526
527
  * Why: Claude may include markdown formatting or explanatory text around JSON
527
528
  *
528
529
  * @param {string} response - Raw response from Claude
530
+ * @param {Object} telemetryContext - Context for telemetry recording (optional)
529
531
  * @returns {Object} Parsed JSON object
530
532
  * @throws {ClaudeClientError} If no valid JSON found
531
533
  */
532
- const extractJSON = (response) => {
534
+ const extractJSON = (response, telemetryContext = {}) => {
533
535
  logger.debug(
534
536
  'claude-client - extractJSON',
535
537
  'Extracting JSON from response',
@@ -594,16 +596,24 @@ const extractJSON = (response) => {
594
596
  }
595
597
 
596
598
  // No valid JSON found
599
+ const responsePreview = response.substring(0, 500);
600
+
597
601
  logger.error(
598
602
  'claude-client - extractJSON',
599
603
  'No valid JSON found in response',
600
604
  new ClaudeClientError('No valid JSON in response', {
601
- output: response.substring(0, 500) // First 500 chars for debugging
605
+ output: responsePreview
602
606
  })
603
607
  );
604
608
 
609
+ // Telemetry is now recorded by withRetry wrapper
605
610
  throw new ClaudeClientError('No valid JSON found in Claude response', {
606
- output: response.substring(0, 500)
611
+ context: {
612
+ response: response,
613
+ errorInfo: {
614
+ type: 'JSON_PARSE_ERROR'
615
+ }
616
+ }
607
617
  });
608
618
  };
609
619
 
@@ -661,8 +671,8 @@ const saveDebugResponse = async (prompt, response, filename = config.output.debu
661
671
  };
662
672
 
663
673
  /**
664
- * Wraps an async function with retry logic for recoverable errors
665
- * Why: Reusable retry logic for Claude CLI operations
674
+ * Wraps an async function with retry logic for recoverable errors and telemetry tracking
675
+ * Why: Reusable retry logic for Claude CLI operations with per-attempt telemetry
666
676
  *
667
677
  * @param {Function} fn - Async function to execute with retry
668
678
  * @param {Object} options - Retry options
@@ -670,15 +680,50 @@ const saveDebugResponse = async (prompt, response, filename = config.output.debu
670
680
  * @param {number} options.baseRetryDelay - Base delay in ms (default: 2000)
671
681
  * @param {number} options.retryCount - Current retry attempt (internal, default: 0)
672
682
  * @param {string} options.operationName - Name for logging (default: 'operation')
683
+ * @param {Object} options.telemetryContext - Context for telemetry recording (optional)
673
684
  * @returns {Promise<any>} Result from fn
674
685
  * @throws {Error} If fn fails after all retries
675
686
  */
676
- const withRetry = async (fn, { maxRetries = 3, baseRetryDelay = 2000, retryCount = 0, operationName = 'operation' } = {}) => {
687
+ const withRetry = async (fn, { maxRetries = 3, baseRetryDelay = 2000, retryCount = 0, operationName = 'operation', telemetryContext = null } = {}) => {
677
688
  const retryDelay = baseRetryDelay * Math.pow(2, retryCount);
689
+ const startTime = Date.now();
678
690
 
679
691
  try {
680
- return await fn();
692
+ const result = await fn();
693
+
694
+ // Record success telemetry if context provided
695
+ if (telemetryContext) {
696
+ const duration = Date.now() - startTime;
697
+ await recordBatchSuccess({
698
+ ...telemetryContext,
699
+ duration,
700
+ retryAttempt: retryCount,
701
+ totalRetries: maxRetries
702
+ }).catch(err => {
703
+ logger.debug('claude-client - withRetry', 'Failed to record success telemetry', err);
704
+ });
705
+ }
706
+
707
+ return result;
681
708
  } catch (error) {
709
+ // Record failure telemetry if context provided (before retry decision)
710
+ if (telemetryContext) {
711
+ const errorType = error.context?.errorInfo?.type || error.name || 'UNKNOWN_ERROR';
712
+ const errorMessage = error.message || 'Unknown error';
713
+
714
+ await recordJsonParseFailure({
715
+ ...telemetryContext,
716
+ errorType,
717
+ errorMessage,
718
+ retryAttempt: retryCount,
719
+ totalRetries: maxRetries,
720
+ responseLength: error.context?.response?.length || 0,
721
+ responsePreview: error.context?.response?.substring(0, 100) || ''
722
+ }).catch(err => {
723
+ logger.debug('claude-client - withRetry', 'Failed to record failure telemetry', err);
724
+ });
725
+ }
726
+
682
727
  // Check if error is recoverable and we haven't exceeded retry limit
683
728
  const hasContext = !!error.context;
684
729
  const hasErrorInfo = !!error.context?.errorInfo;
@@ -714,8 +759,8 @@ const withRetry = async (fn, { maxRetries = 3, baseRetryDelay = 2000, retryCount
714
759
  // Wait before retry
715
760
  await new Promise(resolve => setTimeout(resolve, retryDelay));
716
761
 
717
- // Retry with incremented count
718
- return withRetry(fn, { maxRetries, baseRetryDelay, retryCount: retryCount + 1, operationName });
762
+ // Retry with incremented count and same telemetry context
763
+ return withRetry(fn, { maxRetries, baseRetryDelay, retryCount: retryCount + 1, operationName, telemetryContext });
719
764
  }
720
765
 
721
766
  // Add retry attempt to error context if not already present
@@ -730,17 +775,25 @@ const withRetry = async (fn, { maxRetries = 3, baseRetryDelay = 2000, retryCount
730
775
  };
731
776
 
732
777
  /**
733
- * Executes Claude CLI with retry logic
734
- * Why: Provides retry capability for executeClaude calls
778
+ * Executes Claude CLI with retry logic and telemetry tracking
779
+ * Why: Provides retry capability for executeClaude calls with per-attempt telemetry
735
780
  *
736
781
  * @param {string} prompt - Prompt text
737
- * @param {Object} options - Execution options (timeout, allowedTools)
782
+ * @param {Object} options - Execution options
783
+ * @param {number} options.timeout - Timeout in milliseconds
784
+ * @param {Array<string>} options.allowedTools - Allowed tools for Claude
785
+ * @param {Object} options.telemetryContext - Context for telemetry (fileCount, hook, etc.)
738
786
  * @returns {Promise<string>} Claude's response
739
787
  */
740
788
  const executeClaudeWithRetry = async (prompt, options = {}) => {
789
+ const { telemetryContext = {}, ...executeOptions } = options;
790
+
741
791
  return withRetry(
742
- () => executeClaude(prompt, options),
743
- { operationName: 'executeClaude' }
792
+ () => executeClaude(prompt, executeOptions),
793
+ {
794
+ operationName: 'executeClaude',
795
+ telemetryContext: telemetryContext
796
+ }
744
797
  );
745
798
  };
746
799
 
@@ -752,17 +805,25 @@ const executeClaudeWithRetry = async (prompt, options = {}) => {
752
805
  * @param {Object} options - Analysis options
753
806
  * @param {number} options.timeout - Timeout in milliseconds
754
807
  * @param {boolean} options.saveDebug - Save response to debug file (default: from config)
808
+ * @param {Object} options.telemetryContext - Context for telemetry (fileCount, batchSize, etc.)
755
809
  * @returns {Promise<Object>} Parsed analysis result
756
810
  * @throws {ClaudeClientError} If analysis fails
757
811
  */
758
- const analyzeCode = async (prompt, { timeout = 120000, saveDebug = config.system.debug } = {}) => {
812
+ const analyzeCode = async (prompt, { timeout = 120000, saveDebug = config.system.debug, telemetryContext = {} } = {}) => {
813
+ const startTime = Date.now();
814
+
759
815
  logger.debug(
760
816
  'claude-client - analyzeCode',
761
817
  'Starting code analysis',
762
818
  { promptLength: prompt.length, timeout, saveDebug }
763
819
  );
764
820
 
765
- // Use withRetry to wrap the entire analysis flow
821
+ // Rotate telemetry files periodically
822
+ rotateTelemetry().catch(err => {
823
+ logger.debug('claude-client - analyzeCode', 'Failed to rotate telemetry', err);
824
+ });
825
+
826
+ // Use withRetry to wrap the entire analysis flow (with telemetry tracking)
766
827
  return withRetry(
767
828
  async () => {
768
829
  // Execute Claude CLI
@@ -774,7 +835,9 @@ const analyzeCode = async (prompt, { timeout = 120000, saveDebug = config.system
774
835
  }
775
836
 
776
837
  // Extract and parse JSON
777
- const result = extractJSON(response);
838
+ const result = extractJSON(response, telemetryContext);
839
+
840
+ const duration = Date.now() - startTime;
778
841
 
779
842
  logger.debug(
780
843
  'claude-client - analyzeCode',
@@ -782,13 +845,21 @@ const analyzeCode = async (prompt, { timeout = 120000, saveDebug = config.system
782
845
  {
783
846
  hasApproved: 'approved' in result,
784
847
  hasQualityGate: 'QUALITY_GATE' in result,
785
- blockingIssuesCount: result.blockingIssues?.length ?? 0
848
+ blockingIssuesCount: result.blockingIssues?.length ?? 0,
849
+ duration
786
850
  }
787
851
  );
788
852
 
853
+ // Telemetry is now recorded by withRetry wrapper
789
854
  return result;
790
855
  },
791
- { operationName: 'analyzeCode' }
856
+ {
857
+ operationName: 'analyzeCode',
858
+ telemetryContext: {
859
+ responseLength: 0, // Will be updated in withRetry
860
+ ...telemetryContext
861
+ }
862
+ }
792
863
  );
793
864
  };
794
865
 
@@ -810,6 +881,7 @@ const chunkArray = (array, size) => {
810
881
  * Runs multiple analyzeCode calls in parallel
811
882
  * @param {Array<string>} prompts - Array of prompts to analyze
812
883
  * @param {Object} options - Same options as analyzeCode
884
+ * @param {Object} options.telemetryContext - Base telemetry context (will be augmented per batch)
813
885
  * @returns {Promise<Array<Object>>} Array of results
814
886
  */
815
887
  const analyzeCodeParallel = async (prompts, options = {}) => {
@@ -824,7 +896,18 @@ const analyzeCodeParallel = async (prompts, options = {}) => {
824
896
  const promises = prompts.map((prompt, index) => {
825
897
  console.log(` ⚡ Launching batch ${index + 1}/${prompts.length}...`);
826
898
  logger.debug('claude-client - analyzeCodeParallel', `Starting batch ${index + 1}`);
827
- return analyzeCode(prompt, options);
899
+
900
+ // Augment telemetry context with batch info
901
+ const batchTelemetryContext = {
902
+ ...(options.telemetryContext || {}),
903
+ batchIndex: index,
904
+ totalBatches: prompts.length
905
+ };
906
+
907
+ return analyzeCode(prompt, {
908
+ ...options,
909
+ telemetryContext: batchTelemetryContext
910
+ });
828
911
  });
829
912
 
830
913
  console.log(' ⏳ Waiting for all batches to complete...\n');
@@ -2,7 +2,7 @@
2
2
  * File: github-api.js
3
3
  * Purpose: Direct GitHub API integration via Octokit
4
4
  *
5
- * Why Octokit instead of MCP:
5
+ * Why Octokit:
6
6
  * - PR creation is deterministic (no AI judgment needed)
7
7
  * - Reliable error handling
8
8
  * - No external process dependencies
@@ -11,8 +11,7 @@
11
11
  * Token priority:
12
12
  * 1. GITHUB_TOKEN env var (CI/CD friendly)
13
13
  * 2. GITHUB_PERSONAL_ACCESS_TOKEN env var
14
- * 3. .claude/settings.local.json → githubToken
15
- * 4. Claude Desktop config (cross-platform)
14
+ * 3. .claude/settings.local.json → githubToken (local dev, gitignored)
16
15
  */
17
16
 
18
17
  import { Octokit } from '@octokit/rest';
@@ -21,7 +20,6 @@ import fs from 'fs';
21
20
  import path from 'path';
22
21
  import { fileURLToPath } from 'url';
23
22
  import logger from './logger.js';
24
- import { findGitHubTokenInDesktopConfig } from './mcp-setup.js';
25
23
 
26
24
  // Get package info for user agent
27
25
  const __filename = fileURLToPath(import.meta.url);
@@ -89,6 +87,89 @@ const loadLocalSettings = () => {
89
87
  return {};
90
88
  };
91
89
 
90
+ /**
91
+ * Save GitHub token to .claude/settings.local.json
92
+ * Why: Persist token in gitignored location for local development
93
+ *
94
+ * @param {string} token - GitHub Personal Access Token
95
+ * @returns {Object} { success: boolean, path: string, error?: string }
96
+ */
97
+ export const saveGitHubToken = (token) => {
98
+ try {
99
+ const repoRoot = getRepoRoot();
100
+ const claudeDir = path.join(repoRoot, '.claude');
101
+ const settingsPath = path.join(claudeDir, 'settings.local.json');
102
+
103
+ logger.debug('github-api - saveGitHubToken', 'Saving token', { settingsPath });
104
+
105
+ // Ensure .claude directory exists
106
+ if (!fs.existsSync(claudeDir)) {
107
+ fs.mkdirSync(claudeDir, { recursive: true });
108
+ logger.debug('github-api - saveGitHubToken', 'Created .claude directory');
109
+ }
110
+
111
+ // Load existing settings or create new
112
+ let settings = {};
113
+ if (fs.existsSync(settingsPath)) {
114
+ try {
115
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
116
+ } catch (e) {
117
+ logger.debug('github-api - saveGitHubToken', 'Invalid existing settings, starting fresh');
118
+ settings = {};
119
+ }
120
+ }
121
+
122
+ // Update token
123
+ settings.githubToken = token;
124
+
125
+ // Add comment if new file
126
+ if (!settings._comment) {
127
+ settings._comment = 'Local settings - DO NOT COMMIT. This file is gitignored.';
128
+ }
129
+
130
+ // Save settings
131
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
132
+
133
+ logger.debug('github-api - saveGitHubToken', 'Token saved successfully');
134
+
135
+ return { success: true, path: settingsPath };
136
+
137
+ } catch (error) {
138
+ logger.error('github-api - saveGitHubToken', 'Failed to save token', error);
139
+ return { success: false, path: null, error: error.message };
140
+ }
141
+ };
142
+
143
+ /**
144
+ * Validate GitHub token format
145
+ * Why: Catch obvious format errors before API call
146
+ *
147
+ * @param {string} token - Token to validate
148
+ * @returns {Object} { valid: boolean, warning?: string }
149
+ */
150
+ export const validateTokenFormat = (token) => {
151
+ if (!token || token.trim() === '') {
152
+ return { valid: false, warning: 'Token is empty' };
153
+ }
154
+
155
+ const trimmed = token.trim();
156
+
157
+ // Valid prefixes for GitHub tokens
158
+ if (trimmed.startsWith('ghp_') || trimmed.startsWith('github_pat_')) {
159
+ return { valid: true };
160
+ }
161
+
162
+ // Older token format (40 hex chars)
163
+ if (/^[a-f0-9]{40}$/i.test(trimmed)) {
164
+ return { valid: true };
165
+ }
166
+
167
+ return {
168
+ valid: true, // Still allow, just warn
169
+ warning: 'Token format looks unusual (expected ghp_... or github_pat_...)'
170
+ };
171
+ };
172
+
92
173
  /**
93
174
  * Get GitHub authentication token
94
175
  * Why: Centralized token resolution with multiple fallback sources
@@ -97,7 +178,6 @@ const loadLocalSettings = () => {
97
178
  * 1. GITHUB_TOKEN env var (standard for CI/CD)
98
179
  * 2. GITHUB_PERSONAL_ACCESS_TOKEN env var (legacy support)
99
180
  * 3. .claude/settings.local.json → githubToken (local dev, gitignored)
100
- * 4. Claude Desktop config (cross-platform GUI users)
101
181
  *
102
182
  * @returns {string} GitHub token
103
183
  * @throws {GitHubAPIError} If no token found
@@ -122,15 +202,6 @@ export const getGitHubToken = () => {
122
202
  return localSettings.githubToken;
123
203
  }
124
204
 
125
- // Priority 4: Claude Desktop config
126
- const desktopToken = findGitHubTokenInDesktopConfig();
127
- if (desktopToken?.token) {
128
- logger.debug('github-api - getGitHubToken', 'Using token from Claude Desktop config', {
129
- configPath: desktopToken.configPath
130
- });
131
- return desktopToken.token;
132
- }
133
-
134
205
  // No token found
135
206
  throw new GitHubAPIError(
136
207
  'GitHub token not found. Please configure authentication.',
@@ -139,10 +210,9 @@ export const getGitHubToken = () => {
139
210
  searchedLocations: [
140
211
  'GITHUB_TOKEN env var',
141
212
  'GITHUB_PERSONAL_ACCESS_TOKEN env var',
142
- '.claude/settings.local.json → githubToken',
143
- 'Claude Desktop config'
213
+ '.claude/settings.local.json → githubToken'
144
214
  ],
145
- suggestion: 'Run: claude-hooks setup-github or create .claude/settings.local.json with {"githubToken": "ghp_..."}'
215
+ suggestion: 'Run: claude-hooks setup-github'
146
216
  }
147
217
  }
148
218
  );