Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 213 additions & 18 deletions src/application/handlers/CommitMsgHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
* Handles the commit-msg git hook event.
* Analyzes the commit message, infers memory metadata,
* and appends AI-* trailers to the commit message file.
*
* When LLM enrichment is enabled and available, uses Claude to generate
* richer trailer content based on the staged diff. Falls back to heuristic
* analysis if enrichment fails or is unavailable.
*/

import { readFileSync } from 'fs';
Expand All @@ -23,14 +27,19 @@ import {
import type { IAgentResolver } from '../../domain/interfaces/IAgentResolver';
import type { IHookConfigLoader } from '../../domain/interfaces/IHookConfigLoader';
import type { ICommitMsgConfig } from '../../domain/interfaces/IHookConfig';
import type { ILLMClient, ILLMExtractedFact } from '../../domain/interfaces/ILLMClient';

/** Maximum diff size to send to LLM (10KB). */
const MAX_DIFF_LENGTH = 10_000;

export class CommitMsgHandler implements IEventHandler<ICommitMsgEvent> {
constructor(
private readonly commitAnalyzer: ICommitAnalyzer,
private readonly gitClient: IGitClient,
private readonly logger: ILogger,
private readonly agentResolver: IAgentResolver,
private readonly configLoader: IHookConfigLoader
private readonly configLoader: IHookConfigLoader,
private readonly llmClient?: ILLMClient | null,
) {}

async handle(event: ICommitMsgEvent): Promise<IEventResult> {
Expand Down Expand Up @@ -63,30 +72,48 @@ export class CommitMsgHandler implements IEventHandler<ICommitMsgEvent> {
return { handler: 'CommitMsgHandler', success: true };
}

// 5. Get staged files (only if inferTags is enabled)
const stagedFiles = config.inferTags
? this.gitClient.diffStagedNames(event.cwd)
: [];
// 5. Get staged files (for tags and enrichment)
const stagedFiles = this.gitClient.diffStagedNames(event.cwd);

// 6. Analyze the commit message
const analysis = this.commitAnalyzer.analyze(message, stagedFiles);
// 6. Attempt LLM enrichment if enabled and available
const shouldEnrich = config.enrich && this.llmClient != null;
let enrichedFacts: ILLMExtractedFact[] | null = null;

// 7. Check requireType config - skip if no type detected and requireType is true
if (config.requireType && !analysis.type) {
this.logger.debug('No memory type detected and requireType is true, skipping');
return { handler: 'CommitMsgHandler', success: true };
if (shouldEnrich) {
enrichedFacts = await this.attemptEnrichment(
message,
stagedFiles,
config.enrichTimeout,
event.cwd,
);
}

// 8. Build trailers (skip Agent/Model if already present from prepare-commit-msg)
const trailers = this.buildTrailers(analysis, config, message);
// 7. Build trailers - use enrichment if available, else heuristic
let trailers: ITrailer[];
let source: 'llm-enrichment' | 'heuristic';

if (enrichedFacts && enrichedFacts.length > 0) {
trailers = this.buildEnrichedTrailers(enrichedFacts, config, message);
source = 'llm-enrichment';
} else {
// Fallback to heuristic analysis
const analysis = this.commitAnalyzer.analyze(message, stagedFiles);

// Check requireType config - skip if no type detected and requireType is true
if (config.requireType && !analysis.type) {
this.logger.debug('No memory type detected and requireType is true, skipping');
return { handler: 'CommitMsgHandler', success: true };
}

trailers = this.buildTrailers(analysis, config, message);
source = 'heuristic';
}

// 9. Append trailers to the commit message using git interpret-trailers
// 8. Append trailers to the commit message using git interpret-trailers
await this.appendTrailers(event.commitMsgPath, trailers, event.cwd);

this.logger.info('Commit message analyzed and trailers added', {
type: analysis.type,
tags: analysis.tags,
confidence: analysis.confidence,
source,
trailerCount: trailers.length,
});

Expand All @@ -104,6 +131,79 @@ export class CommitMsgHandler implements IEventHandler<ICommitMsgEvent> {
}
}

/**
* Attempt LLM enrichment with timeout.
* Returns extracted facts on success, null on failure or timeout.
*/
private async attemptEnrichment(
message: string,
stagedFiles: string[],
timeoutMs: number,
cwd: string,
): Promise<ILLMExtractedFact[] | null> {
if (!this.llmClient) return null;

try {
// Get staged diff for LLM context
const diff = this.gitClient.diffStaged(cwd);
const truncatedDiff = this.truncateDiff(diff, MAX_DIFF_LENGTH);

// Parse commit message for LLM input
const lines = message.split('\n');
const subject = lines[0] || '';
const body = lines.slice(2).join('\n').trim(); // Skip blank line after subject
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit message body parsing for enrichment always does lines.slice(2) (assuming a blank line after the subject). If a commit message has no blank separator line, the first body line will be dropped. Consider using the same "first blank line" logic as CommitAnalyzer.parseConventionalCommit() (or reusing that method) to derive subject/body reliably.

Suggested change
const body = lines.slice(2).join('\n').trim(); // Skip blank line after subject
let body = '';
if (lines.length > 1) {
const rest = lines.slice(1);
const firstBlankIdx = rest.findIndex((line) => line.trim() === '');
const bodyLines =
firstBlankIdx === -1 ? rest : rest.slice(firstBlankIdx + 1);
body = bodyLines.join('\n').trim();
}

Copilot uses AI. Check for mistakes.

// Race enrichment against timeout
const enrichmentPromise = this.llmClient.enrichCommit({
sha: 'staged', // No SHA yet - use placeholder
subject,
body,
diff: truncatedDiff,
filesChanged: stagedFiles,
});

let timeoutId: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<null>((resolve) => {
timeoutId = setTimeout(() => resolve(null), timeoutMs);
});

const result = await Promise.race([enrichmentPromise, timeoutPromise]);
Comment on lines +156 to +170
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the timeout is reached, the Promise.race completes, but the LLM API call continues running in the background. This can lead to:

  1. Wasted API credits if the call eventually succeeds
  2. Potential resource leaks in the HTTP client
  3. Unhandled promise rejections if the LLM call fails after timeout

Consider using AbortController/AbortSignal to properly cancel the LLM request when the timeout is reached. The enrichCommit interface would need to accept an optional signal parameter for this to work properly.

Suggested change
// Race enrichment against timeout
const enrichmentPromise = this.llmClient.enrichCommit({
sha: 'staged', // No SHA yet - use placeholder
subject,
body,
diff: truncatedDiff,
filesChanged: stagedFiles,
});
const timeoutPromise = new Promise<null>((resolve) => {
setTimeout(() => resolve(null), timeoutMs);
});
const result = await Promise.race([enrichmentPromise, timeoutPromise]);
// Race enrichment against timeout, with abort support
const abortController = new AbortController();
const { signal } = abortController;
const enrichmentPromise = (this.llmClient.enrichCommit as any)({
sha: 'staged', // No SHA yet - use placeholder
subject,
body,
diff: truncatedDiff,
filesChanged: stagedFiles,
signal,
}).catch((err: any) => {
// Treat abort-related errors as a timed-out enrichment
if (err && (err.name === 'AbortError' || err.code === 'ABORT_ERR')) {
return null;
}
throw err;
});
let timeoutId: ReturnType<typeof setTimeout>;
const timeoutPromise = new Promise<null>((resolve) => {
timeoutId = setTimeout(() => {
// Abort the LLM request when the timeout is reached
abortController.abort();
resolve(null);
}, timeoutMs);
});
const result = await Promise.race([enrichmentPromise, timeoutPromise]);
if (timeoutId) {
clearTimeout(timeoutId);
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point about AbortController. For now, clearing the timeout is the minimal fix. The LLM call will complete in the background but won't affect the commit. Can add abort support in a follow-up if API costs become a concern.


// Clear timeout if enrichment completed first
if (timeoutId) {
clearTimeout(timeoutId);
}

if (result === null) {
this.logger.warn('LLM enrichment timed out', { timeoutMs });
return null;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

this.logger.debug('LLM enrichment successful', {
factsExtracted: result.facts.length,
inputTokens: result.usage.inputTokens,
outputTokens: result.usage.outputTokens,
});

return [...result.facts];
} catch (error) {
this.logger.warn('LLM enrichment failed, falling back to heuristic', {
error: error instanceof Error ? error.message : String(error),
});
return null;
}
}

/**
* Truncate diff to max length, preserving line boundaries.
*/
private truncateDiff(diff: string, maxLength: number): string {
if (diff.length <= maxLength) return diff;
const truncated = diff.slice(0, maxLength);
const lastNewline = truncated.lastIndexOf('\n');
return lastNewline > 0 ? truncated.slice(0, lastNewline) : truncated;
}

/**
* Check if analysis should be skipped for special commit types.
* Returns the skip reason, or null if analysis should proceed.
Expand Down Expand Up @@ -155,7 +255,99 @@ export class CommitMsgHandler implements IEventHandler<ICommitMsgEvent> {
}

/**
* Build all AI-* trailers from the analysis result.
* Build trailers from LLM enrichment results.
*/
private buildEnrichedTrailers(
facts: ILLMExtractedFact[],
config: ICommitMsgConfig,
existingMessage: string,
): ITrailer[] {
const trailers: ITrailer[] = [];

// Agent and Model trailers (skip if already present)
const hasAgent = existingMessage.includes('AI-Agent:');
const hasModel = existingMessage.includes('AI-Model:');
Comment on lines +268 to +269
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When checking for existing trailers with existingMessage.includes('AI-Agent:'), this could produce false positives if 'AI-Agent:' appears in the commit message body itself (e.g., in a quoted text or discussion about the AI-Agent trailer).

Consider using a more robust check that specifically looks for the trailer in the git trailer block, or use the git CLI to parse existing trailers. This same issue affects the AI-Model check on line 263 and similar checks in buildTrailers (lines 352-353).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged - the simple string check is a pragmatic trade-off. False positives are unlikely in practice since 'AI-Agent:' is an unusual string to include in commit message bodies. Can revisit if it becomes an issue.


if (!hasAgent) {
const agent = this.agentResolver.resolveAgent();
if (agent) trailers.push({ key: AI_TRAILER_KEYS.AGENT, value: agent });
}
if (!hasModel) {
const model = this.agentResolver.resolveModel();
if (model) trailers.push({ key: AI_TRAILER_KEYS.MODEL, value: model });
}

// Select best fact for primary trailer (highest priority type/confidence)
const bestFact = this.selectBestFact(facts);

if (bestFact) {
const trailerKey = MEMORY_TYPE_TO_TRAILER_KEY[bestFact.type];
if (trailerKey) {
const truncatedContent = bestFact.content.slice(0, 200);
trailers.push({ key: trailerKey, value: truncatedContent });
}
Comment on lines +284 to +288
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LLM-provided bestFact.content is written directly into a git trailer value. Since LLM output can contain newlines or other control characters, this can make git interpret-trailers fail or produce malformed commit messages. Sanitize trailer values to a single line (e.g., collapse whitespace / strip \r/\n) before appending.

Copilot uses AI. Check for mistakes.

// Use confidence from LLM
trailers.push({ key: AI_TRAILER_KEYS.CONFIDENCE, value: bestFact.confidence });

// Merge tags from all facts (respect inferTags setting)
if (config.inferTags) {
const allTags = new Set<string>();
for (const fact of facts) {
for (const tag of fact.tags) {
allTags.add(tag);
}
}
if (allTags.size > 0) {
trailers.push({ key: AI_TRAILER_KEYS.TAGS, value: [...allTags].join(', ') });
}
Comment on lines +293 to +303
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly, LLM-provided tags are merged and emitted without normalization/sanitization. Tags containing newlines/commas or leading/trailing whitespace can break the AI-Tags trailer format. Consider trimming each tag and filtering out empty/unsafe values before joining.

Copilot uses AI. Check for mistakes.
}
}

// Lifecycle from config
trailers.push({ key: AI_TRAILER_KEYS.LIFECYCLE, value: config.defaultLifecycle });

// Memory ID
trailers.push({ key: AI_TRAILER_KEYS.MEMORY_ID, value: this.generateMemoryId() });

// Source indicator
trailers.push({ key: AI_TRAILER_KEYS.SOURCE, value: 'llm-enrichment' });

return trailers;
}

/**
* Select the best fact from LLM results.
* Priority: decision > gotcha > convention > fact
* Within same type: verified > high > medium > low
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The doc comment says confidence priority is verified > high > medium > low, but the implementation also handles uncertain (and will treat unknowns as 0). Update the comment to match the actual ordering (including uncertain) to avoid confusion when maintaining the ranking logic.

Suggested change
* Within same type: verified > high > medium > low
* Within same type: verified > high > medium > low > uncertain (unknown treated as lowest/0)

Copilot uses AI. Check for mistakes.
*/
private selectBestFact(facts: ILLMExtractedFact[]): ILLMExtractedFact | null {
if (facts.length === 0) return null;

const typePriority: Record<string, number> = {
decision: 4,
gotcha: 3,
convention: 2,
fact: 1,
};

const confidencePriority: Record<string, number> = {
verified: 4,
high: 3,
medium: 2,
low: 1,
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The selectBestFact method doesn't handle the 'uncertain' confidence level in the confidencePriority map, but it exists in the ConfidenceLevel type ('verified' | 'high' | 'medium' | 'low' | 'uncertain'). While the current code uses a default of 0 for unknown values, it would be more explicit and maintainable to include 'uncertain' with a priority value (likely 0 or -1) in the map.

Suggested change
low: 1,
low: 1,
uncertain: 0,

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2d50f69 - added 'uncertain: 0' to the confidencePriority map.

uncertain: 0,
};

return [...facts].sort((a, b) => {
const typeDiff = (typePriority[b.type] || 0) - (typePriority[a.type] || 0);
if (typeDiff !== 0) return typeDiff;
return (confidencePriority[b.confidence] || 0) - (confidencePriority[a.confidence] || 0);
})[0];
}

/**
* Build all AI-* trailers from the heuristic analysis result.
* Skips Agent/Model if they already exist (from prepare-commit-msg).
*/
private buildTrailers(
Expand Down Expand Up @@ -206,6 +398,9 @@ export class CommitMsgHandler implements IEventHandler<ICommitMsgEvent> {
// Add memory ID for tracking
trailers.push({ key: AI_TRAILER_KEYS.MEMORY_ID, value: this.generateMemoryId() });

// Source indicator for heuristic analysis
trailers.push({ key: AI_TRAILER_KEYS.SOURCE, value: 'heuristic' });

return trailers;
}

Expand Down
1 change: 1 addition & 0 deletions src/domain/entities/ITrailer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const AI_TRAILER_KEYS = {
MEMORY_ID: 'AI-Memory-Id',
AGENT: 'AI-Agent',
MODEL: 'AI-Model',
SOURCE: 'AI-Source',
} as const;

/**
Expand Down
4 changes: 4 additions & 0 deletions src/domain/interfaces/IHookConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ export interface ICommitMsgConfig {
readonly requireType: boolean;
/** Default memory lifecycle. */
readonly defaultLifecycle: 'permanent' | 'project' | 'session';
/** Enable LLM enrichment for richer trailer content. Requires ANTHROPIC_API_KEY. */
readonly enrich: boolean;
/** Timeout in ms for LLM enrichment call. Default: 5000. */
readonly enrichTimeout: number;
}

export interface IHooksConfig {
Expand Down
2 changes: 2 additions & 0 deletions src/hooks/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ const DEFAULTS: IHookConfig = {
inferTags: true,
requireType: false,
defaultLifecycle: 'project',
enrich: true,
enrichTimeout: 5000,
},
},
};
Expand Down
10 changes: 9 additions & 1 deletion src/infrastructure/di/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,21 @@ export function createContainer(options?: IContainerOptions): AwilixContainer<IC
container.cradle.logger,
container.cradle.agentResolver,
container.cradle.hookConfigLoader,
container.cradle.llmClient,
));

return bus;
}).singleton(),

llmClient: asFunction(() => {
Copy link

Copilot AI Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change breaks the existing container behavior and will cause test failures. The existing tests in tests/unit/infrastructure/di/container.test.ts (lines 119-133) expect that when createContainer({ enrich: false }) is called, the llmClient should be null.

Removing the options?.enrich check means the LLM client will always be created when ANTHROPIC_API_KEY is available, regardless of the enrich option. This is a breaking change to the container API.

If the intention is to always create the LLM client and control usage via the handler's config, then the IContainerOptions.enrich field should be removed and the container tests should be updated accordingly. Otherwise, restore the original conditional logic.

Suggested change
llmClient: asFunction(() => {
llmClient: asFunction(() => {
// Preserve existing container behavior: when `enrich` is explicitly
// set to false, do not create an LLM client and expose `null` instead.
if (options && options.enrich === false) {
return null;
}

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 2d50f69 - updated to only skip LLM client creation when enrich is explicitly false. When not specified, it now attempts to create the client (allowing hooks to use enrichment without explicit opt-in). Updated test to reflect this behavior.

return options?.enrich ? (createLLMClient() ?? null) : null;
// When enrich is explicitly false, do not create LLM client.
// When enrich is true or not specified, attempt to create it.
// This allows hooks to use LLM enrichment without explicit opt-in,
// while still respecting explicit enrich:false from CLI commands.
if (options?.enrich === false) {
return null;
}
return createLLMClient() ?? null;
}).singleton(),

// ── Application services ─────────────────────────────────────
Expand Down
Loading