claude-git-hooks 2.32.0 → 2.33.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.
- package/CHANGELOG.md +33 -0
- package/CLAUDE.md +22 -0
- package/lib/commands/create-pr.js +13 -0
- package/lib/commands/diff-batch-info.js +0 -9
- package/lib/commands/helpers.js +69 -2
- package/lib/commands/telemetry-cmd.js +0 -15
- package/lib/config.js +0 -1
- package/lib/hooks/pre-commit.js +43 -0
- package/lib/hooks/prepare-commit-msg.js +15 -0
- package/lib/utils/analysis-engine.js +10 -0
- package/lib/utils/claude-client.js +177 -37
- package/lib/utils/judge.js +12 -5
- package/lib/utils/metrics.js +185 -0
- package/lib/utils/pr-statistics.js +15 -0
- package/lib/utils/telemetry.js +12 -39
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,39 @@ 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.1] - 2026-03-26
|
|
9
|
+
|
|
10
|
+
### 🔧 Changed
|
|
11
|
+
- Improved WSL Claude CLI detection to resolve Claude's path via login shell when installed via nvm or user-scoped npm prefix
|
|
12
|
+
- Added support for detecting Claude CLI in non-default WSL distros when the default distro is docker-desktop or similar
|
|
13
|
+
- Switched to execFileSync for WSL distro listing to avoid cmd.exe quote mangling issues
|
|
14
|
+
- Updated README-NPM with release workflow commands documentation and WSL troubleshooting guide
|
|
15
|
+
|
|
16
|
+
### 🐛 Fixed
|
|
17
|
+
- Fixed Claude CLI not being detected on Windows when running from Git Bash with Claude installed inside WSL via nvm (#120)
|
|
18
|
+
- Fixed WSL distro detection failing due to UTF-16LE encoding from wsl.exe output
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
## [2.33.0] - 2026-03-20
|
|
22
|
+
|
|
23
|
+
### ✨ Added
|
|
24
|
+
- Always-on metrics system for code quality observability (#41) - collects structured JSONL events locally with 90-day retention for trend analysis
|
|
25
|
+
- Team-based reviewer selection via GitHub Teams API (#115, #36) - automatically resolves team members and excludes PR author when selecting reviewers
|
|
26
|
+
- New metrics module (`lib/utils/metrics.js`) with fire-and-forget event recording that never blocks callers
|
|
27
|
+
- Metrics instrumentation for pre-commit analysis, commit message generation, PR creation, judge verdicts, and orchestration batches
|
|
28
|
+
- Documented proven implementation patterns in CLAUDE.md covering testability, git operations, and architecture best practices
|
|
29
|
+
|
|
30
|
+
### 🔧 Changed
|
|
31
|
+
- Telemetry system now always enabled - removed opt-out toggle for consistent observability
|
|
32
|
+
- Existing telemetry events dual-write to unified metrics system for consolidated analysis
|
|
33
|
+
- PR statistics module now records to both legacy JSONL and new metrics system
|
|
34
|
+
- Judge module switched from telemetry to metrics recording with richer event data including false positives and unresolved issues
|
|
35
|
+
|
|
36
|
+
### 🗑️ Removed
|
|
37
|
+
- Removed `system.telemetry` configuration option - metrics are now always collected locally
|
|
38
|
+
- Removed telemetry disabled warnings from `batch-info` and `telemetry-cmd` commands
|
|
39
|
+
|
|
40
|
+
|
|
8
41
|
## [2.32.0] - 2026-03-19
|
|
9
42
|
|
|
10
43
|
### ✨ 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.');
|
package/lib/commands/helpers.js
CHANGED
|
@@ -18,6 +18,7 @@ import os from 'os';
|
|
|
18
18
|
import https from 'https';
|
|
19
19
|
import { fileURLToPath } from 'url';
|
|
20
20
|
import { dirname } from 'path';
|
|
21
|
+
import { getRunningWSLDistros } from '../utils/claude-client.js';
|
|
21
22
|
|
|
22
23
|
// Why: ES6 modules don't have __dirname, need to recreate it
|
|
23
24
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -130,9 +131,46 @@ export function isWindows() {
|
|
|
130
131
|
return os.platform() === 'win32' || process.env.OS === 'Windows_NT';
|
|
131
132
|
}
|
|
132
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Resolve Claude's absolute path inside WSL via login shell
|
|
136
|
+
* Why: If Claude is installed via nvm or user-scoped npm prefix in WSL,
|
|
137
|
+
* it's only in PATH when .bashrc/.profile is sourced (login/interactive shell).
|
|
138
|
+
* A bare `wsl claude` runs a non-interactive shell that skips profile loading.
|
|
139
|
+
*
|
|
140
|
+
* @param {string|null} distro - WSL distro name (null = default distro)
|
|
141
|
+
* @returns {string|null} Absolute Linux path to claude binary, or null
|
|
142
|
+
*/
|
|
143
|
+
export function resolveClaudePathInWSL(distro = null) {
|
|
144
|
+
const distroFlag = distro ? `-d "${distro}" ` : '';
|
|
145
|
+
try {
|
|
146
|
+
const resolvedPath = execSync(`wsl ${distroFlag}bash -lc "which claude"`, {
|
|
147
|
+
encoding: 'utf8',
|
|
148
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
149
|
+
timeout: 10000
|
|
150
|
+
}).trim();
|
|
151
|
+
|
|
152
|
+
if (resolvedPath && resolvedPath.startsWith('/')) {
|
|
153
|
+
// Verify the resolved path actually works
|
|
154
|
+
execSync(`wsl ${distroFlag}${resolvedPath} --version`, {
|
|
155
|
+
stdio: 'ignore',
|
|
156
|
+
timeout: 10000
|
|
157
|
+
});
|
|
158
|
+
return resolvedPath;
|
|
159
|
+
}
|
|
160
|
+
return null;
|
|
161
|
+
} catch (e) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
133
166
|
/**
|
|
134
167
|
* Get Claude command based on platform
|
|
135
168
|
* Why: On Windows, try native Claude first, then WSL as fallback
|
|
169
|
+
* Strategy:
|
|
170
|
+
* 1. Native Windows `claude` (installed via npm/scoop/choco on Windows)
|
|
171
|
+
* 2. Direct `wsl claude` (Claude in WSL default PATH)
|
|
172
|
+
* 3. WSL login-shell resolution (Claude installed via nvm or custom PATH in WSL)
|
|
173
|
+
* 4. Non-default distro scan (default may be docker-desktop, not Ubuntu)
|
|
136
174
|
* @returns {string}
|
|
137
175
|
*/
|
|
138
176
|
export function getClaudeCommand() {
|
|
@@ -142,8 +180,37 @@ export function getClaudeCommand() {
|
|
|
142
180
|
execSync('claude --version', { stdio: 'ignore', timeout: 3000 });
|
|
143
181
|
return 'claude';
|
|
144
182
|
} catch (e) {
|
|
145
|
-
//
|
|
146
|
-
|
|
183
|
+
// Try direct WSL (Claude in default non-interactive PATH)
|
|
184
|
+
try {
|
|
185
|
+
execSync('wsl claude --version', { stdio: 'ignore', timeout: 10000 });
|
|
186
|
+
return 'wsl claude';
|
|
187
|
+
} catch (e2) {
|
|
188
|
+
// Try WSL with login shell (Claude installed via nvm or custom PATH)
|
|
189
|
+
const resolvedPath = resolveClaudePathInWSL();
|
|
190
|
+
if (resolvedPath) {
|
|
191
|
+
return `wsl ${resolvedPath}`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Strategy 4: Non-default WSL distro (e.g., default is docker-desktop)
|
|
195
|
+
const distros = getRunningWSLDistros('wsl');
|
|
196
|
+
for (const distro of distros) {
|
|
197
|
+
try {
|
|
198
|
+
execSync(`wsl -d "${distro}" claude --version`, {
|
|
199
|
+
stdio: 'ignore',
|
|
200
|
+
timeout: 10000
|
|
201
|
+
});
|
|
202
|
+
return `wsl -d "${distro}" claude`;
|
|
203
|
+
} catch (distroError) {
|
|
204
|
+
const resolved = resolveClaudePathInWSL(distro);
|
|
205
|
+
if (resolved) {
|
|
206
|
+
return `wsl -d "${distro}" ${resolved}`;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// All attempts failed — return best-effort fallback
|
|
212
|
+
return 'wsl claude';
|
|
213
|
+
}
|
|
147
214
|
}
|
|
148
215
|
}
|
|
149
216
|
return 'claude';
|
|
@@ -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
package/lib/hooks/pre-commit.js
CHANGED
|
@@ -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(
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* - claude-diagnostics: Error detection and formatting
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { spawn, execSync } from 'child_process';
|
|
18
|
+
import { spawn, execSync, execFileSync } from 'child_process';
|
|
19
19
|
import fs from 'fs/promises';
|
|
20
20
|
import path from 'path';
|
|
21
21
|
import os from 'os';
|
|
@@ -70,6 +70,37 @@ const isWSLAvailable = () => {
|
|
|
70
70
|
}
|
|
71
71
|
};
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Get running WSL distros, excluding Docker-internal ones
|
|
75
|
+
* Why: The default WSL distro may be docker-desktop (which doesn't have Claude).
|
|
76
|
+
* We need to find the actual Linux distro (e.g., Ubuntu) where Claude is installed.
|
|
77
|
+
*
|
|
78
|
+
* @param {string} wslPath - Absolute path to wsl.exe
|
|
79
|
+
* @returns {string[]} List of running non-Docker distro names
|
|
80
|
+
*/
|
|
81
|
+
export const getRunningWSLDistros = (wslPath) => {
|
|
82
|
+
try {
|
|
83
|
+
// Use execFileSync to bypass cmd.exe shell quoting — args passed directly to wsl.exe
|
|
84
|
+
const raw = execFileSync(wslPath, ['-l', '--running', '-q'], {
|
|
85
|
+
timeout: 5000,
|
|
86
|
+
stdio: ['pipe', 'pipe', 'ignore']
|
|
87
|
+
});
|
|
88
|
+
// Why UTF-16LE: wsl.exe is a native Windows executable that outputs Unicode in Windows' default
|
|
89
|
+
// encoding (UTF-16LE), not UTF-8. The null bytes (\0) are BOM artifacts and padding characters
|
|
90
|
+
// from the UTF-16LE encoding that must be stripped to get clean distro names.
|
|
91
|
+
const output = raw.toString('utf16le');
|
|
92
|
+
return output
|
|
93
|
+
.split(/\r?\n/)
|
|
94
|
+
.map((s) => s.replace(/\0/g, '').trim())
|
|
95
|
+
.filter((s) => s && !s.toLowerCase().includes('docker'));
|
|
96
|
+
} catch (e) {
|
|
97
|
+
logger.debug('claude-client - getRunningWSLDistros', 'Failed to list WSL distros', {
|
|
98
|
+
error: e.message
|
|
99
|
+
});
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
73
104
|
/**
|
|
74
105
|
* Get Claude command configuration for current platform
|
|
75
106
|
* Why: On Windows, try native Claude first, then WSL as fallback
|
|
@@ -112,11 +143,12 @@ const getClaudeCommand = () => {
|
|
|
112
143
|
// Node 24: Resolve wsl.exe absolute path to avoid shell: true
|
|
113
144
|
const wslPath = which('wsl');
|
|
114
145
|
if (wslPath) {
|
|
146
|
+
const wslCheckTimeout = config.system.wslCheckTimeout || 15000;
|
|
147
|
+
|
|
148
|
+
// Strategy 1: Direct `wsl claude` (Claude in default non-interactive PATH)
|
|
149
|
+
// Use execFileSync to bypass cmd.exe — args passed directly to wsl.exe without quote mangling
|
|
115
150
|
try {
|
|
116
|
-
|
|
117
|
-
// Increased timeout from 5s to 15s to handle system load better
|
|
118
|
-
const wslCheckTimeout = config.system.wslCheckTimeout || 15000;
|
|
119
|
-
execSync(`"${wslPath}" claude --version`, {
|
|
151
|
+
execFileSync(wslPath, ['claude', '--version'], {
|
|
120
152
|
stdio: 'ignore',
|
|
121
153
|
timeout: wslCheckTimeout
|
|
122
154
|
});
|
|
@@ -125,11 +157,9 @@ const getClaudeCommand = () => {
|
|
|
125
157
|
});
|
|
126
158
|
return { command: wslPath, args: ['claude'] };
|
|
127
159
|
} catch (wslError) {
|
|
128
|
-
// Differentiate error types for accurate user feedback
|
|
129
160
|
const errorMsg = wslError.message || '';
|
|
130
|
-
const wslCheckTimeout = config.system.wslCheckTimeout || 15000;
|
|
131
161
|
|
|
132
|
-
// Timeout: Transient system load issue
|
|
162
|
+
// Timeout: Transient system load issue — don't try fallback
|
|
133
163
|
if (errorMsg.includes('ETIMEDOUT')) {
|
|
134
164
|
throw new ClaudeClientError(
|
|
135
165
|
'Timeout connecting to WSL - system under heavy load',
|
|
@@ -146,30 +176,132 @@ const getClaudeCommand = () => {
|
|
|
146
176
|
);
|
|
147
177
|
}
|
|
148
178
|
|
|
149
|
-
// Not
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
179
|
+
// Not a timeout — try login-shell fallback below
|
|
180
|
+
logger.debug(
|
|
181
|
+
'claude-client - getClaudeCommand',
|
|
182
|
+
'Direct wsl claude failed, trying login-shell resolution',
|
|
183
|
+
{ error: errorMsg }
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Strategy 2: WSL login-shell resolution (Claude installed via nvm or custom PATH)
|
|
188
|
+
// Why: `wsl claude` runs a non-interactive shell that doesn't source .bashrc/.profile,
|
|
189
|
+
// so Claude installed via nvm or user-scoped npm prefix won't be in PATH.
|
|
190
|
+
// `bash -lc "which claude"` sources the login profile to resolve the full path.
|
|
191
|
+
// Why execFileSync: cmd.exe strips inner quotes from `bash -lc "which claude"`,
|
|
192
|
+
// causing bash to receive `which` and `claude` as separate args. execFileSync bypasses
|
|
193
|
+
// cmd.exe and passes arguments directly, preserving multi-word argument boundaries.
|
|
194
|
+
try {
|
|
195
|
+
const resolvedPath = execFileSync(
|
|
196
|
+
wslPath,
|
|
197
|
+
['bash', '-lc', 'which claude'],
|
|
198
|
+
{
|
|
199
|
+
encoding: 'utf8',
|
|
200
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
201
|
+
timeout: wslCheckTimeout
|
|
202
|
+
}
|
|
203
|
+
).trim();
|
|
204
|
+
|
|
205
|
+
if (resolvedPath && resolvedPath.startsWith('/')) {
|
|
206
|
+
// Verify: run the resolved path via login shell (node may also require login shell)
|
|
207
|
+
execFileSync(wslPath, ['bash', '-lc', `${resolvedPath} --version`], {
|
|
208
|
+
stdio: 'ignore',
|
|
209
|
+
timeout: wslCheckTimeout
|
|
159
210
|
});
|
|
211
|
+
logger.debug(
|
|
212
|
+
'claude-client - getClaudeCommand',
|
|
213
|
+
'Using WSL Claude CLI via login-shell resolution',
|
|
214
|
+
{ wslPath, claudePath: resolvedPath }
|
|
215
|
+
);
|
|
216
|
+
// Return loginShell so executeClaude wraps the call in bash -lc
|
|
217
|
+
return { command: wslPath, args: [], loginShell: { path: resolvedPath } };
|
|
160
218
|
}
|
|
219
|
+
} catch (loginShellError) {
|
|
220
|
+
logger.debug(
|
|
221
|
+
'claude-client - getClaudeCommand',
|
|
222
|
+
'WSL login-shell resolution failed',
|
|
223
|
+
{ error: loginShellError.message }
|
|
224
|
+
);
|
|
225
|
+
}
|
|
161
226
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
227
|
+
// Strategy 3: Non-default WSL distro (e.g., default is docker-desktop)
|
|
228
|
+
// Why: If the default WSL distro is docker-desktop or another minimal distro,
|
|
229
|
+
// Claude may be installed in a different distro like Ubuntu.
|
|
230
|
+
const distros = getRunningWSLDistros(wslPath);
|
|
231
|
+
logger.debug(
|
|
232
|
+
'claude-client - getClaudeCommand',
|
|
233
|
+
'Trying non-default WSL distros',
|
|
234
|
+
{ distros }
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
for (const distro of distros) {
|
|
238
|
+
// Try direct call in this distro
|
|
239
|
+
try {
|
|
240
|
+
execFileSync(wslPath, ['-d', distro, 'claude', '--version'], {
|
|
241
|
+
stdio: 'ignore',
|
|
242
|
+
timeout: wslCheckTimeout
|
|
243
|
+
});
|
|
244
|
+
logger.debug(
|
|
245
|
+
'claude-client - getClaudeCommand',
|
|
246
|
+
'Using WSL Claude CLI in distro',
|
|
247
|
+
{ wslPath, distro }
|
|
248
|
+
);
|
|
249
|
+
return { command: wslPath, args: ['-d', distro, 'claude'] };
|
|
250
|
+
} catch (distroError) {
|
|
251
|
+
// Try login-shell resolution in this distro
|
|
252
|
+
try {
|
|
253
|
+
const resolved = execFileSync(
|
|
254
|
+
wslPath,
|
|
255
|
+
['-d', distro, 'bash', '-lc', 'which claude'],
|
|
256
|
+
{
|
|
257
|
+
encoding: 'utf8',
|
|
258
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
259
|
+
timeout: wslCheckTimeout
|
|
260
|
+
}
|
|
261
|
+
).trim();
|
|
262
|
+
|
|
263
|
+
if (resolved && resolved.startsWith('/')) {
|
|
264
|
+
execFileSync(
|
|
265
|
+
wslPath,
|
|
266
|
+
['-d', distro, 'bash', '-lc', `${resolved} --version`],
|
|
267
|
+
{
|
|
268
|
+
stdio: 'ignore',
|
|
269
|
+
timeout: wslCheckTimeout
|
|
270
|
+
}
|
|
271
|
+
);
|
|
272
|
+
logger.debug(
|
|
273
|
+
'claude-client - getClaudeCommand',
|
|
274
|
+
'Using WSL Claude CLI via login-shell in distro',
|
|
275
|
+
{ wslPath, distro, claudePath: resolved }
|
|
276
|
+
);
|
|
277
|
+
// Return loginShell so executeClaude wraps the call in bash -lc
|
|
278
|
+
return {
|
|
279
|
+
command: wslPath,
|
|
280
|
+
args: ['-d', distro],
|
|
281
|
+
loginShell: { path: resolved }
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
} catch (distroLoginError) {
|
|
285
|
+
logger.debug(
|
|
286
|
+
'claude-client - getClaudeCommand',
|
|
287
|
+
`Distro ${distro} does not have Claude`,
|
|
288
|
+
{ error: distroLoginError.message }
|
|
289
|
+
);
|
|
170
290
|
}
|
|
171
|
-
}
|
|
291
|
+
}
|
|
172
292
|
}
|
|
293
|
+
|
|
294
|
+
// All strategies failed
|
|
295
|
+
throw new ClaudeClientError('Claude CLI not found in WSL', {
|
|
296
|
+
context: {
|
|
297
|
+
platform: 'Windows',
|
|
298
|
+
wslPath,
|
|
299
|
+
error: 'Claude not found via direct call or login-shell resolution',
|
|
300
|
+
triedDistros: distros,
|
|
301
|
+
suggestion:
|
|
302
|
+
'Install Claude in WSL: wsl -e bash -c "npm install -g @anthropic-ai/claude-cli"'
|
|
303
|
+
}
|
|
304
|
+
});
|
|
173
305
|
}
|
|
174
306
|
|
|
175
307
|
throw new ClaudeClientError('Claude CLI not found in Windows or WSL', {
|
|
@@ -212,18 +344,26 @@ const getClaudeCommand = () => {
|
|
|
212
344
|
const executeClaude = (prompt, { timeout = 120000, allowedTools = [], model = null } = {}) =>
|
|
213
345
|
new Promise((resolve, reject) => {
|
|
214
346
|
// Get platform-specific command
|
|
215
|
-
const { command, args } = getClaudeCommand();
|
|
347
|
+
const { command, args, loginShell } = getClaudeCommand();
|
|
216
348
|
|
|
217
|
-
//
|
|
349
|
+
// Build final args — handling differs for login-shell (WSL via bash -lc) vs direct invocation
|
|
218
350
|
const finalArgs = [...args];
|
|
219
|
-
if (
|
|
220
|
-
//
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
finalArgs.push('
|
|
351
|
+
if (loginShell) {
|
|
352
|
+
// Why: When claude is found via WSL login-shell (e.g. nvm-installed node), it must be
|
|
353
|
+
// executed inside bash --login so that .profile/.bashrc set up the correct PATH.
|
|
354
|
+
// All CLI flags are embedded in the bash -c command string (no spaces in flag values).
|
|
355
|
+
const claudeParts = [loginShell.path];
|
|
356
|
+
if (allowedTools.length > 0) claudeParts.push(`--allowedTools ${allowedTools.join(',')}`);
|
|
357
|
+
if (model) claudeParts.push(`--model ${model}`);
|
|
358
|
+
finalArgs.push('bash', '-lc', claudeParts.join(' '));
|
|
359
|
+
} else {
|
|
360
|
+
if (allowedTools.length > 0) {
|
|
361
|
+
// Format: --allowedTools "mcp__github__create_pull_request,mcp__github__get_file_contents"
|
|
362
|
+
finalArgs.push('--allowedTools', allowedTools.join(','));
|
|
363
|
+
}
|
|
364
|
+
if (model) {
|
|
365
|
+
finalArgs.push('--model', model);
|
|
366
|
+
}
|
|
227
367
|
}
|
|
228
368
|
|
|
229
369
|
// CRITICAL FIX: Windows .cmd/.bat file handling
|
package/lib/utils/judge.js
CHANGED
|
@@ -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 {
|
|
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
|
|
243
|
-
|
|
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
|
package/lib/utils/telemetry.js
CHANGED
|
@@ -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
|
|
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`);
|