claude-git-hooks 2.35.3 → 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
  /**
@@ -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 };
@@ -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', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-git-hooks",
3
- "version": "2.35.3",
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",