claude-git-hooks 2.35.2 → 2.43.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 +150 -0
- package/CLAUDE.md +24 -1384
- package/README.md +113 -0
- package/bin/claude-hooks +11 -7
- package/lib/cli-metadata.js +17 -3
- package/lib/commands/analyze-pr.js +270 -145
- package/lib/commands/analyze.js +151 -3
- package/lib/commands/create-pr.js +345 -134
- package/lib/commands/helpers.js +9 -4
- package/lib/commands/hooks.js +5 -5
- package/lib/commands/install.js +77 -28
- package/lib/commands/lint.js +120 -4
- package/lib/config.js +4 -1
- package/lib/hooks/pre-commit.js +26 -5
- package/lib/hooks/prepare-commit-msg.js +78 -4
- package/lib/utils/analysis-engine.js +12 -6
- package/lib/utils/claude-client.js +222 -12
- package/lib/utils/claude-diagnostics.js +5 -4
- package/lib/utils/cost-tracker.js +128 -0
- package/lib/utils/diff-analysis-orchestrator.js +2 -1
- package/lib/utils/git-operations.js +105 -2
- package/lib/utils/hooks-verified-marker.js +121 -0
- package/lib/utils/interactive-ui.js +4 -4
- package/lib/utils/judge.js +4 -3
- package/lib/utils/langfuse-tracer.js +156 -0
- package/lib/utils/linter-runner.js +29 -4
- package/lib/utils/logger.js +30 -5
- package/lib/utils/pr-metadata-engine.js +4 -2
- package/lib/utils/tool-runner.js +4 -3
- package/package.json +4 -2
- package/templates/CUSTOMIZATION_GUIDE.md +2 -2
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: hooks-verified-marker.js
|
|
3
|
+
* Purpose: Manages the .git/.hooks-verified marker file used to coordinate
|
|
4
|
+
* between pre-commit.js (writes marker on success) and prepare-commit-msg.js
|
|
5
|
+
* (reads marker, appends Hooks-Verified trailer).
|
|
6
|
+
*
|
|
7
|
+
* The marker contains the tree-SHA of the staged content at the time pre-commit
|
|
8
|
+
* passed. prepare-commit-msg compares it against the current tree-SHA to detect
|
|
9
|
+
* staging changes between hooks (e.g., user ran `git add` after pre-commit).
|
|
10
|
+
*
|
|
11
|
+
* Design:
|
|
12
|
+
* - No git operations — standalone testable
|
|
13
|
+
* - Marker file lives inside .git/ (not tracked, per-worktree)
|
|
14
|
+
* - Permissions 0o600 (defensive, even though .git/ is user-scoped)
|
|
15
|
+
*
|
|
16
|
+
* Dependencies:
|
|
17
|
+
* - fs (sync operations for simplicity — marker is tiny)
|
|
18
|
+
* - path
|
|
19
|
+
* - logger
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import fs from 'fs';
|
|
23
|
+
import path from 'path';
|
|
24
|
+
import logger from './logger.js';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Returns the path to the hooks-verified marker file
|
|
28
|
+
*
|
|
29
|
+
* @param {string} repoRoot - Absolute path to repository root
|
|
30
|
+
* @returns {string} Absolute path to .git/.hooks-verified
|
|
31
|
+
*/
|
|
32
|
+
const getMarkerPath = (repoRoot) => path.join(repoRoot, '.git', '.hooks-verified');
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Writes the hooks-verified marker file
|
|
36
|
+
*
|
|
37
|
+
* @param {string} repoRoot - Absolute path to repository root
|
|
38
|
+
* @param {string} treeSha - 40-char hex SHA from git write-tree
|
|
39
|
+
* @param {string} version - Package version (e.g., '2.41.0')
|
|
40
|
+
* @throws {Error} If .git/ directory doesn't exist or write fails
|
|
41
|
+
*/
|
|
42
|
+
const writeMarker = (repoRoot, treeSha, version) => {
|
|
43
|
+
const markerPath = getMarkerPath(repoRoot);
|
|
44
|
+
|
|
45
|
+
const payload = JSON.stringify({
|
|
46
|
+
tree: treeSha,
|
|
47
|
+
timestamp: new Date().toISOString(),
|
|
48
|
+
version
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
fs.writeFileSync(markerPath, payload, { mode: 0o600 });
|
|
52
|
+
|
|
53
|
+
logger.debug('hooks-verified-marker - writeMarker', 'Marker written', {
|
|
54
|
+
path: markerPath,
|
|
55
|
+
treeSha
|
|
56
|
+
});
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Reads the hooks-verified marker file
|
|
61
|
+
*
|
|
62
|
+
* @param {string} repoRoot - Absolute path to repository root
|
|
63
|
+
* @returns {Object|null} Parsed marker object, or null if absent/malformed
|
|
64
|
+
*/
|
|
65
|
+
const readMarker = (repoRoot) => {
|
|
66
|
+
const markerPath = getMarkerPath(repoRoot);
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const content = fs.readFileSync(markerPath, 'utf8');
|
|
70
|
+
const parsed = JSON.parse(content);
|
|
71
|
+
|
|
72
|
+
logger.debug('hooks-verified-marker - readMarker', 'Marker read', {
|
|
73
|
+
tree: parsed.tree
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return parsed;
|
|
77
|
+
} catch (err) {
|
|
78
|
+
if (err.code === 'ENOENT') {
|
|
79
|
+
logger.debug('hooks-verified-marker - readMarker', 'No marker file found');
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Malformed JSON or other read error
|
|
84
|
+
logger.warning(`Hooks-verified marker is malformed: ${err.message}`);
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Deletes the hooks-verified marker file (best-effort)
|
|
91
|
+
*
|
|
92
|
+
* @param {string} repoRoot - Absolute path to repository root
|
|
93
|
+
*/
|
|
94
|
+
const consumeMarker = (repoRoot) => {
|
|
95
|
+
const markerPath = getMarkerPath(repoRoot);
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
fs.unlinkSync(markerPath);
|
|
99
|
+
logger.debug('hooks-verified-marker - consumeMarker', 'Marker consumed');
|
|
100
|
+
} catch (err) {
|
|
101
|
+
if (err.code === 'ENOENT') {
|
|
102
|
+
// Already gone — nothing to do
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
logger.debug('hooks-verified-marker - consumeMarker', 'Could not delete marker', {
|
|
106
|
+
error: err.message
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Validates a marker against the current staged tree SHA
|
|
113
|
+
*
|
|
114
|
+
* @param {Object|null} marker - Parsed marker from readMarker()
|
|
115
|
+
* @param {string} currentTreeSha - Current tree SHA from getStagedTreeSha()
|
|
116
|
+
* @returns {boolean} True if marker is valid and tree SHAs match
|
|
117
|
+
*/
|
|
118
|
+
const validateMarker = (marker, currentTreeSha) =>
|
|
119
|
+
marker !== null && marker.tree === currentTreeSha;
|
|
120
|
+
|
|
121
|
+
export { getMarkerPath, writeMarker, readMarker, consumeMarker, validateMarker };
|
|
@@ -267,7 +267,7 @@ export const showSpinner = (message) => {
|
|
|
267
267
|
* @param {string} message - Success message
|
|
268
268
|
*/
|
|
269
269
|
export const showSuccess = (message) => {
|
|
270
|
-
|
|
270
|
+
logger.success(message);
|
|
271
271
|
};
|
|
272
272
|
|
|
273
273
|
/**
|
|
@@ -275,7 +275,7 @@ export const showSuccess = (message) => {
|
|
|
275
275
|
* @param {string} message - Error message
|
|
276
276
|
*/
|
|
277
277
|
export const showError = (message) => {
|
|
278
|
-
|
|
278
|
+
logger.error('interactive-ui', message);
|
|
279
279
|
};
|
|
280
280
|
|
|
281
281
|
/**
|
|
@@ -283,7 +283,7 @@ export const showError = (message) => {
|
|
|
283
283
|
* @param {string} message - Warning message
|
|
284
284
|
*/
|
|
285
285
|
export const showWarning = (message) => {
|
|
286
|
-
|
|
286
|
+
logger.warning(message);
|
|
287
287
|
};
|
|
288
288
|
|
|
289
289
|
/**
|
|
@@ -291,7 +291,7 @@ export const showWarning = (message) => {
|
|
|
291
291
|
* @param {string} message - Info message
|
|
292
292
|
*/
|
|
293
293
|
export const showInfo = (message) => {
|
|
294
|
-
|
|
294
|
+
logger.info(message);
|
|
295
295
|
};
|
|
296
296
|
|
|
297
297
|
/**
|
package/lib/utils/judge.js
CHANGED
|
@@ -28,7 +28,7 @@ import logger from './logger.js';
|
|
|
28
28
|
import { recordMetric } from './metrics.js';
|
|
29
29
|
|
|
30
30
|
const JUDGE_DEFAULT_MODEL = 'sonnet';
|
|
31
|
-
const JUDGE_TIMEOUT =
|
|
31
|
+
const JUDGE_TIMEOUT = 360000;
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
34
|
* Applies a single search/replace fix to a file and stages it
|
|
@@ -102,7 +102,7 @@ const applyFix = async (fix, repoRoot) => {
|
|
|
102
102
|
* @param {Object} config - Application config
|
|
103
103
|
* @returns {Promise<{fixedCount: number, falsePositiveCount: number, remainingIssues: Array, verdicts: Array}>}
|
|
104
104
|
*/
|
|
105
|
-
const judgeAndFix = async (analysisResult, filesData, config) => {
|
|
105
|
+
const judgeAndFix = async (analysisResult, filesData, config, { headless = false } = {}) => {
|
|
106
106
|
const model = config.judge?.model || JUDGE_DEFAULT_MODEL;
|
|
107
107
|
const repoRoot = getRepoRoot();
|
|
108
108
|
|
|
@@ -135,7 +135,8 @@ const judgeAndFix = async (analysisResult, filesData, config) => {
|
|
|
135
135
|
// Call LLM
|
|
136
136
|
const response = await executeClaudeWithRetry(prompt, {
|
|
137
137
|
model,
|
|
138
|
-
timeout: JUDGE_TIMEOUT
|
|
138
|
+
timeout: JUDGE_TIMEOUT,
|
|
139
|
+
headless
|
|
139
140
|
});
|
|
140
141
|
|
|
141
142
|
const parsed = extractJSON(response);
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File: langfuse-tracer.js
|
|
3
|
+
* Purpose: Optional Langfuse integration for headless SDK calls.
|
|
4
|
+
*
|
|
5
|
+
* Mirrors Lumiere's observability/langfuse_tracer.py — same field names,
|
|
6
|
+
* same metadata conventions, same graceful degradation pattern.
|
|
7
|
+
*
|
|
8
|
+
* Disabled by default. Activate via env vars:
|
|
9
|
+
* LANGFUSE_ENABLED=true
|
|
10
|
+
* LANGFUSE_SECRET_KEY=sk-lf-...
|
|
11
|
+
* LANGFUSE_PUBLIC_KEY=pk-lf-...
|
|
12
|
+
* LANGFUSE_HOST=https://cloud.langfuse.com (optional, default shown)
|
|
13
|
+
*
|
|
14
|
+
* The `langfuse` npm package is lazy-loaded (same pattern as @anthropic-ai/sdk).
|
|
15
|
+
* It is NOT listed in package.json — expected to be installed in the ECS/CI
|
|
16
|
+
* environment where headless mode runs.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import logger from './logger.js';
|
|
20
|
+
import { calculateCost } from './cost-tracker.js';
|
|
21
|
+
|
|
22
|
+
let _client = null;
|
|
23
|
+
let _enabled = null;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Check whether Langfuse tracing is enabled and credentials are present.
|
|
27
|
+
* Result is cached after first evaluation.
|
|
28
|
+
*
|
|
29
|
+
* @returns {boolean}
|
|
30
|
+
*/
|
|
31
|
+
function isEnabled() {
|
|
32
|
+
if (_enabled !== null) return _enabled;
|
|
33
|
+
_enabled =
|
|
34
|
+
process.env.LANGFUSE_ENABLED === 'true' &&
|
|
35
|
+
!!process.env.LANGFUSE_SECRET_KEY &&
|
|
36
|
+
!!process.env.LANGFUSE_PUBLIC_KEY;
|
|
37
|
+
return _enabled;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Lazily initialize the Langfuse client. Returns null when disabled or on any failure.
|
|
42
|
+
*
|
|
43
|
+
* @returns {Promise<Object|null>}
|
|
44
|
+
*/
|
|
45
|
+
async function _getClient() {
|
|
46
|
+
if (_client) return _client;
|
|
47
|
+
if (!isEnabled()) return null;
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const { Langfuse } = await import('langfuse');
|
|
51
|
+
_client = new Langfuse({
|
|
52
|
+
publicKey: process.env.LANGFUSE_PUBLIC_KEY,
|
|
53
|
+
secretKey: process.env.LANGFUSE_SECRET_KEY,
|
|
54
|
+
baseUrl: process.env.LANGFUSE_HOST || 'https://cloud.langfuse.com'
|
|
55
|
+
});
|
|
56
|
+
logger.debug('langfuse-tracer', 'Langfuse client initialized');
|
|
57
|
+
return _client;
|
|
58
|
+
} catch (err) {
|
|
59
|
+
logger.warning(`langfuse-tracer: failed to initialize — ${err.message}`);
|
|
60
|
+
_enabled = false;
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Record a single LLM generation in Langfuse.
|
|
67
|
+
*
|
|
68
|
+
* Creates a trace (named `claude_hooks`) with one nested generation.
|
|
69
|
+
* Metadata field names match Lumiere's orchestrator.py for cross-repo
|
|
70
|
+
* dashboard consistency. All metadata values are strings (Langfuse requirement).
|
|
71
|
+
*
|
|
72
|
+
* @param {Object} params
|
|
73
|
+
* @param {string} params.name - Generation name (e.g. 'executeClaude')
|
|
74
|
+
* @param {string} params.model - Full Anthropic model ID
|
|
75
|
+
* @param {string} [params.input] - Prompt text (optional, can be large)
|
|
76
|
+
* @param {string} [params.output] - Response text (optional)
|
|
77
|
+
* @param {Object} params.usage - Token counts from SDK response
|
|
78
|
+
* @param {number} params.usage.input_tokens
|
|
79
|
+
* @param {number} params.usage.output_tokens
|
|
80
|
+
* @param {number} [params.usage.cache_creation_input_tokens=0]
|
|
81
|
+
* @param {number} [params.usage.cache_read_input_tokens=0]
|
|
82
|
+
* @param {number} [params.durationMs=0] - Wall-clock milliseconds
|
|
83
|
+
*/
|
|
84
|
+
async function traceGeneration({ name, model, input, output, usage, durationMs = 0 }) {
|
|
85
|
+
const client = await _getClient();
|
|
86
|
+
if (!client) return;
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const inputTokens = usage.input_tokens || 0;
|
|
90
|
+
const outputTokens = usage.output_tokens || 0;
|
|
91
|
+
const cacheCreation = usage.cache_creation_input_tokens || 0;
|
|
92
|
+
const cacheRead = usage.cache_read_input_tokens || 0;
|
|
93
|
+
|
|
94
|
+
const totalCost = calculateCost({
|
|
95
|
+
tokens_input: inputTokens,
|
|
96
|
+
tokens_output: outputTokens,
|
|
97
|
+
model,
|
|
98
|
+
cache_creation: cacheCreation,
|
|
99
|
+
cache_read: cacheRead
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const trace = client.trace({
|
|
103
|
+
name: 'claude_hooks',
|
|
104
|
+
tags: ['claude-hooks'],
|
|
105
|
+
metadata: { source: 'claude-hooks' }
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
trace.generation({
|
|
109
|
+
name,
|
|
110
|
+
model,
|
|
111
|
+
input: input !== undefined ? input : undefined,
|
|
112
|
+
output: output !== undefined ? output : undefined,
|
|
113
|
+
usage: {
|
|
114
|
+
input: inputTokens + cacheCreation + cacheRead,
|
|
115
|
+
output: outputTokens,
|
|
116
|
+
total: inputTokens + cacheCreation + cacheRead + outputTokens
|
|
117
|
+
},
|
|
118
|
+
metadata: {
|
|
119
|
+
tokens_input_regular: String(inputTokens),
|
|
120
|
+
cache_creation_tokens: String(cacheCreation),
|
|
121
|
+
cache_read_tokens: String(cacheRead),
|
|
122
|
+
total_cost_usd: String(totalCost),
|
|
123
|
+
duration_ms: String(durationMs),
|
|
124
|
+
source: 'claude-hooks'
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
logger.debug('langfuse-tracer', 'Generation recorded', { name, model, totalCost });
|
|
129
|
+
} catch (err) {
|
|
130
|
+
logger.warning(`langfuse-tracer: failed to record generation — ${err.message}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Flush pending Langfuse events. Call at the end of a command/analysis
|
|
136
|
+
* to ensure data is sent before the process exits.
|
|
137
|
+
*/
|
|
138
|
+
async function flush() {
|
|
139
|
+
if (!_client) return;
|
|
140
|
+
try {
|
|
141
|
+
await _client.flushAsync();
|
|
142
|
+
logger.debug('langfuse-tracer', 'Flushed');
|
|
143
|
+
} catch (err) {
|
|
144
|
+
logger.warning(`langfuse-tracer: flush failed — ${err.message}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Reset client state — for testing only.
|
|
150
|
+
*/
|
|
151
|
+
function _resetClient() {
|
|
152
|
+
_client = null;
|
|
153
|
+
_enabled = null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export { isEnabled, traceGeneration, flush, _getClient, _resetClient };
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
displayToolResult
|
|
26
26
|
} from './tool-runner.js';
|
|
27
27
|
import { getRepoRoot } from './git-operations.js';
|
|
28
|
+
import { which } from './which-command.js';
|
|
28
29
|
import { fetchRemoteConfig } from './remote-config.js';
|
|
29
30
|
import logger from './logger.js';
|
|
30
31
|
|
|
@@ -324,7 +325,7 @@ export const LINTER_TOOLS = {
|
|
|
324
325
|
installHint: 'Add spotless-maven-plugin to pom.xml (see https://github.com/diffplug/spotless)',
|
|
325
326
|
extensions: ['.java'],
|
|
326
327
|
parseOutput: parseSpotlessOutput,
|
|
327
|
-
timeout:
|
|
328
|
+
timeout: 120000,
|
|
328
329
|
// Run per-file to avoid | (pipe) in regex on Windows.
|
|
329
330
|
// filesToSpotlessRegex joins files with | for regex alternation.
|
|
330
331
|
// cmd.exe interprets | as a pipe at every expansion level —
|
|
@@ -474,9 +475,13 @@ export async function runLinters(files, config, presetName = 'default') {
|
|
|
474
475
|
}
|
|
475
476
|
}
|
|
476
477
|
|
|
478
|
+
// Use the higher of config timeout and tool-specific timeout.
|
|
479
|
+
// Spotless (120s) should not be silently capped to the config default (30s).
|
|
480
|
+
const effectiveTimeout = Math.max(timeout, effectiveToolDef.timeout || 0);
|
|
481
|
+
|
|
477
482
|
const toolOpts = {
|
|
478
483
|
autoFix,
|
|
479
|
-
timeout,
|
|
484
|
+
timeout: effectiveTimeout,
|
|
480
485
|
restage: true,
|
|
481
486
|
localBin: availability.localBin,
|
|
482
487
|
toolCwd: availability.configDir
|
|
@@ -487,9 +492,29 @@ export async function runLinters(files, config, presetName = 'default') {
|
|
|
487
492
|
// ^ escaping is consumed at the first parse, and %VAR% expansion in batch
|
|
488
493
|
// scripts (mvn.cmd) re-exposes unescaped | to a second parse.
|
|
489
494
|
// Running per-file ensures each invocation has a single-file regex (no |).
|
|
490
|
-
|
|
495
|
+
//
|
|
496
|
+
// However, per-file mode is ONLY needed when the command routes through
|
|
497
|
+
// cmd.exe (.cmd/.bat files). Direct executables and bash scripts (e.g.,
|
|
498
|
+
// mvnw) pass args to the process directly — | is never interpreted as a pipe.
|
|
499
|
+
// Batch mode avoids N separate JVM startups, which is critical for Spotless
|
|
500
|
+
// on Windows/WSL where each startup adds significant overhead.
|
|
501
|
+
let needsPerFile = effectiveToolDef.perFile && matchingFiles.length > 1;
|
|
502
|
+
if (needsPerFile) {
|
|
503
|
+
const resolvedCmd = effectiveToolDef.command.includes(path.sep)
|
|
504
|
+
? effectiveToolDef.command
|
|
505
|
+
: (which(effectiveToolDef.command) || effectiveToolDef.command);
|
|
506
|
+
if (!/\.(cmd|bat)$/i.test(resolvedCmd)) {
|
|
507
|
+
needsPerFile = false;
|
|
508
|
+
logger.debug('linter-runner - runLinters',
|
|
509
|
+
`${effectiveToolDef.name} command bypasses cmd.exe, using batch mode`,
|
|
510
|
+
{ command: resolvedCmd });
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (needsPerFile) {
|
|
491
515
|
logger.debug('linter-runner - runLinters',
|
|
492
|
-
`Running ${effectiveToolDef.name} per-file
|
|
516
|
+
`Running ${effectiveToolDef.name} per-file (cmd.exe path)`,
|
|
517
|
+
{ fileCount: matchingFiles.length });
|
|
493
518
|
|
|
494
519
|
const perFileResults = matchingFiles.map((file) =>
|
|
495
520
|
runToolWithAutoFix(effectiveToolDef, [file], toolOpts)
|
package/lib/utils/logger.js
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
class Logger {
|
|
18
18
|
constructor({ debugMode = false } = {}) {
|
|
19
19
|
this.debugMode = debugMode;
|
|
20
|
+
this.jsonMode = false;
|
|
20
21
|
this.colors = {
|
|
21
22
|
reset: '\x1b[0m',
|
|
22
23
|
red: '\x1b[31m',
|
|
@@ -34,7 +35,8 @@ class Logger {
|
|
|
34
35
|
* @param {string} message - Simple message for end users
|
|
35
36
|
*/
|
|
36
37
|
info(message) {
|
|
37
|
-
|
|
38
|
+
const out = this.jsonMode ? console.error : console.log;
|
|
39
|
+
out(`${this.colors.blue}ℹ️ ${message}${this.colors.reset}`);
|
|
38
40
|
}
|
|
39
41
|
|
|
40
42
|
/**
|
|
@@ -44,7 +46,8 @@ class Logger {
|
|
|
44
46
|
* @param {string} message - Success message
|
|
45
47
|
*/
|
|
46
48
|
success(message) {
|
|
47
|
-
|
|
49
|
+
const out = this.jsonMode ? console.error : console.log;
|
|
50
|
+
out(`${this.colors.green}✅ ${message}${this.colors.reset}`);
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
/**
|
|
@@ -54,7 +57,8 @@ class Logger {
|
|
|
54
57
|
* @param {string} message - Warning message
|
|
55
58
|
*/
|
|
56
59
|
warning(message) {
|
|
57
|
-
|
|
60
|
+
const out = this.jsonMode ? console.error : console.log;
|
|
61
|
+
out(`${this.colors.yellow}⚠️ ${message}${this.colors.reset}`);
|
|
58
62
|
}
|
|
59
63
|
|
|
60
64
|
/**
|
|
@@ -71,13 +75,14 @@ class Logger {
|
|
|
71
75
|
debug(context, message, data = {}) {
|
|
72
76
|
if (!this.debugMode) return;
|
|
73
77
|
|
|
78
|
+
const out = this.jsonMode ? console.error : console.log;
|
|
74
79
|
const timestamp = new Date().toISOString();
|
|
75
|
-
|
|
80
|
+
out(
|
|
76
81
|
`${this.colors.gray}[DEBUG ${timestamp}] [${context}] ${message}${this.colors.reset}`
|
|
77
82
|
);
|
|
78
83
|
|
|
79
84
|
if (Object.keys(data).length > 0) {
|
|
80
|
-
|
|
85
|
+
out(this.colors.gray, JSON.stringify(data, null, 2), this.colors.reset);
|
|
81
86
|
}
|
|
82
87
|
}
|
|
83
88
|
|
|
@@ -137,6 +142,26 @@ class Logger {
|
|
|
137
142
|
isDebugMode() {
|
|
138
143
|
return this.debugMode;
|
|
139
144
|
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Set JSON mode dynamically
|
|
148
|
+
* Why: When active, info/success/warning/debug write to stderr so stdout
|
|
149
|
+
* is reserved exclusively for structured JSON output.
|
|
150
|
+
*
|
|
151
|
+
* @param {boolean} enabled - Whether to enable JSON mode
|
|
152
|
+
*/
|
|
153
|
+
setJSONMode(enabled) {
|
|
154
|
+
this.jsonMode = !!enabled;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Check if JSON mode is enabled
|
|
159
|
+
*
|
|
160
|
+
* @returns {boolean} True if JSON mode is active
|
|
161
|
+
*/
|
|
162
|
+
isJSONMode() {
|
|
163
|
+
return this.jsonMode;
|
|
164
|
+
}
|
|
140
165
|
}
|
|
141
166
|
|
|
142
167
|
// Export singleton instance
|
|
@@ -371,7 +371,7 @@ export const getBranchContext = async (targetBranch) => {
|
|
|
371
371
|
export const generatePRMetadata = async (context, options = {}) => {
|
|
372
372
|
const config = await getConfig();
|
|
373
373
|
const configTimeout = config.analysis?.timeout;
|
|
374
|
-
const { timeout = configTimeout || DEFAULTS.timeout, hook = 'pr-metadata' } = options;
|
|
374
|
+
const { timeout = configTimeout || DEFAULTS.timeout, hook = 'pr-metadata', headless = false, costTracker = null } = options;
|
|
375
375
|
|
|
376
376
|
logger.debug('pr-metadata-engine - generatePRMetadata', 'Generating PR metadata', {
|
|
377
377
|
filesCount: context.filesCount,
|
|
@@ -411,7 +411,9 @@ export const generatePRMetadata = async (context, options = {}) => {
|
|
|
411
411
|
// Call Claude with retry logic
|
|
412
412
|
const response = await executeClaudeWithRetry(prompt, {
|
|
413
413
|
timeout,
|
|
414
|
-
telemetryContext
|
|
414
|
+
telemetryContext,
|
|
415
|
+
headless,
|
|
416
|
+
costTracker
|
|
415
417
|
});
|
|
416
418
|
|
|
417
419
|
logger.debug('pr-metadata-engine - generatePRMetadata', 'Claude response received', {
|
package/lib/utils/tool-runner.js
CHANGED
|
@@ -461,8 +461,9 @@ export function runTool(toolDef, files, options = {}) {
|
|
|
461
461
|
const parsed = toolDef.parseOutput(err.stdout);
|
|
462
462
|
result.errors = parsed.errors || [];
|
|
463
463
|
result.warnings = parsed.warnings || [];
|
|
464
|
-
} else if (err.killed) {
|
|
465
|
-
// Timeout
|
|
464
|
+
} else if (err.killed || err.code === 'ETIMEDOUT') {
|
|
465
|
+
// Timeout — err.killed for Node.js timeout, ETIMEDOUT for OS-level
|
|
466
|
+
// spawn timeout (common on Windows when cmd.exe → JVM startup is slow)
|
|
466
467
|
logger.warning(`${toolDef.name} timed out after ${timeout}ms`);
|
|
467
468
|
result.errors.push({
|
|
468
469
|
file: '',
|
|
@@ -543,7 +544,7 @@ export function runToolFix(toolDef, files, options = {}) {
|
|
|
543
544
|
try {
|
|
544
545
|
execFileSync(exec.command, exec.args, execOptions);
|
|
545
546
|
} catch (err) {
|
|
546
|
-
if (err.killed) {
|
|
547
|
+
if (err.killed || err.code === 'ETIMEDOUT') {
|
|
547
548
|
logger.warning(`${toolDef.name} auto-fix timed out`);
|
|
548
549
|
return { fixedFiles: [], fixedCount: 0 };
|
|
549
550
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-git-hooks",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.43.0",
|
|
4
4
|
"description": "Git hooks with Claude CLI for code analysis and automatic commit messages",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -60,7 +60,9 @@
|
|
|
60
60
|
"LICENSE"
|
|
61
61
|
],
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@
|
|
63
|
+
"@anthropic-ai/sdk": "^0.91.0",
|
|
64
|
+
"@octokit/rest": "^21.0.0",
|
|
65
|
+
"langfuse": "^3.38.20"
|
|
64
66
|
},
|
|
65
67
|
"devDependencies": {
|
|
66
68
|
"@types/jest": "^29.5.0",
|
|
@@ -284,7 +284,7 @@ cd python-django
|
|
|
284
284
|
"analysis": {
|
|
285
285
|
"maxFileSize": 1000000,
|
|
286
286
|
"maxFiles": 12,
|
|
287
|
-
"timeout":
|
|
287
|
+
"timeout": 360000
|
|
288
288
|
},
|
|
289
289
|
"subagents": {
|
|
290
290
|
"enabled": true,
|
|
@@ -308,7 +308,7 @@ cd python-django
|
|
|
308
308
|
analysis: {
|
|
309
309
|
maxFileSize: 1000000, // 1MB
|
|
310
310
|
maxFiles: 30,
|
|
311
|
-
timeout:
|
|
311
|
+
timeout: 360000 // 6 minutes
|
|
312
312
|
},
|
|
313
313
|
subagents: {
|
|
314
314
|
enabled: true,
|