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.
@@ -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
- new Promise((resolve, reject) => {
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
- return withRetry(
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 result = extractJSON(response, telemetryContext);
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 result,
1076
- hasQualityGate: 'QUALITY_GATE' in result,
1077
- blockingIssuesCount: result.blockingIssues?.length ?? 0,
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 result;
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.type === ClaudeErrorType.EXECUTION_ERROR ||
359
- errorInfo.type === ClaudeErrorType.RATE_LIMIT ||
360
- errorInfo.type === ClaudeErrorType.NETWORK ||
361
- errorInfo.type === ClaudeErrorType.TIMEOUT;
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
  };