From 7dd735bfb5148926bd5d50a1d84dc241115aa5ff Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Fri, 13 Feb 2026 08:42:47 +0000 Subject: [PATCH 1/4] feat: register TrailerService in DI container (GIT-65) Wire the existing TrailerService into the awilix container as a singleton so downstream issues can resolve it from the cradle. Co-Authored-By: Claude Opus 4.6 --- src/infrastructure/di/container.ts | 2 ++ src/infrastructure/di/types.ts | 2 ++ tests/unit/infrastructure/di/container.test.ts | 17 +++++++++++++++++ 3 files changed, 21 insertions(+) diff --git a/src/infrastructure/di/container.ts b/src/infrastructure/di/container.ts index ca97c947..fd1e6a42 100644 --- a/src/infrastructure/di/container.ts +++ b/src/infrastructure/di/container.ts @@ -21,6 +21,7 @@ import type { IGitClient } from '../../domain/interfaces/IGitClient'; import { NotesService } from '../services/NotesService'; import { GitClient } from '../git/GitClient'; import { MemoryRepository } from '../repositories/MemoryRepository'; +import { TrailerService } from '../services/TrailerService'; import { EventBus } from '../events/EventBus'; import { createLogger } from '../logging/factory'; import { createLLMClient } from '../llm/LLMClientFactory'; @@ -55,6 +56,7 @@ export function createContainer(options?: IContainerOptions): AwilixContainer { const bus = new EventBus(container.cradle.logger); diff --git a/src/infrastructure/di/types.ts b/src/infrastructure/di/types.ts index ea13a895..fd21c23b 100644 --- a/src/infrastructure/di/types.ts +++ b/src/infrastructure/di/types.ts @@ -23,6 +23,7 @@ import type { ILiberateService } from '../../application/interfaces/ILiberateSer import type { IMemoryContextLoader } from '../../domain/interfaces/IMemoryContextLoader'; import type { IContextFormatter } from '../../domain/interfaces/IContextFormatter'; import type { ISessionCaptureService } from '../../domain/interfaces/ISessionCaptureService'; +import type { ITrailerService } from '../../domain/interfaces/ITrailerService'; export interface ICradle { // Infrastructure @@ -30,6 +31,7 @@ export interface ICradle { notesService: INotesService; gitClient: IGitClient; memoryRepository: IMemoryRepository; + trailerService: ITrailerService; triageService: IGitTriageService; llmClient: ILLMClient | null; eventBus: IEventBus; diff --git a/tests/unit/infrastructure/di/container.test.ts b/tests/unit/infrastructure/di/container.test.ts index 65b10a60..09546803 100644 --- a/tests/unit/infrastructure/di/container.test.ts +++ b/tests/unit/infrastructure/di/container.test.ts @@ -41,6 +41,7 @@ describe('createContainer', () => { assert.ok(cradle.notesService); assert.ok(cradle.gitClient); assert.ok(cradle.memoryRepository); + assert.ok(cradle.trailerService); assert.ok(cradle.eventBus); assert.ok(cradle.triageService); assert.ok(cradle.memoryService); @@ -162,6 +163,22 @@ describe('createContainer', () => { }); }); + describe('trailerService', () => { + it('should resolve with expected interface', () => { + const container = createContainer(); + const { trailerService } = container.cradle; + + assert.equal(typeof trailerService.readTrailers, 'function'); + assert.equal(typeof trailerService.formatTrailers, 'function'); + assert.equal(typeof trailerService.queryTrailers, 'function'); + }); + + it('should return singleton within container scope', () => { + const container = createContainer(); + assert.equal(container.cradle.trailerService, container.cradle.trailerService); + }); + }); + describe('hook services', () => { it('should resolve hook services with expected interfaces', () => { const container = createContainer(); From 4080eec6d665e08e95b909eb5d42cf9965476bfb Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Fri, 13 Feb 2026 08:45:52 +0000 Subject: [PATCH 2/4] feat: add write capability to TrailerService (GIT-66) Adds two new methods: - addTrailers: amends HEAD to append AI-* trailers, deduplicating against existing trailers on the commit - buildCommitMessage: pure string builder for composing commit messages with a trailer block Co-Authored-By: Claude Opus 4.6 --- src/domain/interfaces/ITrailerService.ts | 17 ++ src/infrastructure/services/TrailerService.ts | 53 +++++++ .../services/TrailerService.test.ts | 146 ++++++++++++++++++ 3 files changed, 216 insertions(+) diff --git a/src/domain/interfaces/ITrailerService.ts b/src/domain/interfaces/ITrailerService.ts index 5faef0f3..a1f63b2d 100644 --- a/src/domain/interfaces/ITrailerService.ts +++ b/src/domain/interfaces/ITrailerService.ts @@ -58,4 +58,21 @@ export interface ITrailerService { * @returns Array of commits with matching trailers. */ queryTrailers(key: string, options?: ITrailerQueryOptions): ICommitTrailers[]; + + /** + * Amend HEAD commit to append AI-* trailers to the commit message. + * Preserves existing trailers and skips duplicates. + * @param trailers - Trailers to add. + * @param cwd - Working directory. + */ + addTrailers(trailers: readonly ITrailer[], cwd?: string): void; + + /** + * Build a commit message with trailers appended. + * Pure string operation — no git commands. + * @param message - Original commit message. + * @param trailers - Trailers to append. + * @returns Complete commit message with trailer block. + */ + buildCommitMessage(message: string, trailers: readonly ITrailer[]): string; } diff --git a/src/infrastructure/services/TrailerService.ts b/src/infrastructure/services/TrailerService.ts index 402551f4..034b82eb 100644 --- a/src/infrastructure/services/TrailerService.ts +++ b/src/infrastructure/services/TrailerService.ts @@ -92,6 +92,59 @@ export class TrailerService implements ITrailerService { } } + addTrailers(trailers: readonly ITrailer[], cwd?: string): void { + if (trailers.length === 0) return; + + // Read existing trailers to avoid duplicates + const existing = this.readTrailers('HEAD', cwd); + const existingKeys = new Set(existing.map(t => `${t.key}:${t.value}`)); + const newTrailers = trailers.filter(t => !existingKeys.has(`${t.key}:${t.value}`)); + if (newTrailers.length === 0) return; + + // Get current commit message + const currentMessage = execFileSync( + 'git', + ['log', '-1', '--format=%B', 'HEAD'], + { encoding: 'utf8', cwd, stdio: ['pipe', 'pipe', 'pipe'] } + ).trimEnd(); + + // Build amended message with new trailers + const amended = this.buildCommitMessage(currentMessage, newTrailers); + + // Amend HEAD with the new message + execFileSync( + 'git', + ['commit', '--amend', '--no-edit', '-m', amended], + { encoding: 'utf8', cwd, stdio: ['pipe', 'pipe', 'pipe'] } + ); + } + + buildCommitMessage(message: string, trailers: readonly ITrailer[]): string { + if (trailers.length === 0) return message; + + const trailerBlock = this.formatTrailers(trailers); + const trimmed = message.trimEnd(); + + // Detect existing trailer block: lines after the last blank line + // must all match "Key: Value" format (git trailer convention). + if (this.hasTrailerBlock(trimmed)) { + return `${trimmed}\n${trailerBlock}\n`; + } + + return `${trimmed}\n\n${trailerBlock}\n`; + } + + private hasTrailerBlock(message: string): boolean { + const lastBlankIdx = message.lastIndexOf('\n\n'); + if (lastBlankIdx === -1) return false; + + const afterBlank = message.slice(lastBlankIdx + 2).trim(); + if (!afterBlank) return false; + + const lines = afterBlank.split('\n').filter(l => l.trim().length > 0); + return lines.length > 0 && lines.every(l => /^[\w-]+:\s/.test(l)); + } + private parseTrailerBlock(block: string): ITrailer[] { const trailers: ITrailer[] = []; const lines = block.split('\n'); diff --git a/tests/unit/infrastructure/services/TrailerService.test.ts b/tests/unit/infrastructure/services/TrailerService.test.ts index 704b9a72..ce25578e 100644 --- a/tests/unit/infrastructure/services/TrailerService.test.ts +++ b/tests/unit/infrastructure/services/TrailerService.test.ts @@ -127,4 +127,150 @@ describe('TrailerService', () => { assert.equal(results.length, 0); }); }); + + describe('buildCommitMessage', () => { + it('should append trailers with blank line separator', () => { + const result = service.buildCommitMessage('feat: add auth', [ + { key: 'AI-Decision', value: 'Use JWT' }, + ]); + assert.equal(result, 'feat: add auth\n\nAI-Decision: Use JWT\n'); + }); + + it('should append multiple trailers', () => { + const result = service.buildCommitMessage('feat: add auth', [ + { key: 'AI-Decision', value: 'Use JWT' }, + { key: 'AI-Confidence', value: 'high' }, + ]); + assert.equal(result, 'feat: add auth\n\nAI-Decision: Use JWT\nAI-Confidence: high\n'); + }); + + it('should append after existing trailers without extra blank line', () => { + const msg = 'feat: add auth\n\nCo-Authored-By: Someone '; + const result = service.buildCommitMessage(msg, [ + { key: 'AI-Decision', value: 'Use JWT' }, + ]); + assert.equal( + result, + 'feat: add auth\n\nCo-Authored-By: Someone \nAI-Decision: Use JWT\n' + ); + }); + + it('should handle message with body and no existing trailers', () => { + const msg = 'feat: add auth\n\nAdded JWT-based auth flow.'; + const result = service.buildCommitMessage(msg, [ + { key: 'AI-Decision', value: 'Use JWT' }, + ]); + assert.equal( + result, + 'feat: add auth\n\nAdded JWT-based auth flow.\n\nAI-Decision: Use JWT\n' + ); + }); + + it('should return original message when trailers array is empty', () => { + assert.equal(service.buildCommitMessage('feat: add auth', []), 'feat: add auth'); + }); + + it('should handle trailing whitespace in message', () => { + const result = service.buildCommitMessage('feat: add auth \n\n', [ + { key: 'AI-Fact', value: 'test' }, + ]); + assert.equal(result, 'feat: add auth\n\nAI-Fact: test\n'); + }); + }); + + describe('addTrailers', () => { + let writeRepoDir: string; + + before(() => { + writeRepoDir = mkdtempSync(join(tmpdir(), 'git-mem-trailer-write-')); + git(['init'], writeRepoDir); + git(['config', 'user.email', 'test@test.com'], writeRepoDir); + git(['config', 'user.name', 'Test User'], writeRepoDir); + }); + + after(() => { + rmSync(writeRepoDir, { recursive: true, force: true }); + }); + + it('should amend HEAD with new trailers', () => { + writeFileSync(join(writeRepoDir, 'a.txt'), 'content'); + git(['add', '.'], writeRepoDir); + git(['commit', '-m', 'feat: initial'], writeRepoDir); + + service.addTrailers( + [{ key: 'AI-Decision', value: 'Use Redis' }], + writeRepoDir + ); + + const trailers = service.readTrailers('HEAD', writeRepoDir); + const decision = trailers.find(t => t.key === 'AI-Decision'); + assert.ok(decision); + assert.equal(decision.value, 'Use Redis'); + }); + + it('should preserve existing trailers', () => { + writeFileSync(join(writeRepoDir, 'b.txt'), 'content'); + git(['add', '.'], writeRepoDir); + const msg = 'feat: with trailer\n\nAI-Gotcha: Watch out for nulls'; + git(['commit', '-m', msg], writeRepoDir); + + service.addTrailers( + [{ key: 'AI-Confidence', value: 'high' }], + writeRepoDir + ); + + const trailers = service.readTrailers('HEAD', writeRepoDir); + assert.ok(trailers.find(t => t.key === 'AI-Gotcha')); + assert.ok(trailers.find(t => t.key === 'AI-Confidence')); + }); + + it('should not duplicate existing trailers', () => { + writeFileSync(join(writeRepoDir, 'c.txt'), 'content'); + git(['add', '.'], writeRepoDir); + const msg = 'feat: dup test\n\nAI-Decision: Use Redis'; + git(['commit', '-m', msg], writeRepoDir); + + service.addTrailers( + [{ key: 'AI-Decision', value: 'Use Redis' }], + writeRepoDir + ); + + const trailers = service.readTrailers('HEAD', writeRepoDir); + const decisions = trailers.filter(t => t.key === 'AI-Decision'); + assert.equal(decisions.length, 1); + }); + + it('should be a no-op when trailers array is empty', () => { + writeFileSync(join(writeRepoDir, 'd.txt'), 'content'); + git(['add', '.'], writeRepoDir); + git(['commit', '-m', 'feat: empty test'], writeRepoDir); + const shaBefore = git(['rev-parse', 'HEAD'], writeRepoDir); + + service.addTrailers([], writeRepoDir); + + const shaAfter = git(['rev-parse', 'HEAD'], writeRepoDir); + assert.equal(shaBefore, shaAfter); + }); + + it('should add multiple trailers at once', () => { + writeFileSync(join(writeRepoDir, 'e.txt'), 'content'); + git(['add', '.'], writeRepoDir); + git(['commit', '-m', 'feat: multi trailer'], writeRepoDir); + + service.addTrailers( + [ + { key: 'AI-Decision', value: 'Use PostgreSQL' }, + { key: 'AI-Confidence', value: 'high' }, + { key: 'AI-Tags', value: 'db, infrastructure' }, + ], + writeRepoDir + ); + + const trailers = service.readTrailers('HEAD', writeRepoDir); + assert.equal(trailers.length, 3); + assert.ok(trailers.find(t => t.key === 'AI-Decision')); + assert.ok(trailers.find(t => t.key === 'AI-Confidence')); + assert.ok(trailers.find(t => t.key === 'AI-Tags')); + }); + }); }); From 8fcd93f0eac264af3d4a481af6912b46a2dff5a6 Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Fri, 13 Feb 2026 09:10:32 +0000 Subject: [PATCH 3/4] fix: address PR review feedback for TrailerService (GIT-66) - Filter addTrailers input to AI-* prefixed trailers only - Add addTrailers and buildCommitMessage to container interface test Co-Authored-By: Claude Opus 4.6 --- src/infrastructure/services/TrailerService.ts | 6 ++++-- tests/unit/infrastructure/di/container.test.ts | 2 ++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/infrastructure/services/TrailerService.ts b/src/infrastructure/services/TrailerService.ts index 034b82eb..70a880a1 100644 --- a/src/infrastructure/services/TrailerService.ts +++ b/src/infrastructure/services/TrailerService.ts @@ -93,12 +93,14 @@ export class TrailerService implements ITrailerService { } addTrailers(trailers: readonly ITrailer[], cwd?: string): void { - if (trailers.length === 0) return; + // Only operate on AI-* trailers to match readTrailers/parseTrailerBlock behavior. + const aiTrailers = trailers.filter(t => t.key.startsWith(AI_TRAILER_PREFIX)); + if (aiTrailers.length === 0) return; // Read existing trailers to avoid duplicates const existing = this.readTrailers('HEAD', cwd); const existingKeys = new Set(existing.map(t => `${t.key}:${t.value}`)); - const newTrailers = trailers.filter(t => !existingKeys.has(`${t.key}:${t.value}`)); + const newTrailers = aiTrailers.filter(t => !existingKeys.has(`${t.key}:${t.value}`)); if (newTrailers.length === 0) return; // Get current commit message diff --git a/tests/unit/infrastructure/di/container.test.ts b/tests/unit/infrastructure/di/container.test.ts index 09546803..4527fad7 100644 --- a/tests/unit/infrastructure/di/container.test.ts +++ b/tests/unit/infrastructure/di/container.test.ts @@ -171,6 +171,8 @@ describe('createContainer', () => { assert.equal(typeof trailerService.readTrailers, 'function'); assert.equal(typeof trailerService.formatTrailers, 'function'); assert.equal(typeof trailerService.queryTrailers, 'function'); + assert.equal(typeof trailerService.addTrailers, 'function'); + assert.equal(typeof trailerService.buildCommitMessage, 'function'); }); it('should return singleton within container scope', () => { From 4e833106142cefcab282d59fa89b353d4dd66e63 Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Fri, 13 Feb 2026 08:49:12 +0000 Subject: [PATCH 4/4] feat: dual-write trailers in MemoryService.remember() (GIT-67) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When remembering a memory, also write AI-* trailers to the commit: - Maps memory type to trailer key (decision→AI-Decision, etc.) - Includes AI-Confidence, AI-Tags, AI-Memory-Id trailers - Opt-out via --no-trailers CLI flag or trailers:false in options - Trailer write failure is non-fatal (logged as warning) - MCP tool gains trailers boolean parameter Co-Authored-By: Claude Opus 4.6 --- src/application/services/MemoryService.ts | 46 ++++++++- src/cli.ts | 1 + src/commands/remember.ts | 2 + src/domain/entities/IMemoryEntity.ts | 2 + src/mcp/tools/remember.ts | 2 + .../services/MemoryService.test.ts | 99 +++++++++++++++++++ 6 files changed, 150 insertions(+), 2 deletions(-) diff --git a/src/application/services/MemoryService.ts b/src/application/services/MemoryService.ts index bad296d3..45cc45e9 100644 --- a/src/application/services/MemoryService.ts +++ b/src/application/services/MemoryService.ts @@ -2,26 +2,68 @@ * MemoryService * * Application service that orchestrates memory operations. - * Delegates storage to IMemoryRepository. + * Dual-writes to git notes (rich JSON) and commit trailers + * (lightweight, natively queryable metadata). */ import type { IMemoryService } from '../interfaces/IMemoryService'; import type { IMemoryRepository, IMemoryQueryOptions, IMemoryQueryResult } from '../../domain/interfaces/IMemoryRepository'; -import type { IMemoryEntity, ICreateMemoryOptions } from '../../domain/entities/IMemoryEntity'; +import type { IMemoryEntity, ICreateMemoryOptions, MemoryType } from '../../domain/entities/IMemoryEntity'; +import type { ITrailerService } from '../../domain/interfaces/ITrailerService'; +import type { ITrailer } from '../../domain/entities/ITrailer'; +import { AI_TRAILER_KEYS } from '../../domain/entities/ITrailer'; import type { ILogger } from '../../domain/interfaces/ILogger'; +const MEMORY_TYPE_TO_TRAILER_KEY: Record = { + decision: AI_TRAILER_KEYS.DECISION, + gotcha: AI_TRAILER_KEYS.GOTCHA, + convention: AI_TRAILER_KEYS.CONVENTION, + fact: AI_TRAILER_KEYS.FACT, +}; + export class MemoryService implements IMemoryService { constructor( private readonly memoryRepository: IMemoryRepository, private readonly logger?: ILogger, + private readonly trailerService?: ITrailerService, ) {} remember(text: string, options?: ICreateMemoryOptions): IMemoryEntity { const memory = this.memoryRepository.create(text, options); this.logger?.info('Memory stored', { id: memory.id, type: memory.type, sha: memory.sha }); + + // Dual-write: also add AI-* trailers to the commit (opt-out via trailers: false) + if (options?.trailers !== false && this.trailerService) { + try { + const trailers = this.buildTrailers(memory); + this.trailerService.addTrailers(trailers, options?.cwd); + this.logger?.info('Trailers written', { count: trailers.length, sha: memory.sha }); + } catch (err) { + // Trailer write failure is non-fatal (commit may be pushed already) + this.logger?.warn('Trailer write failed', { + error: err instanceof Error ? err.message : String(err), + sha: memory.sha, + }); + } + } + return memory; } + private buildTrailers(memory: IMemoryEntity): ITrailer[] { + const trailers: ITrailer[] = [ + { key: MEMORY_TYPE_TO_TRAILER_KEY[memory.type], value: memory.content }, + { key: AI_TRAILER_KEYS.CONFIDENCE, value: memory.confidence }, + { key: AI_TRAILER_KEYS.MEMORY_ID, value: memory.id }, + ]; + + if (memory.tags.length > 0) { + trailers.push({ key: AI_TRAILER_KEYS.TAGS, value: memory.tags.join(', ') }); + } + + return trailers; + } + recall(query?: string, options?: IMemoryQueryOptions): IMemoryQueryResult { const effectiveQuery = query ?? options?.query; const result = this.memoryRepository.query({ diff --git a/src/cli.ts b/src/cli.ts index b1ba9c3e..c143394c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -36,6 +36,7 @@ program .option('--confidence ', 'Confidence: verified, high, medium, low', 'high') .option('--lifecycle ', 'Lifecycle: permanent, project, session', 'project') .option('--tags ', 'Comma-separated tags') + .option('--no-trailers', 'Skip writing AI-* trailers to the commit message') .action((text, options) => rememberCommand(text, options, logger)); program diff --git a/src/commands/remember.ts b/src/commands/remember.ts index d22c084d..87b2a0bf 100644 --- a/src/commands/remember.ts +++ b/src/commands/remember.ts @@ -14,6 +14,7 @@ interface IRememberOptions { confidence?: string; lifecycle?: string; tags?: string; + noTrailers?: boolean; } export async function rememberCommand(text: string, options: IRememberOptions, logger?: ILogger): Promise { @@ -27,6 +28,7 @@ export async function rememberCommand(text: string, options: IRememberOptions, l confidence: (options.confidence || 'high') as ConfidenceLevel, lifecycle: (options.lifecycle || 'project') as MemoryLifecycle, tags: options.tags, + trailers: !options.noTrailers, }); console.log(`Remembered: ${memory.content}`); diff --git a/src/domain/entities/IMemoryEntity.ts b/src/domain/entities/IMemoryEntity.ts index f0a93e98..7cd3ce0f 100644 --- a/src/domain/entities/IMemoryEntity.ts +++ b/src/domain/entities/IMemoryEntity.ts @@ -69,6 +69,8 @@ export interface ICreateMemoryOptions { readonly source?: SourceType; /** Working directory. */ readonly cwd?: string; + /** Write AI-* trailers to the commit message (default: true). */ + readonly trailers?: boolean; } /** diff --git a/src/mcp/tools/remember.ts b/src/mcp/tools/remember.ts index 1c40d8c0..cf8e200f 100644 --- a/src/mcp/tools/remember.ts +++ b/src/mcp/tools/remember.ts @@ -22,6 +22,7 @@ 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)'), + trailers: z.boolean().optional().describe('Write AI-* trailers to commit message (default: true)'), }, async (args) => { const container = createContainer({ scope: 'mcp:remember' }); @@ -35,6 +36,7 @@ export function registerRememberTool(server: McpServer): void { confidence: (args.confidence || 'high') as ConfidenceLevel, lifecycle: (args.lifecycle || 'project') as MemoryLifecycle, tags: args.tags, + trailers: args.trailers, }); return { diff --git a/tests/unit/application/services/MemoryService.test.ts b/tests/unit/application/services/MemoryService.test.ts index 287c6b65..330e37f6 100644 --- a/tests/unit/application/services/MemoryService.test.ts +++ b/tests/unit/application/services/MemoryService.test.ts @@ -7,6 +7,7 @@ import { tmpdir } from 'os'; import { MemoryService } from '../../../../src/application/services/MemoryService'; import { MemoryRepository } from '../../../../src/infrastructure/repositories/MemoryRepository'; import { NotesService } from '../../../../src/infrastructure/services/NotesService'; +import { TrailerService } from '../../../../src/infrastructure/services/TrailerService'; function git(args: string[], cwd: string): string { return execFileSync('git', args, { encoding: 'utf8', cwd }).trim(); @@ -14,13 +15,17 @@ function git(args: string[], cwd: string): string { describe('MemoryService', () => { let service: MemoryService; + let serviceWithTrailers: MemoryService; + let trailerService: TrailerService; let repoDir: string; let commitSha: string; before(() => { const notesService = new NotesService(); const memoryRepo = new MemoryRepository(notesService); + trailerService = new TrailerService(); service = new MemoryService(memoryRepo); + serviceWithTrailers = new MemoryService(memoryRepo, undefined, trailerService); repoDir = mkdtempSync(join(tmpdir(), 'git-mem-memsvc-test-')); git(['init'], repoDir); @@ -53,6 +58,100 @@ describe('MemoryService', () => { }); }); + describe('remember with trailers', () => { + it('should write AI-* trailers to the commit', () => { + // Create a fresh commit for this test + writeFileSync(join(repoDir, 'trailer-test.txt'), 'trailer'); + git(['add', '.'], repoDir); + git(['commit', '-m', 'feat: trailer test'], repoDir); + + serviceWithTrailers.remember('Use Redis for caching', { + cwd: repoDir, + type: 'decision', + confidence: 'high', + tags: 'cache, infra', + }); + + const trailers = trailerService.readTrailers('HEAD', repoDir); + assert.ok(trailers.find(t => t.key === 'AI-Decision' && t.value === 'Use Redis for caching')); + assert.ok(trailers.find(t => t.key === 'AI-Confidence' && t.value === 'high')); + assert.ok(trailers.find(t => t.key === 'AI-Tags' && t.value === 'cache, infra')); + assert.ok(trailers.find(t => t.key === 'AI-Memory-Id')); + }); + + it('should map memory type to correct trailer key', () => { + writeFileSync(join(repoDir, 'gotcha-test.txt'), 'gotcha'); + git(['add', '.'], repoDir); + git(['commit', '-m', 'fix: gotcha test'], repoDir); + + serviceWithTrailers.remember('Watch out for null tokens', { + cwd: repoDir, + type: 'gotcha', + }); + + const trailers = trailerService.readTrailers('HEAD', repoDir); + assert.ok(trailers.find(t => t.key === 'AI-Gotcha')); + assert.ok(!trailers.find(t => t.key === 'AI-Decision')); + }); + + it('should skip trailers when trailers: false', () => { + writeFileSync(join(repoDir, 'no-trailer.txt'), 'skip'); + git(['add', '.'], repoDir); + git(['commit', '-m', 'feat: no trailer test'], repoDir); + const shaBefore = git(['rev-parse', 'HEAD'], repoDir); + + serviceWithTrailers.remember('No trailer for this', { + cwd: repoDir, + type: 'fact', + trailers: false, + }); + + // SHA unchanged means commit was not amended + const shaAfter = git(['rev-parse', 'HEAD'], repoDir); + assert.equal(shaBefore, shaAfter); + }); + + it('should not fail when trailerService is not injected', () => { + writeFileSync(join(repoDir, 'no-svc.txt'), 'plain'); + git(['add', '.'], repoDir); + git(['commit', '-m', 'feat: no service'], repoDir); + + // service (without trailerService) should not throw + const memory = service.remember('Plain memory', { cwd: repoDir }); + assert.ok(memory.id); + }); + + it('should include AI-Memory-Id linking trailer to notes entry', () => { + writeFileSync(join(repoDir, 'link-test.txt'), 'link'); + git(['add', '.'], repoDir); + git(['commit', '-m', 'feat: link test'], repoDir); + + const memory = serviceWithTrailers.remember('Linked memory', { + cwd: repoDir, + type: 'convention', + }); + + const trailers = trailerService.readTrailers('HEAD', repoDir); + const memoryId = trailers.find(t => t.key === 'AI-Memory-Id'); + assert.ok(memoryId); + assert.equal(memoryId.value, memory.id); + }); + + it('should not write AI-Tags trailer when tags are empty', () => { + writeFileSync(join(repoDir, 'no-tags.txt'), 'notags'); + git(['add', '.'], repoDir); + git(['commit', '-m', 'feat: no tags test'], repoDir); + + serviceWithTrailers.remember('No tags here', { + cwd: repoDir, + type: 'fact', + }); + + const trailers = trailerService.readTrailers('HEAD', repoDir); + assert.ok(!trailers.find(t => t.key === 'AI-Tags')); + }); + }); + describe('recall', () => { it('should find memories by query', () => { const result = service.recall('JWT', { cwd: repoDir });