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/.github/workflows/pr-governance.yml b/.github/workflows/pr-governance.yml new file mode 100644 index 00000000..cd5c9e21 --- /dev/null +++ b/.github/workflows/pr-governance.yml @@ -0,0 +1,148 @@ +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_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="" + 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 "${POLL_INTERVAL_SECONDS}" + attempt=$((attempt + 1)) + 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 "${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 + 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 }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + 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) { + pageInfo { + hasNextPage + } + nodes { + id + isResolved + isOutdated + comments(first:100) { + pageInfo { + hasNextPage + } + 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")" + + 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)) + | 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 79678b75..e98f15cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,12 +118,14 @@ 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 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 - 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 diff --git a/src/application/handlers/SessionStartHandler.ts b/src/application/handlers/SessionStartHandler.ts index fa5f4b46..3c21b81d 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,20 @@ 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'; export class SessionStartHandler implements ISessionStartHandler { constructor( private readonly memoryContextLoader: IMemoryContextLoader, private readonly contextFormatter: IContextFormatter, private readonly logger?: ILogger, + private readonly runtimeService?: IRuntimeService, + /** + * 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 { @@ -26,6 +35,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 +77,53 @@ 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.detectAgent || !this.detectModel) { + return; + } + + try { + // 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(); + + 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..763e2e72 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 { @@ -50,6 +53,29 @@ export class SessionStopHandler implements ISessionStopHandler { success: false, error: err, }; + } finally { + // Always deactivate runtime.json on session stop, even if capture fails + this.deactivateRuntime(event.cwd); + } + } + + /** + * 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..9aca8ddd --- /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 — errors are silently ignored. + * @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/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/src/infrastructure/di/container.ts b/src/infrastructure/di/container.ts index 1df9dd00..e5e218f5 100644 --- a/src/infrastructure/di/container.ts +++ b/src/infrastructure/di/container.ts @@ -28,6 +28,8 @@ 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'; +import { resolveAgent, resolveModel } from '../detect-agent'; // Application — core services import { MemoryService } from '../../application/services/MemoryService'; @@ -63,9 +65,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 +84,14 @@ 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..56424c6d 100644 --- a/tests/unit/application/handlers/SessionStartHandler.test.ts +++ b/tests/unit/application/handlers/SessionStartHandler.test.ts @@ -9,6 +9,7 @@ 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'; function createEvent(overrides?: Partial): ISessionStartEvent { return { @@ -48,6 +49,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 createDetectFunctions(agent?: string, model?: string): { detectAgent: () => string | undefined; detectModel: () => string | undefined } { + return { + detectAgent: () => agent, + detectModel: () => model, + }; +} + describe('SessionStartHandler', () => { it('should return success with formatted output when memories exist', async () => { const memories = [createMemory()]; @@ -152,4 +171,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 { 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' })); + + 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 detect functions are 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 { 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()); + + 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 { detectAgent, detectModel } = createDetectFunctions(undefined, undefined); + const handler = new SessionStartHandler(loader, formatter, undefined, runtimeService, detectAgent, detectModel); + + 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/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 diff --git a/tests/unit/infrastructure/services/RuntimeService.test.ts b/tests/unit/infrastructure/services/RuntimeService.test.ts new file mode 100644 index 00000000..1f5e409d --- /dev/null +++ b/tests/unit/infrastructure/services/RuntimeService.test.ts @@ -0,0 +1,256 @@ +import { describe, it, before } from 'node:test'; +import assert from 'node:assert/strict'; +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'; + +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; + + before(() => { + service = new RuntimeService(); + }); + + describe('activate', () => { + it('should create runtime.json with correct content', () => { + withTestDir((testDir) => { + 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); + }); + }); + + it('should create .git-mem/ directory if missing', () => { + withTestDir((testDir) => { + 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'); + }); + }); + + it('should overwrite existing runtime.json', () => { + withTestDir((testDir) => { + 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'); + }); + }); + + it('should handle write errors gracefully (never throw)', () => { + 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'); + }); + }); + }); + + describe('deactivate', () => { + it('should remove runtime.json file', () => { + withTestDir((testDir) => { + 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'); + }); + }); + + it('should handle missing file gracefully (never throw)', () => { + withTestDir((testDir) => { + // Deactivate when no runtime.json exists should not throw + service.deactivate(testDir); + assert.ok(true, 'deactivate should not throw on missing file'); + }); + }); + + it('should handle missing directory gracefully', () => { + 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'); + }); + }); + }); + + describe('read', () => { + it('should return data when file exists and is fresh', () => { + withTestDir((testDir) => { + 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); + }); + }); + + it('should return undefined when file is stale (past TTL)', () => { + 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 }); + 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'); + }); + }); + + it('should respect custom TTL', () => { + withTestDir((testDir) => { + // 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'); + }); + }); + + it('should return undefined when file is missing or JSON is invalid', () => { + withTestDir((testDir) => { + let result = service.read(testDir); + assert.equal(result, undefined, 'read should return undefined for missing file'); + + const gitMemDir = join(testDir, '.git-mem'); + mkdirSync(gitMemDir, { recursive: true }); + writeFileSync(join(gitMemDir, 'runtime.json'), 'not valid json{{{', 'utf8'); + + result = service.read(testDir); + assert.equal(result, undefined, 'read should return undefined for invalid JSON'); + }); + }); + + it('should return undefined when data structure is invalid', () => { + withTestDir((testDir) => { + 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'); + }); + }); + + it('should return data with undefined agent and model', () => { + withTestDir((testDir) => { + 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); + }); + }); + + it('should return undefined when timestamp is invalid/unparseable', () => { + withTestDir((testDir) => { + 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'); + }); + }); + + it('should return undefined when timestamp is in the future', () => { + 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 }); + service.activate(data, testDir); + + const result = service.read(testDir); + assert.equal(result, undefined, 'read should return undefined for future timestamp'); + }); + }); + + it('should allow small clock skew for recent timestamps', () => { + 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 }); + service.activate(data, testDir); + + const result = service.read(testDir); + assert.ok(result, 'read should allow small clock skew'); + }); + }); + }); +});