claude-git-hooks 2.32.0 → 2.33.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,26 @@ Todos los cambios notables en este proyecto se documentarán en este archivo.
5
5
  El formato está basado en [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  y este proyecto adhiere a [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.33.0] - 2026-03-20
9
+
10
+ ### ✨ Added
11
+ - Always-on metrics system for code quality observability (#41) - collects structured JSONL events locally with 90-day retention for trend analysis
12
+ - Team-based reviewer selection via GitHub Teams API (#115, #36) - automatically resolves team members and excludes PR author when selecting reviewers
13
+ - New metrics module (`lib/utils/metrics.js`) with fire-and-forget event recording that never blocks callers
14
+ - Metrics instrumentation for pre-commit analysis, commit message generation, PR creation, judge verdicts, and orchestration batches
15
+ - Documented proven implementation patterns in CLAUDE.md covering testability, git operations, and architecture best practices
16
+
17
+ ### 🔧 Changed
18
+ - Telemetry system now always enabled - removed opt-out toggle for consistent observability
19
+ - Existing telemetry events dual-write to unified metrics system for consolidated analysis
20
+ - PR statistics module now records to both legacy JSONL and new metrics system
21
+ - Judge module switched from telemetry to metrics recording with richer event data including false positives and unresolved issues
22
+
23
+ ### 🗑️ Removed
24
+ - Removed `system.telemetry` configuration option - metrics are now always collected locally
25
+ - Removed telemetry disabled warnings from `batch-info` and `telemetry-cmd` commands
26
+
27
+
8
28
  ## [2.32.0] - 2026-03-19
9
29
 
10
30
  ### ✨ Added
package/CLAUDE.md CHANGED
@@ -1240,6 +1240,28 @@ No enforced threshold. Focus on critical paths:
1240
1240
  - `git-operations.js` - Git command abstraction
1241
1241
  - `config.js` - Configuration merging
1242
1242
 
1243
+ ## Proven Implementation Patterns
1244
+
1245
+ Recurring patterns validated across 15+ automation sessions. Apply these in new code to avoid known pitfalls.
1246
+
1247
+ ### Testability
1248
+
1249
+ - **`process.exit()` outside try/catch**: Mocking `process.exit` to throw requires the throw to propagate. Any surrounding `catch (e)` silently swallows it. Pattern: capture fallible values inside try/catch, check them and exit *outside* the catch block.
1250
+ - **`jest.resetAllMocks()` over `clearAllMocks()`**: `clearAllMocks()` clears call history but does NOT drain `mockResolvedValueOnce` queues. Tests that leave unconsumed values silently corrupt subsequent tests. Use `resetAllMocks()` in `beforeEach` and re-apply all defaults explicitly.
1251
+ - **`_privateHelper` export for unit testing**: Private-by-convention functions (`_` prefix) can be exported for direct unit testing without invoking the full command flow. Used in `close-release.js`, `revert-feature.js`, `shadow.js`.
1252
+
1253
+ ### Git Operations
1254
+
1255
+ - **`execSync` with `input` for multi-line commit messages**: `git commit -F -` with `execSync({ input: fullMessage })` avoids temp files, heredocs, and `\n` escaping. Used when `createCommit()` (which only supports `-m`) is insufficient.
1256
+ - **`git diff-tree --no-commit-id -r --name-only <hash>`**: Canonical command to list files changed in a single commit. Preferable to parsing `git show --stat` (fragile) or `getChangedFilesBetweenRefs('<hash>^', '<hash>')` (breaks on first commit).
1257
+ - **`git merge --no-verify --no-ff` via `execSync`**: `mergeBranch()` in `git-operations.js` does not support `--no-verify`. For hook-free merges (shadow sync, back-merge), use `execSync` directly with the full flags.
1258
+
1259
+ ### Architecture
1260
+
1261
+ - **`PROTECTED_COMMANDS` fast-path**: Static Set check before any API call — non-protected commands short-circuit with zero network overhead. Pattern for any guard that only applies to a subset of commands.
1262
+ - **Graceful degradation for features, fail-closed for security**: Labels, shadow sync, CHANGELOG — warn and continue on failure. Authorization, permissions — block on any error. Two fetch patterns for the same config repo.
1263
+ - **Inline Set intersection over Union-Find for single-target checks**: `coupling-detector.js` Union-Find is for N-PR transitive grouping. For checking one commit against others (revert-feature), a simple `Set` intersection per item is sufficient.
1264
+
1243
1265
  ## Restrictions
1244
1266
 
1245
1267
  ### What Claude should NOT do in this repository
@@ -23,6 +23,7 @@ import { getBranchPushStatus, pushBranch } from '../utils/git-operations.js';
23
23
  import logger from '../utils/logger.js';
24
24
  import { resolveLabels } from '../utils/label-resolver.js';
25
25
  import { error, checkGitRepo } from './helpers.js';
26
+ import { recordMetric } from '../utils/metrics.js';
26
27
 
27
28
  /**
28
29
  * Detect expected merge strategy from branch naming conventions
@@ -697,6 +698,18 @@ export async function runCreatePr(args) {
697
698
  prUrl: result.html_url
698
699
  });
699
700
 
701
+ // Record PR creation metric
702
+ recordMetric('pr.created', {
703
+ repo: repoInfo.fullName,
704
+ base: baseBranch,
705
+ head: currentBranch,
706
+ strategy: mergeStrategy,
707
+ labels,
708
+ reviewerCount: reviewers.length,
709
+ fileCount: filesArray.length,
710
+ prNumber: result.number
711
+ }).catch(() => {});
712
+
700
713
  console.log('');
701
714
  showSuccess('Pull request created successfully!');
702
715
  console.log('');
@@ -39,15 +39,6 @@ export async function runDiffBatchInfo() {
39
39
  try {
40
40
  const stats = await getStatistics(7);
41
41
 
42
- if (!stats.enabled) {
43
- console.log(' Telemetry is disabled — no speed data available.');
44
- console.log(
45
- ' To enable, ensure "system.telemetry" is not set to false in .claude/config.json'
46
- );
47
- console.log();
48
- return;
49
- }
50
-
51
42
  if (stats.totalEvents === 0) {
52
43
  console.log('━━━ SPEED TELEMETRY (last 7 days) ━━━');
53
44
  console.log(' No telemetry data yet.');
@@ -3,13 +3,11 @@
3
3
  * Purpose: Telemetry management commands (show, clear)
4
4
  */
5
5
 
6
- import { getConfig } from '../config.js';
7
6
  import {
8
7
  displayStatistics as showTelemetryStats,
9
8
  clearTelemetry as clearTelemetryData
10
9
  } from '../utils/telemetry.js';
11
10
  import { promptConfirmation } from '../utils/interactive-ui.js';
12
- import logger from '../utils/logger.js';
13
11
  import { success, info } from './helpers.js';
14
12
 
15
13
  /**
@@ -25,19 +23,6 @@ export async function runShowTelemetry() {
25
23
  * Why: Allow users to reset telemetry
26
24
  */
27
25
  export async function runClearTelemetry() {
28
- const config = await getConfig();
29
-
30
- if (!config.system?.telemetry && config.system?.telemetry !== undefined) {
31
- logger.warning('⚠️ Telemetry is currently disabled.');
32
- logger.info('To re-enable (default), remove or set to true in .claude/config.json:');
33
- logger.info('{');
34
- logger.info(' "system": {');
35
- logger.info(' "telemetry": true');
36
- logger.info(' }');
37
- logger.info('}');
38
- return;
39
- }
40
-
41
26
  const confirmed = await promptConfirmation(
42
27
  'Are you sure you want to clear all telemetry data?'
43
28
  );
package/lib/config.js CHANGED
@@ -62,7 +62,6 @@ const HARDCODED = {
62
62
  },
63
63
  system: {
64
64
  debug: false, // Controlled by --debug flag
65
- telemetry: true, // Opt-out telemetry for debugging (local only, set to false to disable)
66
65
  wslCheckTimeout: 15000 // System behavior
67
66
  },
68
67
  git: {
@@ -32,6 +32,7 @@ import { loadPreset } from '../utils/preset-loader.js';
32
32
  import { getVersion } from '../utils/package-info.js';
33
33
  import logger from '../utils/logger.js';
34
34
  import { getConfig } from '../config.js';
35
+ import { recordMetric } from '../utils/metrics.js';
35
36
 
36
37
  /**
37
38
  * Configuration loaded from lib/config.js
@@ -218,6 +219,23 @@ const main = async () => {
218
219
  console.log();
219
220
  }
220
221
 
222
+ // Record blocked analysis metric
223
+ recordMetric('analysis.completed', {
224
+ files: validFiles.map(f => f.path),
225
+ fileCount: validFiles.length,
226
+ issues: result.issues,
227
+ details: (result.details || []).map(d => ({
228
+ severity: d.severity, category: d.category,
229
+ description: d.description, file: d.file
230
+ })),
231
+ blocked: true,
232
+ duration: Date.now() - startTime,
233
+ preset: presetName,
234
+ orchestrated: filesData.length >= 3
235
+ }).catch((err) => {
236
+ logger.debug('pre-commit - main', 'Metrics recording failed (blocked)', err);
237
+ });
238
+
221
239
  // Generate resolution prompt if needed
222
240
  if (shouldGeneratePrompt(result)) {
223
241
  const resolutionPath = await generateResolutionPrompt(result, {
@@ -235,6 +253,23 @@ const main = async () => {
235
253
  process.exit(1);
236
254
  }
237
255
 
256
+ // Record successful analysis metric
257
+ recordMetric('analysis.completed', {
258
+ files: validFiles.map(f => f.path),
259
+ fileCount: validFiles.length,
260
+ issues: result.issues,
261
+ details: (result.details || []).map(d => ({
262
+ severity: d.severity, category: d.category,
263
+ description: d.description, file: d.file
264
+ })),
265
+ blocked: false,
266
+ duration: Date.now() - startTime,
267
+ preset: presetName,
268
+ orchestrated: filesData.length >= 3
269
+ }).catch((err) => {
270
+ logger.debug('pre-commit - main', 'Metrics recording failed (success)', err);
271
+ });
272
+
238
273
  // Success
239
274
  const duration = ((Date.now() - startTime) / 1000).toFixed(2);
240
275
  console.log(`\n⏱️ Analysis time: ${duration}s`);
@@ -242,6 +277,14 @@ const main = async () => {
242
277
 
243
278
  process.exit(0);
244
279
  } catch (error) {
280
+ // Record analysis failure metric
281
+ recordMetric('analysis.failed', {
282
+ errorMessage: error.message,
283
+ duration: Date.now() - startTime
284
+ }).catch((err) => {
285
+ logger.debug('pre-commit - main', 'Metrics recording failed (error)', err);
286
+ });
287
+
245
288
  const duration = ((Date.now() - startTime) / 1000).toFixed(2);
246
289
  console.log(`\n⏱️ Analysis time: ${duration}s`);
247
290
 
@@ -25,6 +25,7 @@ import { getVersion } from '../utils/package-info.js';
25
25
  import logger from '../utils/logger.js';
26
26
  import { getConfig } from '../config.js';
27
27
  import { getOrPromptTaskId, formatWithTaskId } from '../utils/task-id.js';
28
+ import { recordMetric } from '../utils/metrics.js';
28
29
 
29
30
  /**
30
31
  * Builds commit message generation prompt
@@ -265,6 +266,14 @@ const main = async () => {
265
266
  // Write to commit message file
266
267
  await fs.writeFile(commitMsgFile, `${message}\n`, 'utf8');
267
268
 
269
+ // Record commit generation metric
270
+ recordMetric('commit.generated', {
271
+ fileCount: filesData.length,
272
+ files: filesData.map(f => f.path),
273
+ duration: Date.now() - startTime,
274
+ success: true
275
+ }).catch(() => {});
276
+
268
277
  const duration = ((Date.now() - startTime) / 1000).toFixed(2);
269
278
  console.error(`⏱️ Message generation completed in ${duration}s`);
270
279
 
@@ -272,6 +281,12 @@ const main = async () => {
272
281
 
273
282
  process.exit(0);
274
283
  } catch (error) {
284
+ // Record commit failure metric
285
+ recordMetric('commit.failed', {
286
+ errorMessage: error.message,
287
+ duration: Date.now() - startTime
288
+ }).catch(() => {});
289
+
275
290
  const duration = ((Date.now() - startTime) / 1000).toFixed(2);
276
291
  console.error(`⏱️ Message generation failed after ${duration}s`);
277
292
 
@@ -34,6 +34,7 @@ import { analyzeCode } from './claude-client.js';
34
34
  import { orchestrateBatches } from './diff-analysis-orchestrator.js';
35
35
  import { buildAnalysisPrompt } from './prompt-builder.js';
36
36
  import logger from './logger.js';
37
+ import { recordMetric } from './metrics.js';
37
38
 
38
39
  /**
39
40
  * Standard file data schema used throughout the analysis pipeline
@@ -448,6 +449,15 @@ export const runAnalysis = async (filesData, config, options = {}) => {
448
449
 
449
450
  result = consolidateResults(results);
450
451
 
452
+ // Record orchestration metric
453
+ recordMetric('orchestration.completed', {
454
+ fileCount: filesData.length,
455
+ batchCount: batches.length,
456
+ batchBreakdown: batches.map(b => ({ files: b.files.length, model: b.model })),
457
+ orchestrationTime,
458
+ hook
459
+ }).catch(() => {});
460
+
451
461
  if (saveDebug) {
452
462
  const { saveDebugResponse } = await import('./claude-client.js');
453
463
  await saveDebugResponse(
@@ -25,7 +25,7 @@ import { loadPrompt } from './prompt-builder.js';
25
25
  import { formatBlockingIssues, getAffectedFiles, formatFileContents } from './resolution-prompt.js';
26
26
  import { getRepoRoot, getRepoName, getCurrentBranch } from './git-operations.js';
27
27
  import logger from './logger.js';
28
- import { recordEvent } from './telemetry.js';
28
+ import { recordMetric } from './metrics.js';
29
29
 
30
30
  const JUDGE_DEFAULT_MODEL = 'sonnet';
31
31
  const JUDGE_TIMEOUT = 120000;
@@ -239,14 +239,21 @@ const judgeAndFix = async (analysisResult, filesData, config) => {
239
239
  );
240
240
  }
241
241
 
242
- // Record telemetry
243
- await recordEvent('judge', {
242
+ // Record metrics
243
+ recordMetric('judge.completed', {
244
244
  model,
245
245
  issueCount: allIssues.length,
246
246
  fixedCount,
247
247
  falsePositiveCount,
248
- unresolvedCount
249
- });
248
+ unresolvedCount,
249
+ falsePositives: fixes.filter(f => f.assessment === 'FALSE_POSITIVE')
250
+ .map(f => ({ explanation: f.explanation || '' })),
251
+ unresolved: remainingIssues.map(i => ({
252
+ severity: i.severity, description: i.description || i.message || '', file: i.file
253
+ })),
254
+ fixed: fixes.filter((f, idx) => f.assessment === 'TRUE_ISSUE' && resolvedIndices.has(idx))
255
+ .map(f => ({ file: f.file, explanation: f.explanation || '' }))
256
+ }).catch(() => {});
250
257
 
251
258
  return { fixedCount, falsePositiveCount, remainingIssues, verdicts };
252
259
  };
@@ -0,0 +1,185 @@
1
+ /**
2
+ * File: metrics.js
3
+ * Purpose: Centralized metrics collection for code quality observability
4
+ *
5
+ * Design principles:
6
+ * - ALWAYS ON: No opt-in/opt-out toggle
7
+ * - LOCAL ONLY: Data never leaves user's machine
8
+ * - FIRE-AND-FORGET: All errors swallowed, never blocks callers
9
+ * - STRUCTURED LOGS: JSONL format with typed events
10
+ * - AUTO-ROTATION: 90-day retention for quality trend analysis
11
+ *
12
+ * Event schema:
13
+ * {
14
+ * id: "1703612345678-001-a3f",
15
+ * timestamp: "2026-03-20T10:30:00.000Z",
16
+ * type: "analysis.completed",
17
+ * repoId: "owner/repo",
18
+ * data: { ... }
19
+ * }
20
+ *
21
+ * Dependencies:
22
+ * - fs/promises: File operations
23
+ * - logger: Debug logging
24
+ * - git-operations: getRepoRoot
25
+ * - github-client: parseGitHubRepo (lazy, cached)
26
+ */
27
+
28
+ import fs from 'fs/promises';
29
+ import path from 'path';
30
+ import logger from './logger.js';
31
+ import { getRepoRoot } from './git-operations.js';
32
+
33
+ /**
34
+ * Counter for generating unique IDs within the same millisecond
35
+ */
36
+ let eventCounter = 0;
37
+
38
+ /**
39
+ * Cached repoId: undefined = not resolved, null = failed, string = resolved
40
+ */
41
+ let cachedRepoId;
42
+
43
+ /**
44
+ * Generate unique event ID
45
+ * Format: timestamp-counter-random (e.g., 1703612345678-001-a3f)
46
+ *
47
+ * @returns {string} Unique event ID
48
+ */
49
+ const generateEventId = () => {
50
+ const timestamp = Date.now();
51
+ const counter = String(eventCounter++).padStart(3, '0');
52
+ const random = Math.random().toString(36).substring(2, 5);
53
+
54
+ if (eventCounter > 999) {
55
+ eventCounter = 0;
56
+ }
57
+
58
+ return `${timestamp}-${counter}-${random}`;
59
+ };
60
+
61
+ /**
62
+ * Get metrics directory path
63
+ * @returns {string} Metrics directory path under repo root
64
+ */
65
+ const getMetricsDir = () => {
66
+ try {
67
+ return path.join(getRepoRoot(), '.claude', 'metrics');
68
+ } catch {
69
+ return path.join(process.cwd(), '.claude', 'metrics');
70
+ }
71
+ };
72
+
73
+ /**
74
+ * Get current metrics log file path (date-stamped)
75
+ * @returns {string} Current log file path
76
+ */
77
+ const getCurrentLogFile = () => {
78
+ const date = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
79
+ return path.join(getMetricsDir(), `events-${date}.jsonl`);
80
+ };
81
+
82
+ /**
83
+ * Resolve repository identifier from git remote URL
84
+ * Uses dynamic import() to avoid circular deps. Cached per-process.
85
+ *
86
+ * @returns {Promise<string|null>} Repository full name (owner/repo) or null
87
+ */
88
+ export const getRepoId = async () => {
89
+ if (cachedRepoId !== undefined) {
90
+ return cachedRepoId;
91
+ }
92
+
93
+ try {
94
+ const { parseGitHubRepo } = await import('./github-client.js');
95
+ const repo = parseGitHubRepo();
96
+ cachedRepoId = repo.fullName;
97
+ } catch {
98
+ cachedRepoId = null;
99
+ }
100
+
101
+ return cachedRepoId;
102
+ };
103
+
104
+ /**
105
+ * Record a metric event
106
+ * Fire-and-forget: never throws, never blocks
107
+ *
108
+ * @param {string} eventType - Event type (e.g., 'analysis.completed', 'judge.completed')
109
+ * @param {Object} data - Event-specific data
110
+ * @returns {Promise<void>}
111
+ */
112
+ export const recordMetric = async (eventType, data = {}) => {
113
+ try {
114
+ const dir = getMetricsDir();
115
+ await fs.mkdir(dir, { recursive: true });
116
+
117
+ const repoId = await getRepoId();
118
+
119
+ const event = {
120
+ id: generateEventId(),
121
+ timestamp: new Date().toISOString(),
122
+ type: eventType,
123
+ repoId,
124
+ data
125
+ };
126
+
127
+ const logFile = getCurrentLogFile();
128
+ await fs.appendFile(logFile, `${JSON.stringify(event)}\n`, 'utf8');
129
+
130
+ logger.debug('metrics - recordMetric', `Event logged: ${eventType}`);
131
+ } catch (error) {
132
+ // Fire-and-forget: never throw
133
+ logger.debug('metrics - recordMetric', 'Failed to record metric', error);
134
+ }
135
+ };
136
+
137
+ /**
138
+ * Rotate old metrics files
139
+ * Deletes files older than maxDays (default: 90)
140
+ *
141
+ * @param {number} maxDays - Keep files newer than this many days
142
+ * @returns {Promise<void>}
143
+ */
144
+ export const rotateMetrics = async (maxDays = 90) => {
145
+ try {
146
+ const dir = getMetricsDir();
147
+ const files = await fs.readdir(dir);
148
+
149
+ const logFiles = files.filter((f) => f.endsWith('.jsonl'));
150
+
151
+ const cutoffDate = new Date();
152
+ cutoffDate.setDate(cutoffDate.getDate() - maxDays);
153
+ const cutoffStr = cutoffDate.toISOString().split('T')[0];
154
+
155
+ for (const file of logFiles) {
156
+ const match = file.match(/events-(\d{4}-\d{2}-\d{2})\.jsonl/);
157
+ if (match && match[1] < cutoffStr) {
158
+ await fs.unlink(path.join(dir, file));
159
+ logger.debug('metrics - rotateMetrics', `Deleted old metrics file: ${file}`);
160
+ }
161
+ }
162
+ } catch (error) {
163
+ logger.debug('metrics - rotateMetrics', 'Failed to rotate metrics', error);
164
+ }
165
+ };
166
+
167
+ /**
168
+ * Clear all metrics data
169
+ * @returns {Promise<void>}
170
+ */
171
+ export const clearMetrics = async () => {
172
+ try {
173
+ const dir = getMetricsDir();
174
+ const files = await fs.readdir(dir);
175
+ const logFiles = files.filter((f) => f.endsWith('.jsonl'));
176
+
177
+ for (const file of logFiles) {
178
+ await fs.unlink(path.join(dir, file));
179
+ }
180
+
181
+ logger.debug('metrics - clearMetrics', `Cleared ${logFiles.length} metrics files`);
182
+ } catch (error) {
183
+ logger.debug('metrics - clearMetrics', 'Failed to clear metrics', error);
184
+ }
185
+ };
@@ -15,6 +15,7 @@ import fs from 'fs';
15
15
  import path from 'path';
16
16
  import { execSync } from 'child_process';
17
17
  import logger from './logger.js';
18
+ import { recordMetric } from './metrics.js';
18
19
 
19
20
  /**
20
21
  * Get repository root directory
@@ -72,6 +73,20 @@ export const recordPRAnalysis = (data) => {
72
73
 
73
74
  fs.appendFileSync(statsFile, `${JSON.stringify(record)}\n`, 'utf8');
74
75
 
76
+ // Dual-write to unified metrics
77
+ recordMetric('pr.analyzed', {
78
+ url: data.url,
79
+ number: data.number,
80
+ repo: data.repo,
81
+ preset: data.preset,
82
+ verdict: data.verdict,
83
+ totalIssues: data.totalIssues,
84
+ commentsPosted: data.commentsPosted,
85
+ commentsSkipped: data.commentsSkipped
86
+ }).catch((err) => {
87
+ logger.debug('pr-statistics - recordPRAnalysis', 'Metrics write failed', err);
88
+ });
89
+
75
90
  logger.debug('pr-statistics - recordPRAnalysis', 'Statistics recorded', {
76
91
  statsFile,
77
92
  totalIssues: data.totalIssues
@@ -24,7 +24,7 @@ import fs from 'fs/promises';
24
24
  import fsSync from 'fs';
25
25
  import path from 'path';
26
26
  import logger from './logger.js';
27
- import config from '../config.js';
27
+ import { recordMetric, rotateMetrics as rotateMetricsData, clearMetrics as clearMetricsData } from './metrics.js';
28
28
 
29
29
  /**
30
30
  * Telemetry event structure
@@ -82,15 +82,6 @@ const getCurrentLogFile = () => {
82
82
  return path.join(getTelemetryDir(), `telemetry-${date}.jsonl`);
83
83
  };
84
84
 
85
- /**
86
- * Check if telemetry is enabled
87
- * Why: Enabled by default, users must explicitly disable
88
- *
89
- * @returns {boolean} True if telemetry is enabled (default: true)
90
- */
91
- const isTelemetryEnabled = () =>
92
- // Enabled by default - only disabled if explicitly set to false
93
- config.system?.telemetry !== false;
94
85
  /**
95
86
  * Ensure telemetry directory exists
96
87
  * Why: Create on first use
@@ -134,11 +125,6 @@ const appendEvent = async (event) => {
134
125
  * @param {Object} metadata - Event metadata (success, retryAttempt, totalRetries)
135
126
  */
136
127
  export const recordEvent = async (eventType, eventData = {}, metadata = {}) => {
137
- // Check opt-in
138
- if (!isTelemetryEnabled()) {
139
- return;
140
- }
141
-
142
128
  try {
143
129
  // Ensure directory exists
144
130
  await ensureTelemetryDir();
@@ -156,6 +142,11 @@ export const recordEvent = async (eventType, eventData = {}, metadata = {}) => {
156
142
 
157
143
  // Append to log
158
144
  await appendEvent(event);
145
+
146
+ // Dual-write to unified metrics
147
+ recordMetric(eventType, { ...eventData, _meta: metadata }).catch((err) => {
148
+ logger.debug('telemetry - recordEvent', 'Metrics write failed', err);
149
+ });
159
150
  } catch (error) {
160
151
  // Don't fail on telemetry errors
161
152
  logger.debug('telemetry - recordEvent', 'Failed to record event', error);
@@ -289,14 +280,6 @@ const readTelemetryEvents = async (maxDays = 7) => {
289
280
  * @returns {Promise<Object>} Statistics object
290
281
  */
291
282
  export const getStatistics = async (maxDays = 7) => {
292
- if (!isTelemetryEnabled()) {
293
- return {
294
- enabled: false,
295
- message:
296
- 'Telemetry is disabled. To enable (default), remove or set "system.telemetry: true" in .claude/config.json'
297
- };
298
- }
299
-
300
283
  try {
301
284
  const events = await readTelemetryEvents(maxDays);
302
285
 
@@ -415,10 +398,6 @@ export const getStatistics = async (maxDays = 7) => {
415
398
  * @param {number} maxDays - Keep files newer than this many days (default: 30)
416
399
  */
417
400
  export const rotateTelemetry = async (maxDays = 30) => {
418
- if (!isTelemetryEnabled()) {
419
- return;
420
- }
421
-
422
401
  try {
423
402
  const dir = getTelemetryDir();
424
403
  const files = await fs.readdir(dir);
@@ -450,6 +429,9 @@ export const rotateTelemetry = async (maxDays = 30) => {
450
429
  } catch (error) {
451
430
  logger.debug('telemetry - rotateTelemetry', 'Failed to rotate telemetry', error);
452
431
  }
432
+
433
+ // Also rotate unified metrics (90-day retention)
434
+ await rotateMetricsData().catch(() => {});
453
435
  };
454
436
 
455
437
  /**
@@ -479,6 +461,9 @@ export const clearTelemetry = async () => {
479
461
  } catch (error) {
480
462
  logger.error('telemetry - clearTelemetry', 'Failed to clear telemetry', error);
481
463
  }
464
+
465
+ // Also clear unified metrics
466
+ await clearMetricsData().catch(() => {});
482
467
  };
483
468
 
484
469
  /**
@@ -492,18 +477,6 @@ export const displayStatistics = async () => {
492
477
  console.log('║ TELEMETRY STATISTICS ║');
493
478
  console.log('╚════════════════════════════════════════════════════════════════════╝\n');
494
479
 
495
- if (!stats.enabled) {
496
- console.log(stats.message);
497
- console.log('\nTelemetry is disabled in your configuration.');
498
- console.log('To re-enable (default), remove or set to true in .claude/config.json:');
499
- console.log('{');
500
- console.log(' "system": {');
501
- console.log(' "telemetry": true');
502
- console.log(' }');
503
- console.log('}\n');
504
- return;
505
- }
506
-
507
480
  if (stats.error) {
508
481
  console.log(`Error: ${stats.error}`);
509
482
  console.log(`Details: ${stats.details}\n`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-git-hooks",
3
- "version": "2.32.0",
3
+ "version": "2.33.0",
4
4
  "description": "Git hooks with Claude CLI for code analysis and automatic commit messages",
5
5
  "type": "module",
6
6
  "bin": {