From de28a19f696a6b4af86c9333d97a0a1c9f7e0db8 Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Mon, 16 Feb 2026 12:43:24 +0000 Subject: [PATCH 01/10] chore: add claude to test.txt Co-Authored-By: Claude Opus 4.5 AI-Agent: Claude-Code/2.1.42 AI-Model: claude-opus-4-5-20251101 AI-Fact: add claude to test.txt. Co-Authored-By: Claude Opus 4.5 AI-Confidence: low AI-Lifecycle: project AI-Memory-Id: f5ea4bbd AI-Source: heuristic --- test.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 test.txt diff --git a/test.txt b/test.txt new file mode 100644 index 00000000..cb4898ef --- /dev/null +++ b/test.txt @@ -0,0 +1 @@ +claude From 487fc1a91c3914d5f29b82c76ee7e2820f32272b Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Mon, 16 Feb 2026 12:56:09 +0000 Subject: [PATCH 02/10] Add codex to test file AI-Confidence: low AI-Lifecycle: project AI-Memory-Id: 1980faea AI-Source: heuristic --- test.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/test.txt b/test.txt index cb4898ef..bcc5fd8d 100644 --- a/test.txt +++ b/test.txt @@ -1 +1,2 @@ claude +codex From d6e2dce3a35a2a70dcf52079ca2d658ac0fe61fc Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Mon, 16 Feb 2026 16:15:15 +0000 Subject: [PATCH 03/10] feat: add runtime.json for cross-hook agent/model detection (GIT-123) When git-mem hooks run in plain terminal shells (e.g., post-commit), no AI environment variables are set. This causes agent/model detection to fail, meaning commits made during AI sessions don't get properly attributed with AI-Agent and AI-Model trailers. Solution: - Write runtime.json to .git-mem/ on session-start with detected agent/model - AgentResolver reads this file as fallback when env vars are empty - Delete runtime.json on session-stop to prevent stale attribution - TTL enforcement (2h default) prevents stale data from being used Co-Authored-By: Claude Opus 4.5 AI-Agent: Claude-Code/2.1.42 AI-Model: claude-opus-4-5-20251101 AI-Decision: add runtime.json for cross-hook agent/model detection (GIT-123). When git-mem hooks run in plain terminal shells (e.g., post-commit), AI-Confidence: medium AI-Tags: application, handlers, domain, infrastructure, services, tests, unit AI-Lifecycle: project AI-Memory-Id: 42af7fc0 AI-Source: heuristic --- .../handlers/SessionStartHandler.ts | 55 ++++ .../handlers/SessionStopHandler.ts | 26 ++ src/domain/interfaces/IRuntimeService.ts | 49 ++++ src/infrastructure/di/container.ts | 14 +- src/infrastructure/di/types.ts | 2 + src/infrastructure/services/AgentResolver.ts | 30 ++- src/infrastructure/services/RuntimeService.ts | 82 ++++++ .../handlers/SessionStartHandler.test.ts | 90 +++++++ .../handlers/SessionStopHandler.test.ts | 66 +++++ .../services/RuntimeService.test.ts | 250 ++++++++++++++++++ 10 files changed, 659 insertions(+), 5 deletions(-) create mode 100644 src/domain/interfaces/IRuntimeService.ts create mode 100644 src/infrastructure/services/RuntimeService.ts create mode 100644 tests/unit/infrastructure/services/RuntimeService.test.ts diff --git a/src/application/handlers/SessionStartHandler.ts b/src/application/handlers/SessionStartHandler.ts index fa5f4b46..87fc0e0e 100644 --- a/src/application/handlers/SessionStartHandler.ts +++ b/src/application/handlers/SessionStartHandler.ts @@ -3,6 +3,7 @@ * * Handles the session:start event by loading stored memories * and formatting them as markdown for Claude Code's context. + * Also activates runtime.json for cross-hook agent/model detection. */ import type { ISessionStartHandler } from '../interfaces/ISessionStartHandler'; @@ -11,12 +12,16 @@ import type { IEventResult } from '../../domain/interfaces/IEventResult'; import type { IMemoryContextLoader } from '../../domain/interfaces/IMemoryContextLoader'; import type { IContextFormatter } from '../../domain/interfaces/IContextFormatter'; import type { ILogger } from '../../domain/interfaces/ILogger'; +import type { IRuntimeService } from '../../domain/interfaces/IRuntimeService'; +import type { IAgentResolver } from '../../domain/interfaces/IAgentResolver'; export class SessionStartHandler implements ISessionStartHandler { constructor( private readonly memoryContextLoader: IMemoryContextLoader, private readonly contextFormatter: IContextFormatter, private readonly logger?: ILogger, + private readonly runtimeService?: IRuntimeService, + private readonly agentResolver?: IAgentResolver, ) {} async handle(event: ISessionStartEvent): Promise { @@ -26,6 +31,9 @@ export class SessionStartHandler implements ISessionStartHandler { cwd: event.cwd, }); + // Activate runtime.json for cross-hook agent/model detection + this.activateRuntime(event); + const result = this.memoryContextLoader.load({ cwd: event.cwd }); if (result.memories.length === 0) { @@ -65,4 +73,51 @@ export class SessionStartHandler implements ISessionStartHandler { }; } } + + /** + * Activate runtime.json with current agent/model detection. + * Never throws — activation errors are logged and ignored. + */ + private activateRuntime(event: ISessionStartEvent): void { + if (!this.runtimeService || !this.agentResolver) { + return; + } + + try { + const agent = this.agentResolver.resolveAgent(); + const model = this.agentResolver.resolveModel(); + + // Determine source based on which env var is set + const source = this.detectSource(); + + this.runtimeService.activate( + { + sessionId: event.sessionId, + agent, + model, + timestamp: new Date().toISOString(), + source, + }, + event.cwd, + ); + + this.logger?.debug('Runtime activated', { agent, model, source }); + } catch (error) { + // Never fail the handler due to runtime activation errors + this.logger?.warn('Failed to activate runtime', { + error: error instanceof Error ? error.message : String(error), + }); + } + } + + /** + * Determine the source of agent detection from environment variables. + */ + private detectSource(): string { + if (process.env.CLAUDECODE) return 'env:CLAUDECODE'; + if (process.env.CLAUDE_CODE) return 'env:CLAUDE_CODE'; + if (process.env.CODEX_THREAD_ID) return 'env:CODEX_THREAD_ID'; + if (process.env.GIT_MEM_AGENT) return 'env:GIT_MEM_AGENT'; + return 'env:unknown'; + } } diff --git a/src/application/handlers/SessionStopHandler.ts b/src/application/handlers/SessionStopHandler.ts index 49b65b2b..c49f50e8 100644 --- a/src/application/handlers/SessionStopHandler.ts +++ b/src/application/handlers/SessionStopHandler.ts @@ -3,6 +3,7 @@ * * Handles the session:stop event by capturing memories from * commits made during the session via SessionCaptureService. + * Also deactivates runtime.json to prevent stale agent/model attribution. */ import type { ISessionStopHandler } from '../interfaces/ISessionStopHandler'; @@ -10,11 +11,13 @@ import type { ISessionStopEvent } from '../../domain/events/HookEvents'; import type { IEventResult } from '../../domain/interfaces/IEventResult'; import type { ISessionCaptureService } from '../../domain/interfaces/ISessionCaptureService'; import type { ILogger } from '../../domain/interfaces/ILogger'; +import type { IRuntimeService } from '../../domain/interfaces/IRuntimeService'; export class SessionStopHandler implements ISessionStopHandler { constructor( private readonly sessionCaptureService: ISessionCaptureService, private readonly logger?: ILogger, + private readonly runtimeService?: IRuntimeService, ) {} async handle(event: ISessionStopEvent): Promise { @@ -34,6 +37,9 @@ export class SessionStopHandler implements ISessionStopHandler { memoriesExtracted: result.memoriesExtracted, }); + // Deactivate runtime.json to prevent stale attribution + this.deactivateRuntime(event.cwd); + return { handler: 'SessionStopHandler', success: true, @@ -52,4 +58,24 @@ export class SessionStopHandler implements ISessionStopHandler { }; } } + + /** + * Deactivate runtime.json to prevent stale agent/model attribution. + * Never throws — deactivation errors are logged and ignored. + */ + private deactivateRuntime(cwd: string): void { + if (!this.runtimeService) { + return; + } + + try { + this.runtimeService.deactivate(cwd); + this.logger?.debug('Runtime deactivated'); + } catch (error) { + // Never fail the handler due to runtime deactivation errors + this.logger?.warn('Failed to deactivate runtime', { + error: error instanceof Error ? error.message : String(error), + }); + } + } } diff --git a/src/domain/interfaces/IRuntimeService.ts b/src/domain/interfaces/IRuntimeService.ts new file mode 100644 index 00000000..1c2537c9 --- /dev/null +++ b/src/domain/interfaces/IRuntimeService.ts @@ -0,0 +1,49 @@ +/** + * IRuntimeService + * + * Domain interface for managing runtime session data stored in the repo. + * Enables AI agent/model detection to persist across hook invocations + * even when environment variables are not available (e.g., post-commit + * hooks running in plain terminal shells). + */ + +/** + * Runtime session data written to .git-mem/runtime.json. + */ +export interface IRuntimeData { + readonly sessionId: string; + readonly agent: string | undefined; + readonly model: string | undefined; + readonly timestamp: string; // ISO 8601 + readonly source: string; // e.g., "env:CLAUDECODE" +} + +/** + * Service for activating/deactivating runtime session data. + * Implementation lives in infrastructure layer. + */ +export interface IRuntimeService { + /** + * Write runtime.json with session data. + * Creates .git-mem/ directory if missing. + * Never throws — logs errors and continues silently. + * @param data - Runtime data to persist + * @param cwd - Working directory (defaults to process.cwd()) + */ + activate(data: IRuntimeData, cwd?: string): void; + + /** + * Remove runtime.json file. + * Never throws — handles missing file gracefully. + * @param cwd - Working directory (defaults to process.cwd()) + */ + deactivate(cwd?: string): void; + + /** + * Read runtime.json if it exists and is fresh. + * @param cwd - Working directory (defaults to process.cwd()) + * @param ttlMs - Time-to-live in milliseconds (default: 2 hours) + * @returns Runtime data if file exists and is within TTL, undefined otherwise + */ + read(cwd?: string, ttlMs?: number): IRuntimeData | undefined; +} diff --git a/src/infrastructure/di/container.ts b/src/infrastructure/di/container.ts index 1df9dd00..46781699 100644 --- a/src/infrastructure/di/container.ts +++ b/src/infrastructure/di/container.ts @@ -28,6 +28,7 @@ import { createLLMClient } from '../llm/LLMClientFactory'; import { IntentExtractor } from '../llm/IntentExtractor'; import { AgentResolver } from '../services/AgentResolver'; import { HookConfigLoader } from '../services/HookConfigLoader'; +import { RuntimeService } from '../services/RuntimeService'; // Application — core services import { MemoryService } from '../../application/services/MemoryService'; @@ -63,9 +64,17 @@ export function createContainer(options?: IContainerOptions): AwilixContainer { + return new AgentResolver( + container.cradle.runtimeService, + options?.cwd, + ); + }).singleton(), + eventBus: asFunction(() => { const bus = new EventBus(container.cradle.logger); @@ -74,10 +83,13 @@ export function createContainer(options?: IContainerOptions): AwilixContainer ttl) { + return undefined; + } + + return data; + } catch { + // Never throw — return undefined on parse errors + return undefined; + } + } +} diff --git a/tests/unit/application/handlers/SessionStartHandler.test.ts b/tests/unit/application/handlers/SessionStartHandler.test.ts index b75822b6..db01ecc8 100644 --- a/tests/unit/application/handlers/SessionStartHandler.test.ts +++ b/tests/unit/application/handlers/SessionStartHandler.test.ts @@ -9,6 +9,8 @@ import type { IMemoryContextLoader, IMemoryContextResult } from '../../../../src import type { IContextFormatter } from '../../../../src/domain/interfaces/IContextFormatter'; import type { ISessionStartEvent } from '../../../../src/domain/events/HookEvents'; import type { IMemoryEntity } from '../../../../src/domain/entities/IMemoryEntity'; +import type { IRuntimeService, IRuntimeData } from '../../../../src/domain/interfaces/IRuntimeService'; +import type { IAgentResolver } from '../../../../src/domain/interfaces/IAgentResolver'; function createEvent(overrides?: Partial): ISessionStartEvent { return { @@ -48,6 +50,24 @@ function createMockFormatter(output: string): IContextFormatter { }; } +function createMockRuntimeService(overrides?: Partial): IRuntimeService & { activateCalls: IRuntimeData[] } { + const activateCalls: IRuntimeData[] = []; + return { + activateCalls, + activate: (data: IRuntimeData) => { activateCalls.push(data); }, + deactivate: () => {}, + read: () => undefined, + ...overrides, + }; +} + +function createMockAgentResolver(agent?: string, model?: string): IAgentResolver { + return { + resolveAgent: () => agent, + resolveModel: () => model, + }; +} + describe('SessionStartHandler', () => { it('should return success with formatted output when memories exist', async () => { const memories = [createMemory()]; @@ -152,4 +172,74 @@ describe('SessionStartHandler', () => { assert.equal(result.success, false); assert.equal(result.error!.message, 'format failed'); }); + + describe('runtime activation', () => { + it('should call runtimeService.activate with detected agent/model', async () => { + const loader = createMockLoader({ memories: [], total: 0, filtered: 0 }); + const formatter = createMockFormatter(''); + const runtimeService = createMockRuntimeService(); + const agentResolver = createMockAgentResolver('Claude-Code/2.1.0', 'claude-opus-4-5-20251101'); + const handler = new SessionStartHandler(loader, formatter, undefined, runtimeService, agentResolver); + + await handler.handle(createEvent({ sessionId: 'session-123', cwd: '/my/repo' })); + + assert.equal(runtimeService.activateCalls.length, 1); + const activateData = runtimeService.activateCalls[0]; + assert.equal(activateData.sessionId, 'session-123'); + assert.equal(activateData.agent, 'Claude-Code/2.1.0'); + assert.equal(activateData.model, 'claude-opus-4-5-20251101'); + assert.ok(activateData.timestamp); + assert.ok(activateData.source); + }); + + it('should not crash if runtimeService is not provided', async () => { + const loader = createMockLoader({ memories: [], total: 0, filtered: 0 }); + const formatter = createMockFormatter(''); + const handler = new SessionStartHandler(loader, formatter); + + const result = await handler.handle(createEvent()); + + assert.equal(result.success, true); + }); + + it('should not crash if agentResolver is not provided', async () => { + const loader = createMockLoader({ memories: [], total: 0, filtered: 0 }); + const formatter = createMockFormatter(''); + const runtimeService = createMockRuntimeService(); + const handler = new SessionStartHandler(loader, formatter, undefined, runtimeService); + + const result = await handler.handle(createEvent()); + + assert.equal(result.success, true); + assert.equal(runtimeService.activateCalls.length, 0); + }); + + it('should continue if runtimeService.activate throws', async () => { + const loader = createMockLoader({ memories: [], total: 0, filtered: 0 }); + const formatter = createMockFormatter(''); + const runtimeService = createMockRuntimeService({ + activate: () => { throw new Error('activate failed'); }, + }); + const agentResolver = createMockAgentResolver('Claude-Code/2.1.0', 'claude-opus-4-5-20251101'); + const handler = new SessionStartHandler(loader, formatter, undefined, runtimeService, agentResolver); + + const result = await handler.handle(createEvent()); + + assert.equal(result.success, true); + }); + + it('should handle undefined agent and model', async () => { + const loader = createMockLoader({ memories: [], total: 0, filtered: 0 }); + const formatter = createMockFormatter(''); + const runtimeService = createMockRuntimeService(); + const agentResolver = createMockAgentResolver(undefined, undefined); + const handler = new SessionStartHandler(loader, formatter, undefined, runtimeService, agentResolver); + + await handler.handle(createEvent()); + + assert.equal(runtimeService.activateCalls.length, 1); + assert.equal(runtimeService.activateCalls[0].agent, undefined); + assert.equal(runtimeService.activateCalls[0].model, undefined); + }); + }); }); diff --git a/tests/unit/application/handlers/SessionStopHandler.test.ts b/tests/unit/application/handlers/SessionStopHandler.test.ts index 4a1b8401..194cf23d 100644 --- a/tests/unit/application/handlers/SessionStopHandler.test.ts +++ b/tests/unit/application/handlers/SessionStopHandler.test.ts @@ -7,6 +7,7 @@ import assert from 'node:assert/strict'; import { SessionStopHandler } from '../../../../src/application/handlers/SessionStopHandler'; import type { ISessionCaptureService, ISessionCaptureResult } from '../../../../src/domain/interfaces/ISessionCaptureService'; import type { ISessionStopEvent } from '../../../../src/domain/events/HookEvents'; +import type { IRuntimeService } from '../../../../src/domain/interfaces/IRuntimeService'; function createEvent(overrides?: Partial): ISessionStopEvent { return { @@ -28,6 +29,17 @@ function createMockCaptureService(result: Partial = {}): }; } +function createMockRuntimeService(overrides?: Partial): IRuntimeService & { deactivateCalls: string[] } { + const deactivateCalls: string[] = []; + return { + deactivateCalls, + activate: () => {}, + deactivate: (cwd?: string) => { deactivateCalls.push(cwd ?? ''); }, + read: () => undefined, + ...overrides, + }; +} + describe('SessionStopHandler', () => { it('should return success with capture summary', async () => { const captureService = createMockCaptureService(); @@ -82,4 +94,58 @@ describe('SessionStopHandler', () => { assert.ok(result.error instanceof Error); assert.equal(result.error!.message, 'string error'); }); + + describe('runtime deactivation', () => { + it('should call runtimeService.deactivate with cwd after capture', async () => { + const captureService = createMockCaptureService(); + const runtimeService = createMockRuntimeService(); + const handler = new SessionStopHandler(captureService, undefined, runtimeService); + + await handler.handle(createEvent({ cwd: '/my/repo' })); + + assert.equal(runtimeService.deactivateCalls.length, 1); + assert.equal(runtimeService.deactivateCalls[0], '/my/repo'); + }); + + it('should not crash if runtimeService is not provided', async () => { + const captureService = createMockCaptureService(); + const handler = new SessionStopHandler(captureService); + + const result = await handler.handle(createEvent()); + + assert.equal(result.success, true); + }); + + it('should continue if runtimeService.deactivate throws', async () => { + const captureService = createMockCaptureService(); + const runtimeService = createMockRuntimeService({ + deactivate: () => { throw new Error('deactivate failed'); }, + }); + const handler = new SessionStopHandler(captureService, undefined, runtimeService); + + const result = await handler.handle(createEvent()); + + assert.equal(result.success, true); + }); + + it('should call deactivate after capture completes', async () => { + const callOrder: string[] = []; + const captureService: ISessionCaptureService = { + capture: async () => { + callOrder.push('capture'); + return { commitsScanned: 0, memoriesExtracted: 0, summary: '' }; + }, + }; + const runtimeService: IRuntimeService = { + activate: () => {}, + deactivate: () => { callOrder.push('deactivate'); }, + read: () => undefined, + }; + const handler = new SessionStopHandler(captureService, undefined, runtimeService); + + await handler.handle(createEvent()); + + assert.deepEqual(callOrder, ['capture', 'deactivate']); + }); + }); }); diff --git a/tests/unit/infrastructure/services/RuntimeService.test.ts b/tests/unit/infrastructure/services/RuntimeService.test.ts new file mode 100644 index 00000000..9822664d --- /dev/null +++ b/tests/unit/infrastructure/services/RuntimeService.test.ts @@ -0,0 +1,250 @@ +import { describe, it, before, after, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { tmpdir } from 'os'; +import { RuntimeService } from '../../../../src/infrastructure/services/RuntimeService'; +import type { IRuntimeData } from '../../../../src/domain/interfaces/IRuntimeService'; + +describe('RuntimeService', () => { + let service: RuntimeService; + let testDir: string; + + before(() => { + service = new RuntimeService(); + }); + + beforeEach(() => { + testDir = mkdtempSync(join(tmpdir(), 'git-mem-runtime-test-')); + }); + + after(() => { + // Cleanup is handled per-test in beforeEach/individual tests + }); + + function cleanup(): void { + if (testDir && existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + } + + function createRuntimeData(overrides?: Partial): IRuntimeData { + return { + sessionId: 'test-session-123', + agent: 'Claude-Code/2.1.0', + model: 'claude-opus-4-5-20251101', + timestamp: new Date().toISOString(), + source: 'env:CLAUDECODE', + ...overrides, + }; + } + + describe('activate', () => { + it('should create runtime.json with correct content', () => { + try { + const data = createRuntimeData(); + service.activate(data, testDir); + + const filePath = join(testDir, '.git-mem', 'runtime.json'); + assert.ok(existsSync(filePath), 'runtime.json should exist'); + + const content = JSON.parse(readFileSync(filePath, 'utf8')); + assert.equal(content.sessionId, data.sessionId); + assert.equal(content.agent, data.agent); + assert.equal(content.model, data.model); + assert.equal(content.timestamp, data.timestamp); + assert.equal(content.source, data.source); + } finally { + cleanup(); + } + }); + + it('should create .git-mem/ directory if missing', () => { + try { + const gitMemDir = join(testDir, '.git-mem'); + assert.ok(!existsSync(gitMemDir), '.git-mem should not exist initially'); + + const data = createRuntimeData(); + service.activate(data, testDir); + + assert.ok(existsSync(gitMemDir), '.git-mem directory should be created'); + assert.ok(existsSync(join(gitMemDir, 'runtime.json')), 'runtime.json should exist'); + } finally { + cleanup(); + } + }); + + it('should overwrite existing runtime.json', () => { + try { + const firstData = createRuntimeData({ sessionId: 'first-session' }); + const secondData = createRuntimeData({ sessionId: 'second-session' }); + + service.activate(firstData, testDir); + service.activate(secondData, testDir); + + const filePath = join(testDir, '.git-mem', 'runtime.json'); + const content = JSON.parse(readFileSync(filePath, 'utf8')); + assert.equal(content.sessionId, 'second-session'); + } finally { + cleanup(); + } + }); + + it('should handle write errors gracefully (never throw)', () => { + try { + // Try to write to an invalid path (root or protected directory) + // This should not throw + const data = createRuntimeData(); + service.activate(data, '/nonexistent/path/that/should/not/exist'); + // If we get here without throwing, the test passes + assert.ok(true, 'activate should not throw on write errors'); + } finally { + cleanup(); + } + }); + }); + + describe('deactivate', () => { + it('should remove runtime.json file', () => { + try { + const data = createRuntimeData(); + service.activate(data, testDir); + + const filePath = join(testDir, '.git-mem', 'runtime.json'); + assert.ok(existsSync(filePath), 'runtime.json should exist before deactivate'); + + service.deactivate(testDir); + assert.ok(!existsSync(filePath), 'runtime.json should be removed after deactivate'); + } finally { + cleanup(); + } + }); + + it('should handle missing file gracefully (never throw)', () => { + try { + // Deactivate when no runtime.json exists should not throw + service.deactivate(testDir); + assert.ok(true, 'deactivate should not throw on missing file'); + } finally { + cleanup(); + } + }); + + it('should handle missing directory gracefully', () => { + try { + // Deactivate when .git-mem directory doesn't exist + const nonExistentDir = join(testDir, 'does-not-exist'); + service.deactivate(nonExistentDir); + assert.ok(true, 'deactivate should not throw on missing directory'); + } finally { + cleanup(); + } + }); + }); + + describe('read', () => { + it('should return data when file exists and is fresh', () => { + try { + const data = createRuntimeData(); + service.activate(data, testDir); + + const result = service.read(testDir); + assert.ok(result, 'read should return data'); + assert.equal(result.sessionId, data.sessionId); + assert.equal(result.agent, data.agent); + assert.equal(result.model, data.model); + assert.equal(result.source, data.source); + } finally { + cleanup(); + } + }); + + it('should return undefined when file is stale (past TTL)', () => { + try { + // Create data with old timestamp + const oldTimestamp = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(); // 3 hours ago + const data = createRuntimeData({ timestamp: oldTimestamp }); + service.activate(data, testDir); + + // Default TTL is 2 hours, so this should be stale + const result = service.read(testDir); + assert.equal(result, undefined, 'read should return undefined for stale data'); + } finally { + cleanup(); + } + }); + + it('should respect custom TTL', () => { + try { + // Create data with timestamp 30 minutes ago + const timestamp = new Date(Date.now() - 30 * 60 * 1000).toISOString(); + const data = createRuntimeData({ timestamp }); + service.activate(data, testDir); + + // With 1 hour TTL, data should be fresh + const freshResult = service.read(testDir, 60 * 60 * 1000); + assert.ok(freshResult, 'data should be fresh with 1 hour TTL'); + + // With 15 minute TTL, data should be stale + const staleResult = service.read(testDir, 15 * 60 * 1000); + assert.equal(staleResult, undefined, 'data should be stale with 15 minute TTL'); + } finally { + cleanup(); + } + }); + + it('should return undefined when file is missing', () => { + try { + const result = service.read(testDir); + assert.equal(result, undefined, 'read should return undefined for missing file'); + } finally { + cleanup(); + } + }); + + it('should return undefined when JSON is invalid', () => { + try { + const gitMemDir = join(testDir, '.git-mem'); + mkdirSync(gitMemDir, { recursive: true }); + writeFileSync(join(gitMemDir, 'runtime.json'), 'not valid json{{{', 'utf8'); + + const result = service.read(testDir); + assert.equal(result, undefined, 'read should return undefined for invalid JSON'); + } finally { + cleanup(); + } + }); + + it('should return undefined when data structure is invalid', () => { + try { + const gitMemDir = join(testDir, '.git-mem'); + mkdirSync(gitMemDir, { recursive: true }); + // Valid JSON but missing timestamp field + writeFileSync(join(gitMemDir, 'runtime.json'), '{"sessionId": "test"}', 'utf8'); + + const result = service.read(testDir); + assert.equal(result, undefined, 'read should return undefined for invalid structure'); + } finally { + cleanup(); + } + }); + + it('should return data with undefined agent and model', () => { + try { + const data = createRuntimeData({ + agent: undefined, + model: undefined, + }); + service.activate(data, testDir); + + const result = service.read(testDir); + assert.ok(result, 'read should return data'); + assert.equal(result.agent, undefined); + assert.equal(result.model, undefined); + assert.equal(result.sessionId, data.sessionId); + } finally { + cleanup(); + } + }); + }); +}); From 45c86073a5f82a8c63027e8981e1215f67e35057 Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Mon, 16 Feb 2026 16:28:19 +0000 Subject: [PATCH 04/10] fix: address PR review comments - RuntimeService: reject NaN/invalid timestamps and future timestamps (with 1 minute skew allowance) - SessionStopHandler: move deactivateRuntime to finally block so it runs even when capture fails - SessionStartHandler: use env-only detection functions to avoid runtime.json loop when activating - AgentResolver: cache runtime.json read to avoid repeated file I/O - IRuntimeService: fix doc comment (errors silently ignored, not logged) - Remove accidental test.txt file Co-Authored-By: Claude Opus 4.5 AI-Agent: Claude-Code/2.1.42 AI-Model: claude-opus-4-5-20251101 AI-Convention: runtime.json loop when activating AI-Confidence: medium AI-Tags: application, handlers, domain, infrastructure, services, tests, unit, pattern:avoid AI-Lifecycle: project AI-Memory-Id: 70a43cb3 AI-Source: heuristic --- .../handlers/SessionStartHandler.ts | 16 +++++-- .../handlers/SessionStopHandler.ts | 14 ++++-- src/domain/interfaces/IRuntimeService.ts | 2 +- src/infrastructure/di/container.ts | 4 +- src/infrastructure/services/AgentResolver.ts | 25 ++++++++-- src/infrastructure/services/RuntimeService.ts | 9 +++- test.txt | 2 - .../handlers/SessionStartHandler.test.ts | 21 ++++---- .../services/RuntimeService.test.ts | 48 +++++++++++++++++++ 9 files changed, 110 insertions(+), 31 deletions(-) delete mode 100644 test.txt diff --git a/src/application/handlers/SessionStartHandler.ts b/src/application/handlers/SessionStartHandler.ts index 87fc0e0e..3c21b81d 100644 --- a/src/application/handlers/SessionStartHandler.ts +++ b/src/application/handlers/SessionStartHandler.ts @@ -13,7 +13,6 @@ import type { IMemoryContextLoader } from '../../domain/interfaces/IMemoryContex import type { IContextFormatter } from '../../domain/interfaces/IContextFormatter'; import type { ILogger } from '../../domain/interfaces/ILogger'; import type { IRuntimeService } from '../../domain/interfaces/IRuntimeService'; -import type { IAgentResolver } from '../../domain/interfaces/IAgentResolver'; export class SessionStartHandler implements ISessionStartHandler { constructor( @@ -21,7 +20,12 @@ export class SessionStartHandler implements ISessionStartHandler { private readonly contextFormatter: IContextFormatter, private readonly logger?: ILogger, private readonly runtimeService?: IRuntimeService, - private readonly agentResolver?: IAgentResolver, + /** + * Env-only agent detection function. Uses direct env var detection + * to avoid reading runtime.json (which we're about to write). + */ + private readonly detectAgent?: () => string | undefined, + private readonly detectModel?: () => string | undefined, ) {} async handle(event: ISessionStartEvent): Promise { @@ -76,16 +80,18 @@ export class SessionStartHandler implements ISessionStartHandler { /** * Activate runtime.json with current agent/model detection. + * Uses env-only detection to avoid reading runtime.json (circular). * Never throws — activation errors are logged and ignored. */ private activateRuntime(event: ISessionStartEvent): void { - if (!this.runtimeService || !this.agentResolver) { + if (!this.runtimeService || !this.detectAgent || !this.detectModel) { return; } try { - const agent = this.agentResolver.resolveAgent(); - const model = this.agentResolver.resolveModel(); + // Use env-only detection to avoid reading runtime.json we're about to write + const agent = this.detectAgent(); + const model = this.detectModel(); // Determine source based on which env var is set const source = this.detectSource(); diff --git a/src/application/handlers/SessionStopHandler.ts b/src/application/handlers/SessionStopHandler.ts index c49f50e8..ab48775b 100644 --- a/src/application/handlers/SessionStopHandler.ts +++ b/src/application/handlers/SessionStopHandler.ts @@ -21,6 +21,8 @@ export class SessionStopHandler implements ISessionStopHandler { ) {} async handle(event: ISessionStopEvent): Promise { + let handlerResult: IEventResult; + try { this.logger?.info('Session stop handler invoked', { sessionId: event.sessionId, @@ -37,10 +39,7 @@ export class SessionStopHandler implements ISessionStopHandler { memoriesExtracted: result.memoriesExtracted, }); - // Deactivate runtime.json to prevent stale attribution - this.deactivateRuntime(event.cwd); - - return { + handlerResult = { handler: 'SessionStopHandler', success: true, output: result.summary, @@ -51,12 +50,17 @@ export class SessionStopHandler implements ISessionStopHandler { error: err.message, stack: err.stack, }); - return { + handlerResult = { handler: 'SessionStopHandler', success: false, error: err, }; + } finally { + // Always deactivate runtime.json on session stop, even if capture fails + this.deactivateRuntime(event.cwd); } + + return handlerResult; } /** diff --git a/src/domain/interfaces/IRuntimeService.ts b/src/domain/interfaces/IRuntimeService.ts index 1c2537c9..9aca8ddd 100644 --- a/src/domain/interfaces/IRuntimeService.ts +++ b/src/domain/interfaces/IRuntimeService.ts @@ -26,7 +26,7 @@ export interface IRuntimeService { /** * Write runtime.json with session data. * Creates .git-mem/ directory if missing. - * Never throws — logs errors and continues silently. + * Never throws — errors are silently ignored. * @param data - Runtime data to persist * @param cwd - Working directory (defaults to process.cwd()) */ diff --git a/src/infrastructure/di/container.ts b/src/infrastructure/di/container.ts index 46781699..e5e218f5 100644 --- a/src/infrastructure/di/container.ts +++ b/src/infrastructure/di/container.ts @@ -29,6 +29,7 @@ import { IntentExtractor } from '../llm/IntentExtractor'; import { AgentResolver } from '../services/AgentResolver'; import { HookConfigLoader } from '../services/HookConfigLoader'; import { RuntimeService } from '../services/RuntimeService'; +import { resolveAgent, resolveModel } from '../detect-agent'; // Application — core services import { MemoryService } from '../../application/services/MemoryService'; @@ -84,7 +85,8 @@ export function createContainer(options?: IContainerOptions): AwilixContainer ttl) { + // Reject future timestamps (with small skew allowance) or stale data + if (age < -60000 || age > ttl) { return undefined; } diff --git a/test.txt b/test.txt deleted file mode 100644 index bcc5fd8d..00000000 --- a/test.txt +++ /dev/null @@ -1,2 +0,0 @@ -claude -codex diff --git a/tests/unit/application/handlers/SessionStartHandler.test.ts b/tests/unit/application/handlers/SessionStartHandler.test.ts index db01ecc8..56424c6d 100644 --- a/tests/unit/application/handlers/SessionStartHandler.test.ts +++ b/tests/unit/application/handlers/SessionStartHandler.test.ts @@ -10,7 +10,6 @@ import type { IContextFormatter } from '../../../../src/domain/interfaces/IConte import type { ISessionStartEvent } from '../../../../src/domain/events/HookEvents'; import type { IMemoryEntity } from '../../../../src/domain/entities/IMemoryEntity'; import type { IRuntimeService, IRuntimeData } from '../../../../src/domain/interfaces/IRuntimeService'; -import type { IAgentResolver } from '../../../../src/domain/interfaces/IAgentResolver'; function createEvent(overrides?: Partial): ISessionStartEvent { return { @@ -61,10 +60,10 @@ function createMockRuntimeService(overrides?: Partial): IRuntim }; } -function createMockAgentResolver(agent?: string, model?: string): IAgentResolver { +function createDetectFunctions(agent?: string, model?: string): { detectAgent: () => string | undefined; detectModel: () => string | undefined } { return { - resolveAgent: () => agent, - resolveModel: () => model, + detectAgent: () => agent, + detectModel: () => model, }; } @@ -178,8 +177,8 @@ describe('SessionStartHandler', () => { const loader = createMockLoader({ memories: [], total: 0, filtered: 0 }); const formatter = createMockFormatter(''); const runtimeService = createMockRuntimeService(); - const agentResolver = createMockAgentResolver('Claude-Code/2.1.0', 'claude-opus-4-5-20251101'); - const handler = new SessionStartHandler(loader, formatter, undefined, runtimeService, agentResolver); + const { detectAgent, detectModel } = createDetectFunctions('Claude-Code/2.1.0', 'claude-opus-4-5-20251101'); + const handler = new SessionStartHandler(loader, formatter, undefined, runtimeService, detectAgent, detectModel); await handler.handle(createEvent({ sessionId: 'session-123', cwd: '/my/repo' })); @@ -202,7 +201,7 @@ describe('SessionStartHandler', () => { assert.equal(result.success, true); }); - it('should not crash if agentResolver is not provided', async () => { + it('should not crash if detect functions are not provided', async () => { const loader = createMockLoader({ memories: [], total: 0, filtered: 0 }); const formatter = createMockFormatter(''); const runtimeService = createMockRuntimeService(); @@ -220,8 +219,8 @@ describe('SessionStartHandler', () => { const runtimeService = createMockRuntimeService({ activate: () => { throw new Error('activate failed'); }, }); - const agentResolver = createMockAgentResolver('Claude-Code/2.1.0', 'claude-opus-4-5-20251101'); - const handler = new SessionStartHandler(loader, formatter, undefined, runtimeService, agentResolver); + const { detectAgent, detectModel } = createDetectFunctions('Claude-Code/2.1.0', 'claude-opus-4-5-20251101'); + const handler = new SessionStartHandler(loader, formatter, undefined, runtimeService, detectAgent, detectModel); const result = await handler.handle(createEvent()); @@ -232,8 +231,8 @@ describe('SessionStartHandler', () => { const loader = createMockLoader({ memories: [], total: 0, filtered: 0 }); const formatter = createMockFormatter(''); const runtimeService = createMockRuntimeService(); - const agentResolver = createMockAgentResolver(undefined, undefined); - const handler = new SessionStartHandler(loader, formatter, undefined, runtimeService, agentResolver); + const { detectAgent, detectModel } = createDetectFunctions(undefined, undefined); + const handler = new SessionStartHandler(loader, formatter, undefined, runtimeService, detectAgent, detectModel); await handler.handle(createEvent()); diff --git a/tests/unit/infrastructure/services/RuntimeService.test.ts b/tests/unit/infrastructure/services/RuntimeService.test.ts index 9822664d..8b39c317 100644 --- a/tests/unit/infrastructure/services/RuntimeService.test.ts +++ b/tests/unit/infrastructure/services/RuntimeService.test.ts @@ -246,5 +246,53 @@ describe('RuntimeService', () => { cleanup(); } }); + + it('should return undefined when timestamp is invalid/unparseable', () => { + try { + const gitMemDir = join(testDir, '.git-mem'); + mkdirSync(gitMemDir, { recursive: true }); + // Valid JSON with unparseable timestamp + writeFileSync(join(gitMemDir, 'runtime.json'), JSON.stringify({ + sessionId: 'test', + timestamp: 'not-a-date', + agent: 'test', + model: 'test', + source: 'test', + }), 'utf8'); + + const result = service.read(testDir); + assert.equal(result, undefined, 'read should return undefined for invalid timestamp'); + } finally { + cleanup(); + } + }); + + it('should return undefined when timestamp is in the future', () => { + try { + // Create data with timestamp 2 minutes in the future (beyond 1 minute skew allowance) + const futureTimestamp = new Date(Date.now() + 2 * 60 * 1000).toISOString(); + const data = createRuntimeData({ timestamp: futureTimestamp }); + service.activate(data, testDir); + + const result = service.read(testDir); + assert.equal(result, undefined, 'read should return undefined for future timestamp'); + } finally { + cleanup(); + } + }); + + it('should allow small clock skew for recent timestamps', () => { + try { + // Create data with timestamp 30 seconds in the future (within 1 minute skew allowance) + const slightlyFutureTimestamp = new Date(Date.now() + 30 * 1000).toISOString(); + const data = createRuntimeData({ timestamp: slightlyFutureTimestamp }); + service.activate(data, testDir); + + const result = service.read(testDir); + assert.ok(result, 'read should allow small clock skew'); + } finally { + cleanup(); + } + }); }); }); From 12941ebb74909942ed8fefc09b52c621377fc243 Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Tue, 17 Feb 2026 15:21:16 +0000 Subject: [PATCH 05/10] chore: enforce ClickUp linking guardrails GIT-869c63k1e ClickUp task: 869c63k1e https: //app.clickup.com/t/869c63k1e AI-Agent: Codex/0.92.0 AI-Model: gpt-5.3-codex AI-Fact: enforce ClickUp linking guardrails GIT-869c63k1e. ClickUp task: 869c63k1e AI-Confidence: low AI-Tags: hooks, tests, unit AI-Lifecycle: project AI-Memory-Id: 00f9d6ef AI-Source: heuristic --- .github/pull_request_template.md | 14 +++++++++++ .github/workflows/clickup-linking.yml | 34 +++++++++++++++++++++++++++ CLAUDE.md | 2 +- src/hooks/commit-msg.ts | 8 ++++++- tests/unit/hooks/commit-msg.test.ts | 7 +++--- 5 files changed, 60 insertions(+), 5 deletions(-) create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/clickup-linking.yml diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..e616a558 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,14 @@ +## Summary + +Describe what changed and why. + +## ClickUp Task + +Task ID: `GIT-` + +Task Link: https://app.clickup.com/t/ + +## Validation + +- [ ] Build passes locally +- [ ] Tests pass locally diff --git a/.github/workflows/clickup-linking.yml b/.github/workflows/clickup-linking.yml new file mode 100644 index 00000000..e94456a8 --- /dev/null +++ b/.github/workflows/clickup-linking.yml @@ -0,0 +1,34 @@ +name: ClickUp Linking Checks + +on: + pull_request: + types: [opened, edited, synchronize, reopened, ready_for_review] + branches: [main] + +jobs: + validate-clickup-linking: + runs-on: ubuntu-latest + steps: + - name: Validate branch and PR include ClickUp task ID + env: + BRANCH_NAME: ${{ github.event.pull_request.head.ref }} + PR_TITLE: ${{ github.event.pull_request.title }} + PR_BODY: ${{ github.event.pull_request.body }} + run: | + set -euo pipefail + + BRANCH_NO_PREFIX="${BRANCH_NAME#codex/}" + + if [[ ! "$BRANCH_NO_PREFIX" =~ ^GIT-[A-Za-z0-9]+_.+ ]]; then + echo "Branch name must match codex/GIT-_ (example: codex/GIT-123abc_fix-mcp-auth)." >&2 + exit 1 + fi + + TASK_ID="${BRANCH_NO_PREFIX%%_*}" + + if [[ "$PR_TITLE" != *"$TASK_ID"* ]] && [[ "${PR_BODY:-}" != *"$TASK_ID"* ]]; then + echo "PR title or body must include task ID: $TASK_ID" >&2 + exit 1 + fi + + echo "ClickUp linking checks passed for task ID: $TASK_ID" diff --git a/CLAUDE.md b/CLAUDE.md index 79678b75..6b670973 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,7 +118,7 @@ Rules are automatically loaded as context. See `.claude/rules/`: ## Git Workflow -- Branch per Linear issue, named with ticket number (e.g. `GIT-15`) +- Branch per ClickUp task, named with task ID (e.g. `GIT-123abc`) - Use the SKILL .claude/skills/github/SKILL.md for interacting with GitHub - PR workflow use the skill .claude/skills/pr/SKILL.md - Create the PR diff --git a/src/hooks/commit-msg.ts b/src/hooks/commit-msg.ts index 1fc38d56..be746ee6 100644 --- a/src/hooks/commit-msg.ts +++ b/src/hooks/commit-msg.ts @@ -17,7 +17,7 @@ import { execFileSync } from 'child_process'; const HOOK_FINGERPRINT_PREFIX = '# git-mem:commit-msg'; /** Full fingerprint with version — used for upgrade detection. */ -const HOOK_FINGERPRINT = `${HOOK_FINGERPRINT_PREFIX} v6`; +const HOOK_FINGERPRINT = `${HOOK_FINGERPRINT_PREFIX} v7`; /** * The shell hook script. @@ -44,6 +44,12 @@ head -1 "$COMMIT_MSG_FILE" | grep -qiE "^(fixup|squash|amend)! " && exit 0 # Skip revert commits (auto-generated) head -1 "$COMMIT_MSG_FILE" | grep -qiE '^Revert "' && exit 0 +# Require ClickUp task ID in commit message for GitHub/ClickUp linking +grep -qiE '\\bGIT-[A-Za-z0-9]+\\b' "$COMMIT_MSG_FILE" || { + echo "git-mem: commit message must include a ClickUp task ID (e.g. GIT-123abc)." >&2 + exit 1 +} + # Resolve repository root for hook context. REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd) diff --git a/tests/unit/hooks/commit-msg.test.ts b/tests/unit/hooks/commit-msg.test.ts index 1732e03e..11f882e0 100644 --- a/tests/unit/hooks/commit-msg.test.ts +++ b/tests/unit/hooks/commit-msg.test.ts @@ -39,8 +39,9 @@ describe('installCommitMsgHook', () => { const content = readFileSync(result.hookPath, 'utf8'); assert.ok(content.includes('#!/bin/sh')); - assert.ok(content.includes('git-mem:commit-msg v6')); + assert.ok(content.includes('git-mem:commit-msg v7')); assert.ok(content.includes('git-mem hook commit-msg')); + assert.ok(content.includes('commit message must include a ClickUp task ID')); assert.ok(content.includes('\\"cwd\\"')); assert.ok(content.includes('git rev-parse --show-toplevel')); }); @@ -76,7 +77,7 @@ describe('installCommitMsgHook', () => { // Installed hook should contain both fingerprint and wrapper reference const content = readFileSync(hookPath, 'utf8'); - assert.ok(content.includes('git-mem:commit-msg v6')); + assert.ok(content.includes('git-mem:commit-msg v7')); assert.ok(content.includes('user-backup')); } finally { rmSync(freshRepo, { recursive: true, force: true }); @@ -104,7 +105,7 @@ describe('installCommitMsgHook', () => { assert.equal(result.wrapped, false); const content = readFileSync(hookPath, 'utf8'); - assert.ok(content.includes('git-mem:commit-msg v6'), 'Should be upgraded to v6'); + assert.ok(content.includes('git-mem:commit-msg v7'), 'Should be upgraded to v7'); assert.ok(content.includes('git-mem hook commit-msg'), 'Should include git-mem command'); // Second install should be idempotent From 033ba7c801decbbede9494cff4dd0f60b2ceddde Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Tue, 17 Feb 2026 15:27:01 +0000 Subject: [PATCH 06/10] test: reduce duplication in runtime service tests GIT-869c63k1e Refactor repeated temp-dir setup/cleanup into withTestDir helper and use node:* imports. AI-Agent: Codex/0.92.0 AI-Model: gpt-5.3-codex AI-Decision: The withTestDir helper pattern provides automatic cleanup using try-finally to ensure temporary directories are always removed even if tests fail. AI-Confidence: high AI-Tags: testing, refactoring, test-helpers, node-js, imports, modules, resource-management, cleanup, temp-directories, runtime-service, filesystem AI-Lifecycle: project AI-Memory-Id: 87292011 AI-Source: llm-enrichment --- src/infrastructure/services/RuntimeService.ts | 4 +- .../services/RuntimeService.test.ts | 136 +++++++----------- 2 files changed, 51 insertions(+), 89 deletions(-) diff --git a/src/infrastructure/services/RuntimeService.ts b/src/infrastructure/services/RuntimeService.ts index 8f42697c..9e734416 100644 --- a/src/infrastructure/services/RuntimeService.ts +++ b/src/infrastructure/services/RuntimeService.ts @@ -6,8 +6,8 @@ * agent/model detection when environment variables aren't available. */ -import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'fs'; -import { join } from 'path'; +import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; import type { IRuntimeService, IRuntimeData } from '../../domain/interfaces/IRuntimeService'; import { getConfigDir } from '../../hooks/utils/config'; diff --git a/tests/unit/infrastructure/services/RuntimeService.test.ts b/tests/unit/infrastructure/services/RuntimeService.test.ts index 8b39c317..49b363b9 100644 --- a/tests/unit/infrastructure/services/RuntimeService.test.ts +++ b/tests/unit/infrastructure/services/RuntimeService.test.ts @@ -1,33 +1,18 @@ -import { describe, it, before, after, beforeEach } from 'node:test'; +import { describe, it, before } from 'node:test'; import assert from 'node:assert/strict'; -import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'fs'; -import { join } from 'path'; -import { tmpdir } from 'os'; +import { existsSync, mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; import { RuntimeService } from '../../../../src/infrastructure/services/RuntimeService'; import type { IRuntimeData } from '../../../../src/domain/interfaces/IRuntimeService'; describe('RuntimeService', () => { let service: RuntimeService; - let testDir: string; before(() => { service = new RuntimeService(); }); - beforeEach(() => { - testDir = mkdtempSync(join(tmpdir(), 'git-mem-runtime-test-')); - }); - - after(() => { - // Cleanup is handled per-test in beforeEach/individual tests - }); - - function cleanup(): void { - if (testDir && existsSync(testDir)) { - rmSync(testDir, { recursive: true, force: true }); - } - } - function createRuntimeData(overrides?: Partial): IRuntimeData { return { sessionId: 'test-session-123', @@ -39,9 +24,20 @@ describe('RuntimeService', () => { }; } + function withTestDir(run: (testDir: string) => void): void { + const testDir = mkdtempSync(join(tmpdir(), 'git-mem-runtime-test-')); + try { + run(testDir); + } finally { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + } + } + describe('activate', () => { it('should create runtime.json with correct content', () => { - try { + withTestDir((testDir) => { const data = createRuntimeData(); service.activate(data, testDir); @@ -54,13 +50,11 @@ describe('RuntimeService', () => { assert.equal(content.model, data.model); assert.equal(content.timestamp, data.timestamp); assert.equal(content.source, data.source); - } finally { - cleanup(); - } + }); }); it('should create .git-mem/ directory if missing', () => { - try { + withTestDir((testDir) => { const gitMemDir = join(testDir, '.git-mem'); assert.ok(!existsSync(gitMemDir), '.git-mem should not exist initially'); @@ -69,13 +63,11 @@ describe('RuntimeService', () => { assert.ok(existsSync(gitMemDir), '.git-mem directory should be created'); assert.ok(existsSync(join(gitMemDir, 'runtime.json')), 'runtime.json should exist'); - } finally { - cleanup(); - } + }); }); it('should overwrite existing runtime.json', () => { - try { + withTestDir((testDir) => { const firstData = createRuntimeData({ sessionId: 'first-session' }); const secondData = createRuntimeData({ sessionId: 'second-session' }); @@ -85,28 +77,24 @@ describe('RuntimeService', () => { const filePath = join(testDir, '.git-mem', 'runtime.json'); const content = JSON.parse(readFileSync(filePath, 'utf8')); assert.equal(content.sessionId, 'second-session'); - } finally { - cleanup(); - } + }); }); it('should handle write errors gracefully (never throw)', () => { - try { + withTestDir(() => { // Try to write to an invalid path (root or protected directory) // This should not throw const data = createRuntimeData(); service.activate(data, '/nonexistent/path/that/should/not/exist'); // If we get here without throwing, the test passes assert.ok(true, 'activate should not throw on write errors'); - } finally { - cleanup(); - } + }); }); }); describe('deactivate', () => { it('should remove runtime.json file', () => { - try { + withTestDir((testDir) => { const data = createRuntimeData(); service.activate(data, testDir); @@ -115,36 +103,30 @@ describe('RuntimeService', () => { service.deactivate(testDir); assert.ok(!existsSync(filePath), 'runtime.json should be removed after deactivate'); - } finally { - cleanup(); - } + }); }); it('should handle missing file gracefully (never throw)', () => { - try { + withTestDir((testDir) => { // Deactivate when no runtime.json exists should not throw service.deactivate(testDir); assert.ok(true, 'deactivate should not throw on missing file'); - } finally { - cleanup(); - } + }); }); it('should handle missing directory gracefully', () => { - try { + withTestDir((testDir) => { // Deactivate when .git-mem directory doesn't exist const nonExistentDir = join(testDir, 'does-not-exist'); service.deactivate(nonExistentDir); assert.ok(true, 'deactivate should not throw on missing directory'); - } finally { - cleanup(); - } + }); }); }); describe('read', () => { it('should return data when file exists and is fresh', () => { - try { + withTestDir((testDir) => { const data = createRuntimeData(); service.activate(data, testDir); @@ -154,13 +136,11 @@ describe('RuntimeService', () => { assert.equal(result.agent, data.agent); assert.equal(result.model, data.model); assert.equal(result.source, data.source); - } finally { - cleanup(); - } + }); }); it('should return undefined when file is stale (past TTL)', () => { - try { + withTestDir((testDir) => { // Create data with old timestamp const oldTimestamp = new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(); // 3 hours ago const data = createRuntimeData({ timestamp: oldTimestamp }); @@ -169,13 +149,11 @@ describe('RuntimeService', () => { // Default TTL is 2 hours, so this should be stale const result = service.read(testDir); assert.equal(result, undefined, 'read should return undefined for stale data'); - } finally { - cleanup(); - } + }); }); it('should respect custom TTL', () => { - try { + withTestDir((testDir) => { // Create data with timestamp 30 minutes ago const timestamp = new Date(Date.now() - 30 * 60 * 1000).toISOString(); const data = createRuntimeData({ timestamp }); @@ -188,35 +166,29 @@ describe('RuntimeService', () => { // With 15 minute TTL, data should be stale const staleResult = service.read(testDir, 15 * 60 * 1000); assert.equal(staleResult, undefined, 'data should be stale with 15 minute TTL'); - } finally { - cleanup(); - } + }); }); it('should return undefined when file is missing', () => { - try { + withTestDir((testDir) => { const result = service.read(testDir); assert.equal(result, undefined, 'read should return undefined for missing file'); - } finally { - cleanup(); - } + }); }); it('should return undefined when JSON is invalid', () => { - try { + withTestDir((testDir) => { const gitMemDir = join(testDir, '.git-mem'); mkdirSync(gitMemDir, { recursive: true }); writeFileSync(join(gitMemDir, 'runtime.json'), 'not valid json{{{', 'utf8'); const result = service.read(testDir); assert.equal(result, undefined, 'read should return undefined for invalid JSON'); - } finally { - cleanup(); - } + }); }); it('should return undefined when data structure is invalid', () => { - try { + withTestDir((testDir) => { const gitMemDir = join(testDir, '.git-mem'); mkdirSync(gitMemDir, { recursive: true }); // Valid JSON but missing timestamp field @@ -224,13 +196,11 @@ describe('RuntimeService', () => { const result = service.read(testDir); assert.equal(result, undefined, 'read should return undefined for invalid structure'); - } finally { - cleanup(); - } + }); }); it('should return data with undefined agent and model', () => { - try { + withTestDir((testDir) => { const data = createRuntimeData({ agent: undefined, model: undefined, @@ -242,13 +212,11 @@ describe('RuntimeService', () => { assert.equal(result.agent, undefined); assert.equal(result.model, undefined); assert.equal(result.sessionId, data.sessionId); - } finally { - cleanup(); - } + }); }); it('should return undefined when timestamp is invalid/unparseable', () => { - try { + withTestDir((testDir) => { const gitMemDir = join(testDir, '.git-mem'); mkdirSync(gitMemDir, { recursive: true }); // Valid JSON with unparseable timestamp @@ -262,13 +230,11 @@ describe('RuntimeService', () => { const result = service.read(testDir); assert.equal(result, undefined, 'read should return undefined for invalid timestamp'); - } finally { - cleanup(); - } + }); }); it('should return undefined when timestamp is in the future', () => { - try { + withTestDir((testDir) => { // Create data with timestamp 2 minutes in the future (beyond 1 minute skew allowance) const futureTimestamp = new Date(Date.now() + 2 * 60 * 1000).toISOString(); const data = createRuntimeData({ timestamp: futureTimestamp }); @@ -276,13 +242,11 @@ describe('RuntimeService', () => { const result = service.read(testDir); assert.equal(result, undefined, 'read should return undefined for future timestamp'); - } finally { - cleanup(); - } + }); }); it('should allow small clock skew for recent timestamps', () => { - try { + withTestDir((testDir) => { // Create data with timestamp 30 seconds in the future (within 1 minute skew allowance) const slightlyFutureTimestamp = new Date(Date.now() + 30 * 1000).toISOString(); const data = createRuntimeData({ timestamp: slightlyFutureTimestamp }); @@ -290,9 +254,7 @@ describe('RuntimeService', () => { const result = service.read(testDir); assert.ok(result, 'read should allow small clock skew'); - } finally { - cleanup(); - } + }); }); }); }); From c9980e76b21e6b46f6f7e5e8f363e40292ccaed4 Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Tue, 17 Feb 2026 15:29:00 +0000 Subject: [PATCH 07/10] test: remove duplicated runtime read assertions GIT-869c63k1e Consolidate missing/invalid JSON read checks into one case to reduce Sonar duplication. AI-Agent: Codex/0.92.0 AI-Model: gpt-5.3-codex AI-Convention: Test consolidation is preferred over duplication to satisfy Sonar code quality rules, even when it means combining logically separate test scenarios. AI-Confidence: verified AI-Tags: testing, sonar, code-quality, test-consolidation, runtime-service, error-handling, json-parsing, file-operations, file-structure, git-mem, json-storage AI-Lifecycle: project AI-Memory-Id: 5642fc55 AI-Source: llm-enrichment --- .../infrastructure/services/RuntimeService.test.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/tests/unit/infrastructure/services/RuntimeService.test.ts b/tests/unit/infrastructure/services/RuntimeService.test.ts index 49b363b9..be96f069 100644 --- a/tests/unit/infrastructure/services/RuntimeService.test.ts +++ b/tests/unit/infrastructure/services/RuntimeService.test.ts @@ -169,20 +169,16 @@ describe('RuntimeService', () => { }); }); - it('should return undefined when file is missing', () => { + it('should return undefined when file is missing or JSON is invalid', () => { withTestDir((testDir) => { - const result = service.read(testDir); + let result = service.read(testDir); assert.equal(result, undefined, 'read should return undefined for missing file'); - }); - }); - it('should return undefined when JSON is invalid', () => { - withTestDir((testDir) => { const gitMemDir = join(testDir, '.git-mem'); mkdirSync(gitMemDir, { recursive: true }); writeFileSync(join(gitMemDir, 'runtime.json'), 'not valid json{{{', 'utf8'); - const result = service.read(testDir); + result = service.read(testDir); assert.equal(result, undefined, 'read should return undefined for invalid JSON'); }); }); From 3d9083612efaf97480a655856ef2f96d17f03b10 Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Tue, 17 Feb 2026 15:32:56 +0000 Subject: [PATCH 08/10] fix: address review comments in runtime and stop handler GIT-869c63k1e Use CLOCK_SKEW_ALLOWANCE_MS constant and remove uninitialized handlerResult path. AI-Agent: Codex/0.92.0 AI-Model: gpt-5.3-codex AI-Decision: Clock skew allowance for runtime data validation is set to 1 minute (60,000ms) to handle minor timestamp discrepancies between systems. AI-Confidence: verified AI-Tags: error-handling, variable-initialization, session-stop-handler, runtime-service, clock-skew, timestamp-validation, constants, magic-numbers, code-quality, cleanup, finally-block AI-Lifecycle: project AI-Memory-Id: d6a8f851 AI-Source: llm-enrichment --- src/application/handlers/SessionStopHandler.ts | 8 ++------ src/infrastructure/services/RuntimeService.ts | 4 +++- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/application/handlers/SessionStopHandler.ts b/src/application/handlers/SessionStopHandler.ts index ab48775b..763e2e72 100644 --- a/src/application/handlers/SessionStopHandler.ts +++ b/src/application/handlers/SessionStopHandler.ts @@ -21,8 +21,6 @@ export class SessionStopHandler implements ISessionStopHandler { ) {} async handle(event: ISessionStopEvent): Promise { - let handlerResult: IEventResult; - try { this.logger?.info('Session stop handler invoked', { sessionId: event.sessionId, @@ -39,7 +37,7 @@ export class SessionStopHandler implements ISessionStopHandler { memoriesExtracted: result.memoriesExtracted, }); - handlerResult = { + return { handler: 'SessionStopHandler', success: true, output: result.summary, @@ -50,7 +48,7 @@ export class SessionStopHandler implements ISessionStopHandler { error: err.message, stack: err.stack, }); - handlerResult = { + return { handler: 'SessionStopHandler', success: false, error: err, @@ -59,8 +57,6 @@ export class SessionStopHandler implements ISessionStopHandler { // Always deactivate runtime.json on session stop, even if capture fails this.deactivateRuntime(event.cwd); } - - return handlerResult; } /** diff --git a/src/infrastructure/services/RuntimeService.ts b/src/infrastructure/services/RuntimeService.ts index 9e734416..4569beb9 100644 --- a/src/infrastructure/services/RuntimeService.ts +++ b/src/infrastructure/services/RuntimeService.ts @@ -16,6 +16,8 @@ const RUNTIME_FILE = 'runtime.json'; /** Default TTL: 2 hours in milliseconds. */ const DEFAULT_TTL_MS = 2 * 60 * 60 * 1000; +/** Allow up to 1 minute of future timestamp skew. */ +const CLOCK_SKEW_ALLOWANCE_MS = 60 * 1000; export class RuntimeService implements IRuntimeService { activate(data: IRuntimeData, cwd?: string): void { @@ -76,7 +78,7 @@ export class RuntimeService implements IRuntimeService { const age = Date.now() - timestamp; // Reject future timestamps (with small skew allowance) or stale data - if (age < -60000 || age > ttl) { + if (age < -CLOCK_SKEW_ALLOWANCE_MS || age > ttl) { return undefined; } From 278640945bfbe0ff05513a32bb1d2aa7cade840e Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Tue, 17 Feb 2026 15:38:09 +0000 Subject: [PATCH 09/10] ci: enforce sonar and inline review governance GIT-869c63k1e Require Sonar pass + zero new issues and enforce inline-replied, resolved review threads. AI-Agent: Codex/0.92.0 AI-Model: gpt-5.3-codex AI-Fact: enforce sonar and inline review governance GIT-869c63k1e. Require Sonar pass + zero new issues and enforce inline-replied, resolved review threads. AI-Confidence: low AI-Tags: config, docs AI-Lifecycle: project AI-Memory-Id: c1d001da AI-Source: heuristic --- .github/workflows/pr-governance.yml | 111 ++++++++++++++++++++++++++++ CLAUDE.md | 2 + 2 files changed, 113 insertions(+) create mode 100644 .github/workflows/pr-governance.yml diff --git a/.github/workflows/pr-governance.yml b/.github/workflows/pr-governance.yml new file mode 100644 index 00000000..404c5a68 --- /dev/null +++ b/.github/workflows/pr-governance.yml @@ -0,0 +1,111 @@ +name: PR Governance + +on: + pull_request: + types: [opened, edited, synchronize, reopened, ready_for_review] + branches: [main] + +permissions: + contents: read + pull-requests: read + +jobs: + enforce-governance: + runs-on: ubuntu-latest + steps: + - name: Enforce Sonar quality gate and new issues policy + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + SONAR_PROJECT_KEY: ${{ github.repository_owner }}_${{ github.event.repository.name }} + run: | + set -euo pipefail + + quality_status="" + for _ in {1..30}; do + response="$(curl -sS "https://sonarcloud.io/api/project_pull_requests/list?project=${SONAR_PROJECT_KEY}")" + quality_status="$(jq -r --arg pr "${PR_NUMBER}" '.pullRequests[]? | select(.key == $pr) | .status.qualityGateStatus' <<<"$response")" + + if [[ -n "$quality_status" && "$quality_status" != "NONE" ]]; then + break + fi + + sleep 10 + done + + if [[ -z "$quality_status" || "$quality_status" == "NONE" ]]; then + echo "Sonar result not ready for PR #${PR_NUMBER}." >&2 + exit 1 + fi + + if [[ "$quality_status" != "OK" ]]; then + echo "Sonar quality gate must pass. Current status: ${quality_status}" >&2 + exit 1 + fi + + issues_response="$(curl -sS "https://sonarcloud.io/api/issues/search?componentKeys=${SONAR_PROJECT_KEY}&pullRequest=${PR_NUMBER}&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true&ps=1")" + new_issues="$(jq -r '.total // 0' <<<"$issues_response")" + + if [[ "$new_issues" != "0" ]]; then + echo "PR introduces ${new_issues} Sonar new issue(s). New issues must be 0." >&2 + exit 1 + fi + + echo "Sonar checks passed: quality gate OK and 0 new issues." + + - name: Enforce inline review replies and resolved threads + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO_OWNER: ${{ github.repository_owner }} + REPO_NAME: ${{ github.event.repository.name }} + run: | + set -euo pipefail + + query=' + query($owner:String!, $repo:String!, $number:Int!) { + repository(owner:$owner, name:$repo) { + pullRequest(number:$number) { + author { login } + reviewThreads(first:100) { + nodes { + id + isResolved + isOutdated + comments(first:100) { + nodes { + author { login } + } + } + } + } + } + } + }' + + response="$(gh api graphql -f query="$query" -F owner="$REPO_OWNER" -F repo="$REPO_NAME" -F number="$PR_NUMBER")" + pr_author="$(jq -r '.data.repository.pullRequest.author.login' <<<"$response")" + + unresolved_count="$(jq -r ' + .data.repository.pullRequest.reviewThreads.nodes + | map(select(.isOutdated | not)) + | map(select(.isResolved | not)) + | length + ' <<<"$response")" + + missing_inline_reply_count="$(jq -r --arg author "$pr_author" ' + .data.repository.pullRequest.reviewThreads.nodes + | map(select(.isOutdated | not)) + | map(select(([.comments.nodes[]?.author.login] | index($author)) | not)) + | length + ' <<<"$response")" + + if [[ "$missing_inline_reply_count" != "0" ]]; then + echo "Each active review thread must include an inline reply from PR author (${pr_author})." >&2 + exit 1 + fi + + if [[ "$unresolved_count" != "0" ]]; then + echo "All active review threads must be resolved before merge." >&2 + exit 1 + fi + + echo "Review thread checks passed." diff --git a/CLAUDE.md b/CLAUDE.md index 6b670973..ec9d5366 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,7 +123,9 @@ Rules are automatically loaded as context. See `.claude/rules/`: - PR workflow use the skill .claude/skills/pr/SKILL.md - Create the PR - Wait for 120 seconds to allow for review from coderabbit + - SonarCloud quality gate must pass and Sonar "New issues" must be 0 - Address comments directly inline to the comment + - Do not respond to review feedback in top-level PR comments when an inline thread exists - if a fix is applied, mark the comment as resolved - wait for another 120 seconds to allow for review from coderabbit - repeat the steps until all comments are resolved From 8380710f71d404eb974d925620e5ae302023a755 Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Tue, 17 Feb 2026 15:57:14 +0000 Subject: [PATCH 10/10] fix: address PR governance and review feedback GIT-869c63k1e Add Sonar auth/org support, GH token for gh api, pagination safeguards, doc naming alignment, and Sonar issue fixes. AI-Agent: Codex/0.92.0 AI-Model: gpt-5.3-codex AI-Gotcha: address PR governance and review feedback GIT-869c63k1e. Add Sonar auth/org support, GH token for gh api, pagination safeguards, doc naming alignment, and Sonar issue fixes. AI-Confidence: medium AI-Tags: infrastructure, services, tests, unit AI-Lifecycle: project AI-Memory-Id: bf652dba AI-Source: heuristic --- .github/workflows/pr-governance.yml | 47 +++++++++++++++++-- CLAUDE.md | 2 +- src/infrastructure/services/AgentResolver.ts | 10 ++-- .../services/RuntimeService.test.ts | 44 ++++++++--------- 4 files changed, 71 insertions(+), 32 deletions(-) diff --git a/.github/workflows/pr-governance.yml b/.github/workflows/pr-governance.yml index 404c5a68..cd5c9e21 100644 --- a/.github/workflows/pr-governance.yml +++ b/.github/workflows/pr-governance.yml @@ -16,20 +16,36 @@ jobs: - name: Enforce Sonar quality gate and new issues policy env: PR_NUMBER: ${{ github.event.pull_request.number }} - SONAR_PROJECT_KEY: ${{ github.repository_owner }}_${{ github.event.repository.name }} + SONAR_ORGANIZATION: ${{ vars.SONAR_ORGANIZATION || github.repository_owner }} + SONAR_PROJECT_KEY: ${{ vars.SONAR_PROJECT_KEY || replace(github.repository, '/', '_') }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_MAX_POLL_ITERATIONS: "30" + SONAR_POLL_INTERVAL_SECONDS: "10" run: | set -euo pipefail + MAX_POLL_ITERATIONS="${SONAR_MAX_POLL_ITERATIONS:-30}" + POLL_INTERVAL_SECONDS="${SONAR_POLL_INTERVAL_SECONDS:-10}" + SONAR_AUTH_ARGS=() + if [[ -n "${SONAR_TOKEN:-}" ]]; then + SONAR_AUTH_ARGS=(-u "${SONAR_TOKEN}:") + fi + quality_status="" - for _ in {1..30}; do - response="$(curl -sS "https://sonarcloud.io/api/project_pull_requests/list?project=${SONAR_PROJECT_KEY}")" + attempt=1 + while (( attempt <= MAX_POLL_ITERATIONS )); do + response="$( + curl -sS "${SONAR_AUTH_ARGS[@]}" \ + "https://sonarcloud.io/api/project_pull_requests/list?organization=${SONAR_ORGANIZATION}&project=${SONAR_PROJECT_KEY}" + )" quality_status="$(jq -r --arg pr "${PR_NUMBER}" '.pullRequests[]? | select(.key == $pr) | .status.qualityGateStatus' <<<"$response")" if [[ -n "$quality_status" && "$quality_status" != "NONE" ]]; then break fi - sleep 10 + sleep "${POLL_INTERVAL_SECONDS}" + attempt=$((attempt + 1)) done if [[ -z "$quality_status" || "$quality_status" == "NONE" ]]; then @@ -42,7 +58,10 @@ jobs: exit 1 fi - issues_response="$(curl -sS "https://sonarcloud.io/api/issues/search?componentKeys=${SONAR_PROJECT_KEY}&pullRequest=${PR_NUMBER}&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true&ps=1")" + issues_response="$( + curl -sS "${SONAR_AUTH_ARGS[@]}" \ + "https://sonarcloud.io/api/issues/search?organization=${SONAR_ORGANIZATION}&componentKeys=${SONAR_PROJECT_KEY}&pullRequest=${PR_NUMBER}&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true&ps=1" + )" new_issues="$(jq -r '.total // 0' <<<"$issues_response")" if [[ "$new_issues" != "0" ]]; then @@ -57,6 +76,7 @@ jobs: PR_NUMBER: ${{ github.event.pull_request.number }} REPO_OWNER: ${{ github.repository_owner }} REPO_NAME: ${{ github.event.repository.name }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail @@ -66,11 +86,17 @@ jobs: pullRequest(number:$number) { author { login } reviewThreads(first:100) { + pageInfo { + hasNextPage + } nodes { id isResolved isOutdated comments(first:100) { + pageInfo { + hasNextPage + } nodes { author { login } } @@ -84,6 +110,17 @@ jobs: response="$(gh api graphql -f query="$query" -F owner="$REPO_OWNER" -F repo="$REPO_NAME" -F number="$PR_NUMBER")" pr_author="$(jq -r '.data.repository.pullRequest.author.login' <<<"$response")" + has_more_threads="$(jq -r '.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage' <<<"$response")" + has_more_comments="$(jq -r ' + .data.repository.pullRequest.reviewThreads.nodes + | any(.comments.pageInfo.hasNextPage == true) + ' <<<"$response")" + + if [[ "$has_more_threads" == "true" || "$has_more_comments" == "true" ]]; then + echo "Review thread pagination limit reached; increase pagination handling before enforcing this check." >&2 + exit 1 + fi + unresolved_count="$(jq -r ' .data.repository.pullRequest.reviewThreads.nodes | map(select(.isOutdated | not)) diff --git a/CLAUDE.md b/CLAUDE.md index ec9d5366..e98f15cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,7 +118,7 @@ Rules are automatically loaded as context. See `.claude/rules/`: ## Git Workflow -- Branch per ClickUp task, named with task ID (e.g. `GIT-123abc`) +- Branch per ClickUp task, named using `codex/GIT-_` (e.g. `codex/GIT-123abc_fix-mcp-auth`) - Use the SKILL .claude/skills/github/SKILL.md for interacting with GitHub - PR workflow use the skill .claude/skills/pr/SKILL.md - Create the PR diff --git a/src/infrastructure/services/AgentResolver.ts b/src/infrastructure/services/AgentResolver.ts index 851eb47c..7ffc9155 100644 --- a/src/infrastructure/services/AgentResolver.ts +++ b/src/infrastructure/services/AgentResolver.ts @@ -17,7 +17,9 @@ import { resolveAgent, resolveModel } from '../detect-agent'; */ export class AgentResolver implements IAgentResolver { /** Cached runtime data to avoid repeated file reads. */ - private cachedRuntimeData: IRuntimeData | undefined | null = null; + private cachedRuntimeData: IRuntimeData | undefined; + /** Tracks whether runtime data was read from disk. */ + private runtimeDataLoaded = false; constructor( private readonly runtimeService?: IRuntimeService, @@ -50,9 +52,9 @@ export class AgentResolver implements IAgentResolver { * Get cached runtime data, reading from file only once per instance. */ private getRuntimeData(): IRuntimeData | undefined { - // null = not yet read, undefined = read but no data - if (this.cachedRuntimeData === null) { - this.cachedRuntimeData = this.runtimeService?.read(this.cwd); + if (!this.runtimeDataLoaded) { + this.cachedRuntimeData ??= this.runtimeService?.read(this.cwd); + this.runtimeDataLoaded = true; } return this.cachedRuntimeData; } diff --git a/tests/unit/infrastructure/services/RuntimeService.test.ts b/tests/unit/infrastructure/services/RuntimeService.test.ts index be96f069..1f5e409d 100644 --- a/tests/unit/infrastructure/services/RuntimeService.test.ts +++ b/tests/unit/infrastructure/services/RuntimeService.test.ts @@ -6,6 +6,28 @@ import { tmpdir } from 'node:os'; import { RuntimeService } from '../../../../src/infrastructure/services/RuntimeService'; import type { IRuntimeData } from '../../../../src/domain/interfaces/IRuntimeService'; +function createRuntimeData(overrides?: Partial): IRuntimeData { + return { + sessionId: 'test-session-123', + agent: 'Claude-Code/2.1.0', + model: 'claude-opus-4-5-20251101', + timestamp: new Date().toISOString(), + source: 'env:CLAUDECODE', + ...overrides, + }; +} + +function withTestDir(run: (testDir: string) => void): void { + const testDir = mkdtempSync(join(tmpdir(), 'git-mem-runtime-test-')); + try { + run(testDir); + } finally { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + } +} + describe('RuntimeService', () => { let service: RuntimeService; @@ -13,28 +35,6 @@ describe('RuntimeService', () => { service = new RuntimeService(); }); - function createRuntimeData(overrides?: Partial): IRuntimeData { - return { - sessionId: 'test-session-123', - agent: 'Claude-Code/2.1.0', - model: 'claude-opus-4-5-20251101', - timestamp: new Date().toISOString(), - source: 'env:CLAUDECODE', - ...overrides, - }; - } - - function withTestDir(run: (testDir: string) => void): void { - const testDir = mkdtempSync(join(tmpdir(), 'git-mem-runtime-test-')); - try { - run(testDir); - } finally { - if (existsSync(testDir)) { - rmSync(testDir, { recursive: true, force: true }); - } - } - } - describe('activate', () => { it('should create runtime.json with correct content', () => { withTestDir((testDir) => {