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.
- package/CHANGELOG.md +135 -0
- package/CLAUDE.md +24 -1389
- 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 +3 -0
- 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 +3 -2
- package/lib/utils/langfuse-tracer.js +156 -0
- package/lib/utils/logger.js +30 -5
- package/lib/utils/pr-metadata-engine.js +4 -2
- package/package.json +4 -2
|
@@ -278,7 +278,8 @@ export const createEmptyResult = () => ({
|
|
|
278
278
|
*
|
|
279
279
|
* @param {Object} result - Analysis result
|
|
280
280
|
*/
|
|
281
|
-
export const displayIssueSummary = (result) => {
|
|
281
|
+
export const displayIssueSummary = (result, { silent = false } = {}) => {
|
|
282
|
+
if (silent) return;
|
|
282
283
|
const { blocker = 0, critical = 0, major = 0, minor = 0, info = 0 } = result.issues || {};
|
|
283
284
|
const total = blocker + critical + major + minor + info;
|
|
284
285
|
|
|
@@ -296,7 +297,8 @@ export const displayIssueSummary = (result) => {
|
|
|
296
297
|
*
|
|
297
298
|
* @param {Object} result - Analysis result from Claude
|
|
298
299
|
*/
|
|
299
|
-
export const displayResults = (result) => {
|
|
300
|
+
export const displayResults = (result, { silent = false } = {}) => {
|
|
301
|
+
if (silent) return;
|
|
300
302
|
console.log();
|
|
301
303
|
console.log('╔════════════════════════════════════════════════════════════════════╗');
|
|
302
304
|
console.log('║ CODE QUALITY ANALYSIS ║');
|
|
@@ -376,7 +378,7 @@ const ORCHESTRATOR_THRESHOLD = 3;
|
|
|
376
378
|
* @returns {Promise<Object>} Analysis result
|
|
377
379
|
*/
|
|
378
380
|
export const runAnalysis = async (filesData, config, options = {}) => {
|
|
379
|
-
const { saveDebug = config.system?.debug, hook = 'analysis' } = options;
|
|
381
|
+
const { saveDebug = config.system?.debug, hook = 'analysis', headless = false, costTracker = null } = options;
|
|
380
382
|
|
|
381
383
|
if (filesData.length === 0) {
|
|
382
384
|
logger.debug('analysis-engine - runAnalysis', 'No files to analyze');
|
|
@@ -396,7 +398,7 @@ export const runAnalysis = async (filesData, config, options = {}) => {
|
|
|
396
398
|
if (useOrchestrator) {
|
|
397
399
|
// Orchestrated parallel execution: Opus groups files semantically
|
|
398
400
|
const orchestrationStart = Date.now();
|
|
399
|
-
const orchestration = await orchestrateBatches(filesData);
|
|
401
|
+
const orchestration = await orchestrateBatches(filesData, { headless });
|
|
400
402
|
const orchestrationTime = Date.now() - orchestrationStart;
|
|
401
403
|
|
|
402
404
|
const { batches, commonContext } = orchestration;
|
|
@@ -442,7 +444,9 @@ export const runAnalysis = async (filesData, config, options = {}) => {
|
|
|
442
444
|
timeout: config.analysis?.timeout,
|
|
443
445
|
model: batch.model,
|
|
444
446
|
saveDebug: false,
|
|
445
|
-
telemetryContext
|
|
447
|
+
telemetryContext,
|
|
448
|
+
headless,
|
|
449
|
+
costTracker
|
|
446
450
|
});
|
|
447
451
|
})
|
|
448
452
|
);
|
|
@@ -489,7 +493,9 @@ export const runAnalysis = async (filesData, config, options = {}) => {
|
|
|
489
493
|
result = await analyzeCode(prompt, {
|
|
490
494
|
timeout: config.analysis?.timeout,
|
|
491
495
|
saveDebug,
|
|
492
|
-
telemetryContext
|
|
496
|
+
telemetryContext,
|
|
497
|
+
headless,
|
|
498
|
+
costTracker
|
|
493
499
|
});
|
|
494
500
|
}
|
|
495
501
|
|
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
} from './claude-diagnostics.js';
|
|
30
30
|
import { which } from './which-command.js';
|
|
31
31
|
import { recordJsonParseFailure, recordBatchSuccess, rotateTelemetry } from './telemetry.js';
|
|
32
|
+
import { CostTracker } from './cost-tracker.js';
|
|
33
|
+
import { traceGeneration, flush as langfuseFlush } from './langfuse-tracer.js';
|
|
32
34
|
|
|
33
35
|
/**
|
|
34
36
|
* Custom error for Claude client failures
|
|
@@ -43,6 +45,24 @@ class ClaudeClientError extends Error {
|
|
|
43
45
|
}
|
|
44
46
|
}
|
|
45
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Model alias map — resolves shorthand names to full SDK model IDs.
|
|
50
|
+
* Env var overrides match Lumiere contract (CLAUDE_MODEL_HAIKU, CLAUDE_MODEL_SONNET, CLAUDE_MODEL_OPUS).
|
|
51
|
+
* Full model IDs (e.g., 'claude-sonnet-4-6') pass through unchanged.
|
|
52
|
+
*/
|
|
53
|
+
const MODEL_ALIASES = {
|
|
54
|
+
haiku: process.env.CLAUDE_MODEL_HAIKU || 'claude-haiku-4-5-20251001',
|
|
55
|
+
sonnet: process.env.CLAUDE_MODEL_SONNET || 'claude-sonnet-4-6',
|
|
56
|
+
opus: process.env.CLAUDE_MODEL_OPUS || 'claude-opus-4-6'
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolves a model alias to a full model ID.
|
|
61
|
+
* @param {string} model - Alias ('sonnet') or full ID ('claude-sonnet-4-6')
|
|
62
|
+
* @returns {string} Full model ID
|
|
63
|
+
*/
|
|
64
|
+
const resolveModelAlias = (model) => MODEL_ALIASES[model] || model;
|
|
65
|
+
|
|
46
66
|
/**
|
|
47
67
|
* Detect if running on Windows
|
|
48
68
|
* Why: Need to use 'wsl claude' instead of 'claude' on Windows
|
|
@@ -329,6 +349,145 @@ const getClaudeCommand = () => {
|
|
|
329
349
|
return { command: 'claude', args: [] };
|
|
330
350
|
};
|
|
331
351
|
|
|
352
|
+
/**
|
|
353
|
+
* Executes a prompt via the Anthropic SDK (headless mode only).
|
|
354
|
+
*
|
|
355
|
+
* This is the execution backend for headless environments (ECS, CI) where no
|
|
356
|
+
* interactive Claude CLI session exists — only an ANTHROPIC_API_KEY. Activated
|
|
357
|
+
* when `options.headless === true` in `executeClaude`. (refs GH#133, enables CT-791)
|
|
358
|
+
*
|
|
359
|
+
* The SDK handles transient retries internally (2 automatic retries on rate
|
|
360
|
+
* limits, timeouts, and 5xx errors). Errors thrown from this function have
|
|
361
|
+
* already exhausted SDK-level retries and are marked `fromSDK: true` so that
|
|
362
|
+
* the outer `withRetry` does NOT retry them again.
|
|
363
|
+
*
|
|
364
|
+
* Model alias resolution: Accepts shorthand aliases ('sonnet', 'opus', 'haiku')
|
|
365
|
+
* and resolves them to full model IDs via MODEL_ALIASES. Env var overrides
|
|
366
|
+
* (CLAUDE_MODEL_SONNET, etc.) are supported.
|
|
367
|
+
*
|
|
368
|
+
* @param {string} prompt - Prompt text to send
|
|
369
|
+
* @param {Object} options - Execution options
|
|
370
|
+
* @param {string} options.model - Model name or alias (default: config.claude.defaultModel)
|
|
371
|
+
* @param {number} options.timeout - Timeout in milliseconds (default: 120000)
|
|
372
|
+
* @param {number} options.maxTokens - Max output tokens (default: 16384)
|
|
373
|
+
* @returns {Promise<string>} Raw text response
|
|
374
|
+
* @throws {ClaudeClientError} With context.errorInfo.fromSDK: true
|
|
375
|
+
*/
|
|
376
|
+
const _executeSDK = async (prompt, { model, timeout = 120000, maxTokens } = {}) => {
|
|
377
|
+
// Lazy-load SDK — not imported at module top to avoid requiring the package
|
|
378
|
+
// in CLI (non-headless) environments where it is not needed
|
|
379
|
+
const { default: Anthropic } = await import('@anthropic-ai/sdk');
|
|
380
|
+
|
|
381
|
+
const resolvedModel = resolveModelAlias(model || config.claude?.defaultModel || 'sonnet');
|
|
382
|
+
|
|
383
|
+
logger.debug('claude-client - _executeSDK', 'Executing via Anthropic SDK (headless)', {
|
|
384
|
+
promptLength: prompt.length,
|
|
385
|
+
model: resolvedModel,
|
|
386
|
+
timeout,
|
|
387
|
+
maxTokens: maxTokens || 16384
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
const startTime = Date.now();
|
|
391
|
+
|
|
392
|
+
try {
|
|
393
|
+
const client = new Anthropic({ timeout });
|
|
394
|
+
|
|
395
|
+
const response = await client.messages.create({
|
|
396
|
+
model: resolvedModel,
|
|
397
|
+
max_tokens: maxTokens || 16384,
|
|
398
|
+
messages: [{ role: 'user', content: prompt }]
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// Extract text from ALL text blocks (SDK can return multiple content blocks)
|
|
402
|
+
const text = response.content
|
|
403
|
+
.filter((b) => b.type === 'text')
|
|
404
|
+
.map((b) => b.text)
|
|
405
|
+
.join('');
|
|
406
|
+
|
|
407
|
+
const elapsedTime = Date.now() - startTime;
|
|
408
|
+
const usage = response.usage || {};
|
|
409
|
+
|
|
410
|
+
const normalizedUsage = {
|
|
411
|
+
input_tokens: usage.input_tokens || 0,
|
|
412
|
+
output_tokens: usage.output_tokens || 0,
|
|
413
|
+
cache_creation_input_tokens: usage.cache_creation_input_tokens || 0,
|
|
414
|
+
cache_read_input_tokens: usage.cache_read_input_tokens || 0
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
logger.debug('claude-client - _executeSDK', 'SDK response received', {
|
|
418
|
+
elapsedTime,
|
|
419
|
+
model: resolvedModel,
|
|
420
|
+
inputTokens: normalizedUsage.input_tokens,
|
|
421
|
+
outputTokens: normalizedUsage.output_tokens,
|
|
422
|
+
cacheCreationTokens: normalizedUsage.cache_creation_input_tokens,
|
|
423
|
+
cacheReadTokens: normalizedUsage.cache_read_input_tokens,
|
|
424
|
+
contentBlocks: response.content.length,
|
|
425
|
+
responseLength: text.length
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
return { text, usage: normalizedUsage, model: resolvedModel };
|
|
429
|
+
} catch (error) {
|
|
430
|
+
const elapsedTime = Date.now() - startTime;
|
|
431
|
+
|
|
432
|
+
if (error.constructor?.name === 'RateLimitError' || error.status === 429) {
|
|
433
|
+
logger.error(
|
|
434
|
+
'claude-client - _executeSDK',
|
|
435
|
+
`SDK rate limit exceeded (after SDK retries): ${error.message}`,
|
|
436
|
+
error
|
|
437
|
+
);
|
|
438
|
+
throw new ClaudeClientError('SDK rate limit exceeded', {
|
|
439
|
+
cause: error,
|
|
440
|
+
context: {
|
|
441
|
+
elapsedTime,
|
|
442
|
+
model: resolvedModel,
|
|
443
|
+
errorInfo: { type: 'RATE_LIMIT', fromSDK: true }
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (error.constructor?.name === 'APIStatusError' || error.status) {
|
|
449
|
+
logger.error(
|
|
450
|
+
'claude-client - _executeSDK',
|
|
451
|
+
`SDK API error (status ${error.status}): ${error.message}`,
|
|
452
|
+
error
|
|
453
|
+
);
|
|
454
|
+
throw new ClaudeClientError(`SDK API error (status ${error.status})`, {
|
|
455
|
+
cause: error,
|
|
456
|
+
context: {
|
|
457
|
+
elapsedTime,
|
|
458
|
+
model: resolvedModel,
|
|
459
|
+
statusCode: error.status,
|
|
460
|
+
errorInfo: { type: 'API_STATUS_ERROR', fromSDK: true }
|
|
461
|
+
}
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// Unknown error — propagate with SDK marker so withRetry skips it
|
|
466
|
+
logger.error('claude-client - _executeSDK', `SDK error: ${error.message}`, error);
|
|
467
|
+
throw new ClaudeClientError(`SDK error: ${error.message}`, {
|
|
468
|
+
cause: error,
|
|
469
|
+
context: {
|
|
470
|
+
elapsedTime,
|
|
471
|
+
model: resolvedModel,
|
|
472
|
+
errorInfo: { type: 'SDK_ERROR', fromSDK: true }
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Verify SDK connectivity with a minimal 1-token ping.
|
|
480
|
+
* @returns {Promise<{ok: boolean, usage?: Object, error?: string}>}
|
|
481
|
+
*/
|
|
482
|
+
async function verifySDKConnection() {
|
|
483
|
+
try {
|
|
484
|
+
const result = await _executeSDK('ping', { model: 'haiku', maxTokens: 5, timeout: 10000 });
|
|
485
|
+
return { ok: true, usage: result.usage };
|
|
486
|
+
} catch (err) {
|
|
487
|
+
return { ok: false, error: err.message };
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
332
491
|
/**
|
|
333
492
|
* Executes Claude CLI with a prompt
|
|
334
493
|
* Why: Centralized Claude CLI execution with error handling and timeout
|
|
@@ -338,11 +497,43 @@ const getClaudeCommand = () => {
|
|
|
338
497
|
* @param {Object} options - Execution options
|
|
339
498
|
* @param {number} options.timeout - Timeout in milliseconds (default: 120000 = 2 minutes)
|
|
340
499
|
* @param {string} options.model - Claude model override (e.g., 'haiku', 'sonnet', 'opus')
|
|
500
|
+
* @param {boolean} options.headless - Use SDK instead of CLI (default: false)
|
|
341
501
|
* @returns {Promise<string>} Claude's response
|
|
342
502
|
* @throws {ClaudeClientError} If execution fails or times out
|
|
343
503
|
*/
|
|
344
|
-
const executeClaude = (prompt, { timeout = 120000, allowedTools = [], model = null } = {}) =>
|
|
345
|
-
|
|
504
|
+
const executeClaude = (prompt, { timeout = 120000, allowedTools = [], model = null, headless = false, maxTokens = null, costTracker = null } = {}) => {
|
|
505
|
+
// Headless mode: use Anthropic SDK directly (GH#133)
|
|
506
|
+
// Branch here (not in executeClaudeWithRetry) because analyzeCode calls
|
|
507
|
+
// executeClaude directly via withRetry — branching here covers all paths.
|
|
508
|
+
if (headless) {
|
|
509
|
+
const startTime = Date.now();
|
|
510
|
+
return _executeSDK(prompt, { model, timeout, maxTokens }).then(async (sdkResult) => {
|
|
511
|
+
const durationMs = Date.now() - startTime;
|
|
512
|
+
if (costTracker) {
|
|
513
|
+
costTracker.add({
|
|
514
|
+
command: 'executeClaude',
|
|
515
|
+
model: sdkResult.model,
|
|
516
|
+
tokens_input: sdkResult.usage.input_tokens,
|
|
517
|
+
tokens_output: sdkResult.usage.output_tokens,
|
|
518
|
+
cache_creation: sdkResult.usage.cache_creation_input_tokens,
|
|
519
|
+
cache_read: sdkResult.usage.cache_read_input_tokens,
|
|
520
|
+
duration_ms: durationMs
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
// Langfuse generation — awaited so trace is queued before flush runs (no-op when disabled)
|
|
524
|
+
await traceGeneration({
|
|
525
|
+
name: 'executeClaude',
|
|
526
|
+
model: sdkResult.model,
|
|
527
|
+
input: prompt,
|
|
528
|
+
output: sdkResult.text,
|
|
529
|
+
usage: sdkResult.usage,
|
|
530
|
+
durationMs
|
|
531
|
+
}).catch(() => {});
|
|
532
|
+
return sdkResult.text;
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
return new Promise((resolve, reject) => {
|
|
346
537
|
// Get platform-specific command
|
|
347
538
|
const { command, args, loginShell } = getClaudeCommand();
|
|
348
539
|
|
|
@@ -637,6 +828,7 @@ const executeClaude = (prompt, { timeout = 120000, allowedTools = [], model = nu
|
|
|
637
828
|
);
|
|
638
829
|
}
|
|
639
830
|
});
|
|
831
|
+
};
|
|
640
832
|
|
|
641
833
|
/**
|
|
642
834
|
* Executes Claude CLI fully interactively
|
|
@@ -1039,9 +1231,11 @@ const executeClaudeWithRetry = async (prompt, options = {}) => {
|
|
|
1039
1231
|
*/
|
|
1040
1232
|
const analyzeCode = async (
|
|
1041
1233
|
prompt,
|
|
1042
|
-
{ timeout = 120000, model = null, saveDebug = config.system.debug, telemetryContext = {} } = {}
|
|
1234
|
+
{ timeout = 120000, model = null, saveDebug = config.system.debug, telemetryContext = {}, headless = false, costTracker = null } = {}
|
|
1043
1235
|
) => {
|
|
1044
1236
|
const startTime = Date.now();
|
|
1237
|
+
// In headless mode, always track costs — create internal tracker if caller didn't supply one
|
|
1238
|
+
const tracker = costTracker || (headless ? new CostTracker() : null);
|
|
1045
1239
|
|
|
1046
1240
|
logger.debug('claude-client - analyzeCode', 'Starting code analysis', {
|
|
1047
1241
|
promptLength: prompt.length,
|
|
@@ -1056,10 +1250,10 @@ const analyzeCode = async (
|
|
|
1056
1250
|
});
|
|
1057
1251
|
|
|
1058
1252
|
// Use withRetry to wrap the entire analysis flow (with telemetry tracking)
|
|
1059
|
-
|
|
1253
|
+
const result = await withRetry(
|
|
1060
1254
|
async () => {
|
|
1061
|
-
// Execute Claude CLI
|
|
1062
|
-
const response = await executeClaude(prompt, { timeout, model });
|
|
1255
|
+
// Execute Claude (CLI or SDK depending on headless flag)
|
|
1256
|
+
const response = await executeClaude(prompt, { timeout, model, headless, costTracker: tracker });
|
|
1063
1257
|
|
|
1064
1258
|
// Save debug if requested
|
|
1065
1259
|
if (saveDebug) {
|
|
@@ -1067,19 +1261,24 @@ const analyzeCode = async (
|
|
|
1067
1261
|
}
|
|
1068
1262
|
|
|
1069
1263
|
// Extract and parse JSON
|
|
1070
|
-
const
|
|
1264
|
+
const parsed = extractJSON(response, telemetryContext);
|
|
1071
1265
|
|
|
1072
1266
|
const duration = Date.now() - startTime;
|
|
1073
1267
|
|
|
1074
1268
|
logger.debug('claude-client - analyzeCode', 'Analysis complete', {
|
|
1075
|
-
hasApproved: 'approved' in
|
|
1076
|
-
hasQualityGate: 'QUALITY_GATE' in
|
|
1077
|
-
blockingIssuesCount:
|
|
1269
|
+
hasApproved: 'approved' in parsed,
|
|
1270
|
+
hasQualityGate: 'QUALITY_GATE' in parsed,
|
|
1271
|
+
blockingIssuesCount: parsed.blockingIssues?.length ?? 0,
|
|
1078
1272
|
duration
|
|
1079
1273
|
});
|
|
1080
1274
|
|
|
1275
|
+
// Attach cost data in headless mode (GH#142)
|
|
1276
|
+
if (tracker && headless) {
|
|
1277
|
+
parsed._costs = tracker.toJSON();
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1081
1280
|
// Telemetry is now recorded by withRetry wrapper
|
|
1082
|
-
return
|
|
1281
|
+
return parsed;
|
|
1083
1282
|
},
|
|
1084
1283
|
{
|
|
1085
1284
|
operationName: 'analyzeCode',
|
|
@@ -1089,6 +1288,13 @@ const analyzeCode = async (
|
|
|
1089
1288
|
}
|
|
1090
1289
|
}
|
|
1091
1290
|
);
|
|
1291
|
+
|
|
1292
|
+
// Flush Langfuse traces after analysis completes (no-op when disabled)
|
|
1293
|
+
if (headless) {
|
|
1294
|
+
await langfuseFlush().catch(() => {});
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
return result;
|
|
1092
1298
|
};
|
|
1093
1299
|
|
|
1094
1300
|
export {
|
|
@@ -1101,5 +1307,9 @@ export {
|
|
|
1101
1307
|
analyzeCode,
|
|
1102
1308
|
isWindows,
|
|
1103
1309
|
isWSLAvailable,
|
|
1104
|
-
getClaudeCommand
|
|
1310
|
+
getClaudeCommand,
|
|
1311
|
+
MODEL_ALIASES,
|
|
1312
|
+
resolveModelAlias,
|
|
1313
|
+
_executeSDK,
|
|
1314
|
+
verifySDKConnection
|
|
1105
1315
|
};
|
|
@@ -355,7 +355,8 @@ const formatGenericError = (errorInfo) => {
|
|
|
355
355
|
* @returns {boolean} True if error might resolve with retry
|
|
356
356
|
*/
|
|
357
357
|
export const isRecoverableError = (errorInfo) =>
|
|
358
|
-
errorInfo.
|
|
359
|
-
errorInfo.type === ClaudeErrorType.
|
|
360
|
-
|
|
361
|
-
|
|
358
|
+
!errorInfo.fromSDK &&
|
|
359
|
+
(errorInfo.type === ClaudeErrorType.EXECUTION_ERROR ||
|
|
360
|
+
errorInfo.type === ClaudeErrorType.RATE_LIMIT ||
|
|
361
|
+
errorInfo.type === ClaudeErrorType.NETWORK ||
|
|
362
|
+
errorInfo.type === ClaudeErrorType.TIMEOUT);
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// MODEL_PRICING last updated: 2026-04-28 (source: anthropic.com/pricing)
|
|
2
|
+
//
|
|
3
|
+
// Cache pricing (per Anthropic public pricing, mirrors Lumiere observability/pricing.py):
|
|
4
|
+
// cache_creation = base input price * 1.25
|
|
5
|
+
// cache_read = base input price * 0.10
|
|
6
|
+
|
|
7
|
+
import logger from './logger.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Pricing per 1M tokens (USD) keyed by full Anthropic model ID.
|
|
11
|
+
* Mirrors mscope-S-L/Lumiere observability/pricing.py exactly.
|
|
12
|
+
*/
|
|
13
|
+
const MODEL_PRICING = {
|
|
14
|
+
// Sonnet 4.5 (Sep 2025)
|
|
15
|
+
'claude-sonnet-4-5-20250929': { input_per_1m: 3.0, output_per_1m: 15.0 },
|
|
16
|
+
// Sonnet 4.6 (Apr 2025)
|
|
17
|
+
'claude-sonnet-4-6-20250414': { input_per_1m: 3.0, output_per_1m: 15.0 },
|
|
18
|
+
'claude-sonnet-4-6': { input_per_1m: 3.0, output_per_1m: 15.0 },
|
|
19
|
+
// Opus 4.6 (Apr 2025)
|
|
20
|
+
'claude-opus-4-6-20250415': { input_per_1m: 15.0, output_per_1m: 75.0 },
|
|
21
|
+
'claude-opus-4-6': { input_per_1m: 15.0, output_per_1m: 75.0 },
|
|
22
|
+
// Haiku 4.5 (Oct 2025)
|
|
23
|
+
'claude-haiku-4-5-20251001': { input_per_1m: 1.0, output_per_1m: 5.0 },
|
|
24
|
+
'claude-haiku-4-5': { input_per_1m: 1.0, output_per_1m: 5.0 }
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Fallback for unknown models — uses sonnet pricing (conservative). */
|
|
28
|
+
const DEFAULT_PRICING = { input_per_1m: 3.0, output_per_1m: 15.0 };
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Calculate cost in USD for a given token usage and model.
|
|
32
|
+
*
|
|
33
|
+
* @param {Object} params
|
|
34
|
+
* @param {number} params.tokens_input - Regular input tokens
|
|
35
|
+
* @param {number} params.tokens_output - Output tokens
|
|
36
|
+
* @param {string} params.model - Full Anthropic model ID
|
|
37
|
+
* @param {number} [params.cache_creation=0] - Cache creation (write) tokens
|
|
38
|
+
* @param {number} [params.cache_read=0] - Cache read tokens
|
|
39
|
+
* @returns {number} Cost in USD, rounded to 6 decimal places
|
|
40
|
+
*/
|
|
41
|
+
function calculateCost({ tokens_input, tokens_output, model, cache_creation = 0, cache_read = 0 }) {
|
|
42
|
+
const pricing = MODEL_PRICING[model];
|
|
43
|
+
if (!pricing) {
|
|
44
|
+
logger.warning(`cost-tracker: unknown model "${model}", using DEFAULT_PRICING (sonnet)`);
|
|
45
|
+
}
|
|
46
|
+
const { input_per_1m, output_per_1m } = pricing || DEFAULT_PRICING;
|
|
47
|
+
|
|
48
|
+
const costInput = (tokens_input / 1_000_000) * input_per_1m;
|
|
49
|
+
const costCacheCreation = (cache_creation / 1_000_000) * input_per_1m * 1.25;
|
|
50
|
+
const costCacheRead = (cache_read / 1_000_000) * input_per_1m * 0.1;
|
|
51
|
+
const costOutput = (tokens_output / 1_000_000) * output_per_1m;
|
|
52
|
+
|
|
53
|
+
return Math.round((costInput + costCacheCreation + costCacheRead + costOutput) * 1_000_000) / 1_000_000;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Tracks costs across multiple Claude SDK calls within a single invocation.
|
|
58
|
+
*/
|
|
59
|
+
class CostTracker {
|
|
60
|
+
constructor() {
|
|
61
|
+
this.entries = [];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Record a single SDK call.
|
|
66
|
+
*
|
|
67
|
+
* @param {Object} params
|
|
68
|
+
* @param {string} params.command - Caller label (e.g. 'executeClaude', 'analyzeCode')
|
|
69
|
+
* @param {string} params.model - Full Anthropic model ID
|
|
70
|
+
* @param {number} params.tokens_input - Input tokens
|
|
71
|
+
* @param {number} params.tokens_output - Output tokens
|
|
72
|
+
* @param {number} [params.cache_creation=0] - Cache creation tokens
|
|
73
|
+
* @param {number} [params.cache_read=0] - Cache read tokens
|
|
74
|
+
* @param {number} [params.duration_ms=0] - Wall-clock milliseconds
|
|
75
|
+
*/
|
|
76
|
+
add({ command, model, tokens_input, tokens_output, cache_creation = 0, cache_read = 0, duration_ms = 0 }) {
|
|
77
|
+
const cost_usd = calculateCost({ tokens_input, tokens_output, model, cache_creation, cache_read });
|
|
78
|
+
this.entries.push({
|
|
79
|
+
command,
|
|
80
|
+
model,
|
|
81
|
+
tokens_input,
|
|
82
|
+
tokens_output,
|
|
83
|
+
cache_creation_tokens: cache_creation,
|
|
84
|
+
cache_read_tokens: cache_read,
|
|
85
|
+
cost_usd,
|
|
86
|
+
duration_ms
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Export tracker data as a plain object suitable for JSON serialization.
|
|
92
|
+
*
|
|
93
|
+
* @returns {{ entries: Array, totals: Object }}
|
|
94
|
+
*/
|
|
95
|
+
toJSON() {
|
|
96
|
+
const totals = {
|
|
97
|
+
input_tokens: 0,
|
|
98
|
+
output_tokens: 0,
|
|
99
|
+
cache_creation_tokens: 0,
|
|
100
|
+
cache_read_tokens: 0,
|
|
101
|
+
cost_usd: 0,
|
|
102
|
+
duration_ms: 0
|
|
103
|
+
};
|
|
104
|
+
for (const e of this.entries) {
|
|
105
|
+
totals.input_tokens += e.tokens_input;
|
|
106
|
+
totals.output_tokens += e.tokens_output;
|
|
107
|
+
totals.cache_creation_tokens += e.cache_creation_tokens;
|
|
108
|
+
totals.cache_read_tokens += e.cache_read_tokens;
|
|
109
|
+
totals.cost_usd += e.cost_usd;
|
|
110
|
+
totals.duration_ms += e.duration_ms;
|
|
111
|
+
}
|
|
112
|
+
totals.cost_usd = Math.round(totals.cost_usd * 1_000_000) / 1_000_000;
|
|
113
|
+
|
|
114
|
+
return { entries: [...this.entries], totals };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Emit one structured JSON log line per entry via logger.info().
|
|
119
|
+
* Designed for CloudWatch Logs Insights: `fields @timestamp, command, cost_usd`.
|
|
120
|
+
*/
|
|
121
|
+
logSummary() {
|
|
122
|
+
for (const entry of this.entries) {
|
|
123
|
+
logger.info(JSON.stringify({ metric: 'claude_hooks_cost', ...entry }));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export { MODEL_PRICING, DEFAULT_PRICING, calculateCost, CostTracker };
|
|
@@ -183,7 +183,7 @@ const _buildCommonContext = (filesData, batchGroups) => {
|
|
|
183
183
|
* @param {Array<import('./analysis-engine.js').FileData>} filesData
|
|
184
184
|
* @returns {Promise<{batches: Array<{files: FileData[], rationale: string, model: string}>, commonContext: string}>}
|
|
185
185
|
*/
|
|
186
|
-
export const orchestrateBatches = async (filesData) => {
|
|
186
|
+
export const orchestrateBatches = async (filesData, { headless = false } = {}) => {
|
|
187
187
|
logger.debug('diff-analysis-orchestrator - orchestrateBatches', 'Building file overview', {
|
|
188
188
|
fileCount: filesData.length
|
|
189
189
|
});
|
|
@@ -223,6 +223,7 @@ export const orchestrateBatches = async (filesData) => {
|
|
|
223
223
|
rawResponse = await executeClaudeWithRetry(prompt, {
|
|
224
224
|
model: ORCHESTRATOR_MODEL,
|
|
225
225
|
timeout: ORCHESTRATOR_TIMEOUT,
|
|
226
|
+
headless,
|
|
226
227
|
telemetryContext: {
|
|
227
228
|
hook: 'orchestrator',
|
|
228
229
|
fileCount: filesData.length,
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* - logger: For debug and error logging
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import { execSync } from 'child_process';
|
|
15
|
+
import { execSync, spawnSync } from 'child_process';
|
|
16
16
|
import fs from 'fs';
|
|
17
17
|
import path from 'path';
|
|
18
18
|
import logger from './logger.js';
|
|
@@ -1546,6 +1546,106 @@ const getCommitFiles = (hash, { repoPath } = {}) => {
|
|
|
1546
1546
|
}
|
|
1547
1547
|
};
|
|
1548
1548
|
|
|
1549
|
+
/**
|
|
1550
|
+
* Gets the SHA of the current staged tree (index)
|
|
1551
|
+
* Why: Used as a fingerprint to verify that staged content hasn't changed between
|
|
1552
|
+
* pre-commit (writes marker) and prepare-commit-msg (reads marker). Identical
|
|
1553
|
+
* staging produces identical SHA; any `git add` in between invalidates it.
|
|
1554
|
+
*
|
|
1555
|
+
* Side effect: git write-tree creates a loose object in .git/objects/ as a
|
|
1556
|
+
* by-product of computing the tree SHA. The object is harmless and will be
|
|
1557
|
+
* collected by the next `git gc` run.
|
|
1558
|
+
*
|
|
1559
|
+
* @returns {string} 40-character hex SHA of the staged tree
|
|
1560
|
+
* @throws {GitError} If git write-tree fails
|
|
1561
|
+
*/
|
|
1562
|
+
const getStagedTreeSha = () => {
|
|
1563
|
+
logger.debug('git-operations - getStagedTreeSha', 'Computing staged tree SHA');
|
|
1564
|
+
|
|
1565
|
+
const sha = execGitCommand('write-tree');
|
|
1566
|
+
|
|
1567
|
+
logger.debug('git-operations - getStagedTreeSha', 'Tree SHA computed', { sha });
|
|
1568
|
+
return sha;
|
|
1569
|
+
};
|
|
1570
|
+
|
|
1571
|
+
/**
|
|
1572
|
+
* Checks whether a commit has the Hooks-Verified trailer set to 'true'
|
|
1573
|
+
* Why: Used by tests and downstream CI checks (CT-702) to verify that
|
|
1574
|
+
* a commit passed pre-commit analysis
|
|
1575
|
+
*
|
|
1576
|
+
* @param {string} commitHash - Commit hash to check (short or full SHA)
|
|
1577
|
+
* @returns {boolean} True if the commit has Hooks-Verified: true trailer
|
|
1578
|
+
*/
|
|
1579
|
+
const hasHooksVerifiedTrailer = (commitHash) => {
|
|
1580
|
+
logger.debug('git-operations - hasHooksVerifiedTrailer', 'Checking trailer', { commitHash });
|
|
1581
|
+
|
|
1582
|
+
const sanitizedHash = /^[0-9a-f]{7,40}$/i.test(String(commitHash)) ? commitHash : null;
|
|
1583
|
+
if (!sanitizedHash) {
|
|
1584
|
+
throw new GitError('Invalid commit hash', {
|
|
1585
|
+
command: 'hasHooksVerifiedTrailer',
|
|
1586
|
+
output: commitHash
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
try {
|
|
1591
|
+
const output = execGitCommand(
|
|
1592
|
+
`log -1 --format=%(trailers:key=Hooks-Verified,valueonly) ${sanitizedHash}`
|
|
1593
|
+
);
|
|
1594
|
+
const hasTrailer = output.trim().includes('true');
|
|
1595
|
+
|
|
1596
|
+
logger.debug('git-operations - hasHooksVerifiedTrailer', 'Trailer check result', {
|
|
1597
|
+
commitHash: sanitizedHash,
|
|
1598
|
+
hasTrailer
|
|
1599
|
+
});
|
|
1600
|
+
return hasTrailer;
|
|
1601
|
+
} catch (error) {
|
|
1602
|
+
logger.error(
|
|
1603
|
+
'git-operations - hasHooksVerifiedTrailer',
|
|
1604
|
+
'Failed to check trailer',
|
|
1605
|
+
error
|
|
1606
|
+
);
|
|
1607
|
+
return false;
|
|
1608
|
+
}
|
|
1609
|
+
};
|
|
1610
|
+
|
|
1611
|
+
/**
|
|
1612
|
+
* Appends a git trailer to a commit message using git interpret-trailers
|
|
1613
|
+
* Why: git interpret-trailers is the canonical implementation — it handles existing
|
|
1614
|
+
* trailer blocks, comment lines, signed-off-by blocks, and deduplication correctly.
|
|
1615
|
+
* Reimplementing from scratch is fragile and error-prone.
|
|
1616
|
+
*
|
|
1617
|
+
* @param {string} message - Current commit message
|
|
1618
|
+
* @param {string} key - Trailer key (e.g., 'Hooks-Verified')
|
|
1619
|
+
* @param {string} value - Trailer value (e.g., 'true')
|
|
1620
|
+
* @returns {string} Message with trailer appended
|
|
1621
|
+
*/
|
|
1622
|
+
const appendTrailer = (message, key, value) => {
|
|
1623
|
+
logger.debug('git-operations - appendTrailer', 'Appending trailer', { key, value });
|
|
1624
|
+
|
|
1625
|
+
try {
|
|
1626
|
+
// Why: spawnSync avoids shell interpretation — key/value are passed as discrete
|
|
1627
|
+
// argv entries, not interpolated into a shell command string
|
|
1628
|
+
const result = spawnSync('git', ['interpret-trailers', '--trailer', `${key}: ${value}`], {
|
|
1629
|
+
input: message,
|
|
1630
|
+
encoding: 'utf8',
|
|
1631
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
1632
|
+
});
|
|
1633
|
+
|
|
1634
|
+
if (result.error || result.status !== 0) {
|
|
1635
|
+
throw result.error || new Error(result.stderr || 'interpret-trailers failed');
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
logger.debug('git-operations - appendTrailer', 'Trailer appended successfully');
|
|
1639
|
+
return result.stdout;
|
|
1640
|
+
} catch (error) {
|
|
1641
|
+
logger.error('git-operations - appendTrailer', 'Failed to append trailer', error);
|
|
1642
|
+
throw new GitError('Failed to append trailer', {
|
|
1643
|
+
command: `git interpret-trailers --trailer "${key}: ${value}"`,
|
|
1644
|
+
cause: error
|
|
1645
|
+
});
|
|
1646
|
+
}
|
|
1647
|
+
};
|
|
1648
|
+
|
|
1549
1649
|
export {
|
|
1550
1650
|
GitError,
|
|
1551
1651
|
getStagedFiles,
|
|
@@ -1581,5 +1681,8 @@ export {
|
|
|
1581
1681
|
getLatestTag,
|
|
1582
1682
|
isWorkingDirectoryClean,
|
|
1583
1683
|
getActiveBranch,
|
|
1584
|
-
getCommitFiles
|
|
1684
|
+
getCommitFiles,
|
|
1685
|
+
getStagedTreeSha,
|
|
1686
|
+
hasHooksVerifiedTrailer,
|
|
1687
|
+
appendTrailer
|
|
1585
1688
|
};
|