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.
@@ -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
- console.log(`✅ ${message}`);
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
- console.log(`❌ ${message}`);
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
- console.log(`⚠️ ${message}`);
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
- console.log(`ℹ️ ${message}`);
294
+ logger.info(message);
295
295
  };
296
296
 
297
297
  /**
@@ -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 = 180000;
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: 60000,
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
- if (effectiveToolDef.perFile && matchingFiles.length > 1) {
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`, { fileCount: matchingFiles.length });
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)
@@ -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
- console.log(`${this.colors.blue}ℹ️ ${message}${this.colors.reset}`);
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
- console.log(`${this.colors.green}✅ ${message}${this.colors.reset}`);
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
- console.log(`${this.colors.yellow}⚠️ ${message}${this.colors.reset}`);
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
- console.log(
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
- console.log(this.colors.gray, JSON.stringify(data, null, 2), this.colors.reset);
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', {
@@ -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.35.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
- "@octokit/rest": "^21.0.0"
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": 180000
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: 180000 // 3 minutes
311
+ timeout: 360000 // 6 minutes
312
312
  },
313
313
  subagents: {
314
314
  enabled: true,