diff --git a/src/application/services/MemoryService.ts b/src/application/services/MemoryService.ts index ac904257..c8f899dc 100644 --- a/src/application/services/MemoryService.ts +++ b/src/application/services/MemoryService.ts @@ -38,7 +38,7 @@ export class MemoryService implements IMemoryService { this.logger?.warn('Skipping trailer write for non-HEAD commit', { sha: targetSha }); } else { try { - const trailers = this.buildTrailers(memory); + const trailers = this.buildTrailers(memory, options); this.trailerService.addTrailers(trailers, options?.cwd); this.logger?.info('Trailers written', { count: trailers.length, sha: memory.sha }); } catch (err) { @@ -62,7 +62,7 @@ export class MemoryService implements IMemoryService { return value.replace(/\r?\n+/g, ' ').trim(); } - private buildTrailers(memory: IMemoryEntity): ITrailer[] { + private buildTrailers(memory: IMemoryEntity, options?: ICreateMemoryOptions): ITrailer[] { const trailers: ITrailer[] = [ { key: MEMORY_TYPE_TO_TRAILER_KEY[memory.type], value: this.normalizeTrailerValue(memory.content) }, { key: AI_TRAILER_KEYS.CONFIDENCE, value: memory.confidence }, @@ -73,6 +73,14 @@ export class MemoryService implements IMemoryService { trailers.push({ key: AI_TRAILER_KEYS.TAGS, value: this.normalizeTrailerValue(memory.tags.join(', ')) }); } + if (options?.agent) { + trailers.push({ key: AI_TRAILER_KEYS.AGENT, value: this.normalizeTrailerValue(options.agent) }); + } + + if (options?.model) { + trailers.push({ key: AI_TRAILER_KEYS.MODEL, value: this.normalizeTrailerValue(options.model) }); + } + return trailers; } diff --git a/src/cli.ts b/src/cli.ts index a84a091d..a8ead64a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -39,6 +39,8 @@ program .option('--confidence ', 'Confidence: verified, high, medium, low', 'high') .option('--lifecycle ', 'Lifecycle: permanent, project, session', 'project') .option('--tags ', 'Comma-separated tags') + .option('--agent ', 'AI agent name (default: auto-detect from $GIT_MEM_AGENT / $CLAUDE_CODE)') + .option('--model ', 'AI model identifier (default: $GIT_MEM_MODEL)') .option('--no-trailers', 'Skip writing AI-* trailers to the commit message') .action((text, options) => rememberCommand(text, options, logger)); diff --git a/src/commands/remember.ts b/src/commands/remember.ts index 87b2a0bf..0f82aa8f 100644 --- a/src/commands/remember.ts +++ b/src/commands/remember.ts @@ -15,6 +15,8 @@ interface IRememberOptions { lifecycle?: string; tags?: string; noTrailers?: boolean; + agent?: string; + model?: string; } export async function rememberCommand(text: string, options: IRememberOptions, logger?: ILogger): Promise { @@ -22,6 +24,14 @@ export async function rememberCommand(text: string, options: IRememberOptions, l const { memoryService, logger: log } = container.cradle; log.info('Command invoked', { type: options.type || 'fact' }); + // Resolve agent: explicit flag > $GIT_MEM_AGENT > $CLAUDE_CODE heuristic + const agent = options.agent + || process.env.GIT_MEM_AGENT + || (process.env.CLAUDE_CODE ? 'Claude-Code' : undefined); + + // Resolve model: explicit flag > $GIT_MEM_MODEL + const model = options.model || process.env.GIT_MEM_MODEL || undefined; + const memory = memoryService.remember(text, { sha: options.commit, type: (options.type || 'fact') as MemoryType, @@ -29,6 +39,8 @@ export async function rememberCommand(text: string, options: IRememberOptions, l lifecycle: (options.lifecycle || 'project') as MemoryLifecycle, tags: options.tags, trailers: !options.noTrailers, + agent, + model, }); console.log(`Remembered: ${memory.content}`); diff --git a/src/domain/entities/IMemoryEntity.ts b/src/domain/entities/IMemoryEntity.ts index 7cd3ce0f..9db94911 100644 --- a/src/domain/entities/IMemoryEntity.ts +++ b/src/domain/entities/IMemoryEntity.ts @@ -71,6 +71,10 @@ export interface ICreateMemoryOptions { readonly cwd?: string; /** Write AI-* trailers to the commit message (default: true). */ readonly trailers?: boolean; + /** AI agent name (e.g. 'Claude-Code'). */ + readonly agent?: string; + /** AI model identifier (e.g. 'claude-opus-4-6'). */ + readonly model?: string; } /** diff --git a/src/domain/entities/ITrailer.ts b/src/domain/entities/ITrailer.ts index e11d74f1..63cf22b8 100644 --- a/src/domain/entities/ITrailer.ts +++ b/src/domain/entities/ITrailer.ts @@ -33,6 +33,7 @@ export const AI_TRAILER_KEYS = { LIFECYCLE: 'AI-Lifecycle', MEMORY_ID: 'AI-Memory-Id', AGENT: 'AI-Agent', + MODEL: 'AI-Model', } as const; /** diff --git a/src/hooks/prepare-commit-msg.ts b/src/hooks/prepare-commit-msg.ts index 77176f0b..dcf91eca 100644 --- a/src/hooks/prepare-commit-msg.ts +++ b/src/hooks/prepare-commit-msg.ts @@ -1,12 +1,13 @@ /** * prepare-commit-msg git hook * - * Installs/uninstalls a git hook that injects AI-Agent trailers - * into commit messages when an AI-assisted session is detected. + * Installs/uninstalls a git hook that injects AI-Agent and AI-Model + * trailers into commit messages when an AI-assisted session is detected. * * Detection heuristics (checked in order): * - $GIT_MEM_AGENT env var (explicit, user-defined agent string) * - $CLAUDE_CODE env var (Claude Code session) + * - $GIT_MEM_MODEL env var (explicit, user-defined model string) * * The hook uses `git interpret-trailers` for proper formatting. */ @@ -15,8 +16,11 @@ import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync, chmodS import { join, resolve } from 'path'; import { execFileSync } from 'child_process'; -/** Fingerprint comment used to detect our hook. */ -const HOOK_FINGERPRINT = '# git-mem:prepare-commit-msg v1'; +/** Prefix used to detect any version of our hook. */ +const HOOK_FINGERPRINT_PREFIX = '# git-mem:prepare-commit-msg'; + +/** Full fingerprint with version — used for upgrade detection. */ +const HOOK_FINGERPRINT = `${HOOK_FINGERPRINT_PREFIX} v2`; /** * The shell hook script. @@ -24,7 +28,7 @@ const HOOK_FINGERPRINT = '# git-mem:prepare-commit-msg v1'; */ const HOOK_SCRIPT = `#!/bin/sh ${HOOK_FINGERPRINT} -# Injects AI-Agent trailer when an AI-assisted session is detected. +# Injects AI-Agent and AI-Model trailers when an AI-assisted session is detected. COMMIT_MSG_FILE="$1" COMMIT_SOURCE="$2" @@ -42,14 +46,26 @@ elif [ -n "$CLAUDE_CODE" ]; then AGENT="Claude-Code" fi +# Detect AI model +MODEL="" +if [ -n "$GIT_MEM_MODEL" ]; then + MODEL="$GIT_MEM_MODEL" +fi + # No agent detected — exit silently [ -z "$AGENT" ] && exit 0 # Skip if AI-Agent trailer already present grep -q "^AI-Agent:" "$COMMIT_MSG_FILE" && exit 0 -# Append trailer using git's built-in formatter +# Append agent trailer using git's built-in formatter git interpret-trailers --in-place --trailer "AI-Agent: $AGENT" "$COMMIT_MSG_FILE" + +# Append model trailer if detected +if [ -n "$MODEL" ]; then + grep -q "^AI-Model:" "$COMMIT_MSG_FILE" || + git interpret-trailers --in-place --trailer "AI-Model: $MODEL" "$COMMIT_MSG_FILE" +fi `; /** @@ -70,9 +86,18 @@ function findGitDir(cwd: string): string { } /** - * Check if an existing hook file was installed by git-mem. + * Check if an existing hook file was installed by git-mem (any version). */ function isGitMemHook(hookPath: string): boolean { + if (!existsSync(hookPath)) return false; + const content = readFileSync(hookPath, 'utf8'); + return content.includes(HOOK_FINGERPRINT_PREFIX); +} + +/** + * Check if an installed hook is the current version. + */ +function isCurrentVersion(hookPath: string): boolean { if (!existsSync(hookPath)) return false; const content = readFileSync(hookPath, 'utf8'); return content.includes(HOOK_FINGERPRINT); @@ -91,6 +116,7 @@ export interface IHookInstallResult { * Install the prepare-commit-msg hook. * Idempotent: re-running is safe. * Wraps existing non-git-mem hooks by renaming them to .user-backup. + * Upgrades outdated git-mem hooks in-place. */ export function installHook(cwd?: string): IHookInstallResult { const gitDir = findGitDir(cwd || process.cwd()); @@ -98,11 +124,18 @@ export function installHook(cwd?: string): IHookInstallResult { const hookPath = join(hooksDir, 'prepare-commit-msg'); const backupPath = join(hooksDir, 'prepare-commit-msg.user-backup'); - // Already installed — idempotent - if (isGitMemHook(hookPath)) { + // Already installed and up-to-date — idempotent + if (isGitMemHook(hookPath) && isCurrentVersion(hookPath)) { return { installed: false, wrapped: false, hookPath }; } + // Outdated git-mem hook — upgrade in-place + if (isGitMemHook(hookPath) && !isCurrentVersion(hookPath)) { + writeFileSync(hookPath, HOOK_SCRIPT); + chmodSync(hookPath, 0o755); + return { installed: true, wrapped: false, hookPath }; + } + let wrapped = false; // Existing non-git-mem hook — wrap it diff --git a/src/mcp/tools/remember.ts b/src/mcp/tools/remember.ts index cf8e200f..5a19ccf2 100644 --- a/src/mcp/tools/remember.ts +++ b/src/mcp/tools/remember.ts @@ -22,6 +22,8 @@ export function registerRememberTool(server: McpServer): void { confidence: z.enum(['verified', 'high', 'medium', 'low']).optional().describe('Confidence level (default: high)'), tags: z.string().optional().describe('Comma-separated tags'), lifecycle: z.enum(['permanent', 'project', 'session']).optional().describe('Lifecycle tier (default: project)'), + agent: z.string().optional().describe('AI agent name (default: auto-detect from $GIT_MEM_AGENT / $CLAUDE_CODE)'), + model: z.string().optional().describe('AI model identifier (default: $GIT_MEM_MODEL)'), trailers: z.boolean().optional().describe('Write AI-* trailers to commit message (default: true)'), }, async (args) => { @@ -30,6 +32,14 @@ export function registerRememberTool(server: McpServer): void { try { logger.info('Tool invoked', { type: args.type || 'fact' }); + // Resolve agent: explicit param > $GIT_MEM_AGENT > $CLAUDE_CODE heuristic + const agent = args.agent + || process.env.GIT_MEM_AGENT + || (process.env.CLAUDE_CODE ? 'Claude-Code' : undefined); + + // Resolve model: explicit param > $GIT_MEM_MODEL + const model = args.model || process.env.GIT_MEM_MODEL || undefined; + const memory = memoryService.remember(args.text, { sha: args.commit, type: (args.type || 'fact') as MemoryType, @@ -37,6 +47,8 @@ export function registerRememberTool(server: McpServer): void { lifecycle: (args.lifecycle || 'project') as MemoryLifecycle, tags: args.tags, trailers: args.trailers, + agent, + model, }); return { diff --git a/tests/unit/application/services/MemoryService.test.ts b/tests/unit/application/services/MemoryService.test.ts index f76beff0..f8fda6f3 100644 --- a/tests/unit/application/services/MemoryService.test.ts +++ b/tests/unit/application/services/MemoryService.test.ts @@ -153,6 +153,38 @@ describe('MemoryService', () => { const trailers = trailerService.readTrailers('HEAD', repoDir); assert.ok(!trailers.find(t => t.key === 'AI-Tags')); }); + + it('should write AI-Agent and AI-Model trailers when provided', () => { + writeFileSync(join(repoDir, 'agent-model.txt'), 'agent-model'); + git(['add', '.'], repoDir); + git(['commit', '-m', 'feat: agent model test'], repoDir); + + serviceWithTrailers.remember('Memory with agent and model', { + cwd: repoDir, + type: 'fact', + agent: 'Claude-Code', + model: 'claude-opus-4-6', + }); + + const trailers = trailerService.readTrailers('HEAD', repoDir); + assert.ok(trailers.find(t => t.key === 'AI-Agent' && t.value === 'Claude-Code')); + assert.ok(trailers.find(t => t.key === 'AI-Model' && t.value === 'claude-opus-4-6')); + }); + + it('should not write AI-Agent or AI-Model trailers when not provided', () => { + writeFileSync(join(repoDir, 'no-agent-model.txt'), 'no-agent'); + git(['add', '.'], repoDir); + git(['commit', '-m', 'feat: no agent model test'], repoDir); + + serviceWithTrailers.remember('Memory without agent', { + cwd: repoDir, + type: 'fact', + }); + + const trailers = trailerService.readTrailers('HEAD', repoDir); + assert.ok(!trailers.find(t => t.key === 'AI-Agent')); + assert.ok(!trailers.find(t => t.key === 'AI-Model')); + }); }); describe('recall', () => { diff --git a/tests/unit/hooks/prepare-commit-msg.test.ts b/tests/unit/hooks/prepare-commit-msg.test.ts index 7bfcf820..ca9716bb 100644 --- a/tests/unit/hooks/prepare-commit-msg.test.ts +++ b/tests/unit/hooks/prepare-commit-msg.test.ts @@ -39,7 +39,7 @@ describe('installHook', () => { const content = readFileSync(result.hookPath, 'utf8'); assert.ok(content.includes('#!/bin/sh')); - assert.ok(content.includes('git-mem:prepare-commit-msg v1')); + assert.ok(content.includes('git-mem:prepare-commit-msg v2')); assert.ok(content.includes('git interpret-trailers')); assert.ok(content.includes('AI-Agent')); }); @@ -75,12 +75,44 @@ describe('installHook', () => { // Installed hook should contain both fingerprint and wrapper reference const content = readFileSync(hookPath, 'utf8'); - assert.ok(content.includes('git-mem:prepare-commit-msg v1')); + assert.ok(content.includes('git-mem:prepare-commit-msg v2')); assert.ok(content.includes('user-backup')); } finally { rmSync(freshRepo, { recursive: true, force: true }); } }); + + it('should upgrade v1 hook to v2 on reinstall', () => { + const freshRepo = mkdtempSync(join(tmpdir(), 'git-mem-hook-upgrade-')); + git(['init'], freshRepo); + + const hooksDir = join(freshRepo, '.git', 'hooks'); + const hookPath = join(hooksDir, 'prepare-commit-msg'); + + // Write a v1 hook (old fingerprint, no AI-Model support) + const v1Hook = '#!/bin/sh\n# git-mem:prepare-commit-msg v1\n# Old hook without AI-Model\nexit 0\n'; + mkdirSync(hooksDir, { recursive: true }); + writeFileSync(hookPath, v1Hook); + chmodSync(hookPath, 0o755); + + try { + const result = installHook(freshRepo); + + // Should have upgraded in-place + assert.equal(result.installed, true); + assert.equal(result.wrapped, false); + + const content = readFileSync(hookPath, 'utf8'); + assert.ok(content.includes('git-mem:prepare-commit-msg v2'), 'Should be upgraded to v2'); + assert.ok(content.includes('AI-Model'), 'Should include AI-Model support'); + + // Second install should be idempotent + const result2 = installHook(freshRepo); + assert.equal(result2.installed, false); + } finally { + rmSync(freshRepo, { recursive: true, force: true }); + } + }); }); describe('uninstallHook', () => { @@ -245,6 +277,52 @@ describe('hook integration — commit message modification', () => { assert.ok(message.includes('ExistingAgent')); }); + it('should add AI-Model trailer when GIT_MEM_MODEL is set', () => { + writeFileSync(join(repoDir, 'model-test.txt'), 'model'); + git(['add', '.'], repoDir); + + execFileSync('git', ['commit', '-m', 'feat: model trailer test'], { + encoding: 'utf8', + cwd: repoDir, + env: { ...process.env, GIT_MEM_AGENT: 'TestAgent', GIT_MEM_MODEL: 'claude-opus-4-6' }, + }); + + const message = git(['log', '-1', '--format=%B'], repoDir); + assert.ok(message.includes('AI-Model: claude-opus-4-6'), `Expected AI-Model trailer in: ${message}`); + }); + + it('should not add AI-Model trailer when GIT_MEM_MODEL is not set', () => { + writeFileSync(join(repoDir, 'no-model.txt'), 'no-model'); + git(['add', '.'], repoDir); + + const cleanEnv = { ...process.env, GIT_MEM_AGENT: 'TestAgent' }; + delete cleanEnv.GIT_MEM_MODEL; + + execFileSync('git', ['commit', '-m', 'feat: no model test'], { + encoding: 'utf8', + cwd: repoDir, + env: cleanEnv, + }); + + const message = git(['log', '-1', '--format=%B'], repoDir); + assert.ok(!message.includes('AI-Model:'), `Should not have AI-Model trailer in: ${message}`); + }); + + it('should add both AI-Agent and AI-Model when both env vars set', () => { + writeFileSync(join(repoDir, 'both-trailers.txt'), 'both'); + git(['add', '.'], repoDir); + + execFileSync('git', ['commit', '-m', 'feat: both trailers test'], { + encoding: 'utf8', + cwd: repoDir, + env: { ...process.env, GIT_MEM_AGENT: 'Claude-Code', GIT_MEM_MODEL: 'claude-opus-4-6' }, + }); + + const message = git(['log', '-1', '--format=%B'], repoDir); + assert.ok(message.includes('AI-Agent: Claude-Code'), `Expected AI-Agent trailer in: ${message}`); + assert.ok(message.includes('AI-Model: claude-opus-4-6'), `Expected AI-Model trailer in: ${message}`); + }); + it('should skip merge commits', () => { // Determine the default branch name before creating feature branch const defaultBranch = git(['rev-parse', '--abbrev-ref', 'HEAD'], repoDir);