diff --git a/src/cli/handlers/session-init.ts b/src/cli/handlers/session-init.ts index 16984364..5c255409 100644 --- a/src/cli/handlers/session-init.ts +++ b/src/cli/handlers/session-init.ts @@ -6,7 +6,7 @@ import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js'; import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js'; -import { getProjectName } from '../../utils/project-name.js'; +import { getProjectContext } from '../../utils/project-name.js'; import { logger } from '../../utils/logger.js'; import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js'; import { isProjectExcluded } from '../../utils/project-filter.js'; @@ -42,7 +42,7 @@ export const sessionInitHandler: EventHandler = { // Use placeholder so sessions still get created and tracked for memory const prompt = (!rawPrompt || !rawPrompt.trim()) ? '[media prompt]' : rawPrompt; - const project = getProjectName(cwd); + const project = getProjectContext(cwd).primary; const platformSource = normalizePlatformSource(input.platform); logger.debug('HOOK', 'session-init: Calling /api/sessions/init', { contentSessionId: sessionId, project }); diff --git a/src/services/context/ContextBuilder.ts b/src/services/context/ContextBuilder.ts index 0a5c1988..34b36de0 100644 --- a/src/services/context/ContextBuilder.ts +++ b/src/services/context/ContextBuilder.ts @@ -10,7 +10,7 @@ import { homedir } from 'os'; import { unlinkSync } from 'fs'; import { SessionStore } from '../sqlite/SessionStore.js'; import { logger } from '../../utils/logger.js'; -import { getProjectName } from '../../utils/project-name.js'; +import { getProjectContext } from '../../utils/project-name.js'; import type { ContextInput, ContextConfig, Observation, SessionSummary } from './types.js'; import { loadContextConfig } from './ContextConfigLoader.js'; @@ -129,11 +129,12 @@ export async function generateContext( ): Promise { const config = loadContextConfig(); const cwd = input?.cwd ?? process.cwd(); - const project = getProjectName(cwd); + const context = getProjectContext(cwd); + const project = context.primary; const platformSource = input?.platform_source; - // Use provided projects array (for worktree support) or fall back to single project - const projects = input?.projects || [project]; + // Use provided projects array (for worktree support) or fall back to all known projects + const projects = input?.projects ?? context.allProjects; // Full mode: fetch all observations but keep normal rendering (level 1 summaries) if (input?.full) { diff --git a/src/services/transcripts/processor.ts b/src/services/transcripts/processor.ts index 18fa96f0..04cb4afe 100644 --- a/src/services/transcripts/processor.ts +++ b/src/services/transcripts/processor.ts @@ -4,7 +4,7 @@ import { fileEditHandler } from '../../cli/handlers/file-edit.js'; import { sessionCompleteHandler } from '../../cli/handlers/session-complete.js'; import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js'; import { logger } from '../../utils/logger.js'; -import { getProjectContext, getProjectName } from '../../utils/project-name.js'; +import { getProjectContext } from '../../utils/project-name.js'; import { writeAgentsMd } from '../../utils/agents-md-utils.js'; import { resolveFieldSpec, resolveFields, matchesRule } from './field-utils.js'; import { expandHomePath } from './config.js'; @@ -104,7 +104,7 @@ export class TranscriptEventProcessor { const resolved = resolveFieldSpec(fieldSpec, entry, ctx); if (typeof resolved === 'string' && resolved.trim()) return resolved; if (watch.project) return watch.project; - if (session.cwd) return getProjectName(session.cwd); + if (session.cwd) return getProjectContext(session.cwd).primary; return session.project; } diff --git a/src/services/worker/http/routes/SessionRoutes.ts b/src/services/worker/http/routes/SessionRoutes.ts index 13828933..64d59514 100644 --- a/src/services/worker/http/routes/SessionRoutes.ts +++ b/src/services/worker/http/routes/SessionRoutes.ts @@ -22,7 +22,7 @@ import { PrivacyCheckValidator } from '../../validation/PrivacyCheckValidator.js import { SettingsDefaultsManager } from '../../../../shared/SettingsDefaultsManager.js'; import { USER_SETTINGS_PATH } from '../../../../shared/paths.js'; import { getProcessBySession, ensureProcessExit } from '../../ProcessRegistry.js'; -import { getProjectName } from '../../../../utils/project-name.js'; +import { getProjectContext } from '../../../../utils/project-name.js'; import { normalizePlatformSource } from '../../../../shared/platform-source.js'; export class SessionRoutes extends BaseRouteHandler { @@ -507,7 +507,7 @@ export class SessionRoutes extends BaseRouteHandler { private handleObservationsByClaudeId = this.wrapHandler((req: Request, res: Response): void => { const { contentSessionId, tool_name, tool_input, tool_response, cwd } = req.body; const platformSource = normalizePlatformSource(req.body.platformSource); - const project = typeof cwd === 'string' && cwd.trim() ? getProjectName(cwd) : ''; + const project = typeof cwd === 'string' && cwd.trim() ? getProjectContext(cwd).primary : ''; if (!contentSessionId) { return this.badRequest(res, 'Missing contentSessionId'); diff --git a/src/utils/project-name.ts b/src/utils/project-name.ts index 6e8e88ff..eb52edf4 100644 --- a/src/utils/project-name.ts +++ b/src/utils/project-name.ts @@ -58,13 +58,13 @@ export function getProjectName(cwd: string | null | undefined): string { * Project context with worktree awareness */ export interface ProjectContext { - /** The current project name (worktree or main repo) */ + /** Canonical project name for writes/queries (parent repo in worktrees) */ primary: string; /** Parent project name if in a worktree, null otherwise */ parent: string | null; /** True if currently in a worktree */ isWorktree: boolean; - /** All projects to query: [primary] for main repo, [parent, primary] for worktree */ + /** All projects to query: [primary] for main repo, [parentRepo, worktreeName] for worktree */ allProjects: string[]; } @@ -78,24 +78,26 @@ export interface ProjectContext { * @returns ProjectContext with worktree info */ export function getProjectContext(cwd: string | null | undefined): ProjectContext { - const primary = getProjectName(cwd); + const cwdProjectName = getProjectName(cwd); if (!cwd) { - return { primary, parent: null, isWorktree: false, allProjects: [primary] }; + return { primary: cwdProjectName, parent: null, isWorktree: false, allProjects: [cwdProjectName] }; } const expandedCwd = expandTilde(cwd); const worktreeInfo = detectWorktree(expandedCwd); if (worktreeInfo.isWorktree && worktreeInfo.parentProjectName) { - // In a worktree: include parent first for chronological ordering + // In a worktree: use parent project name as primary so observations + // are stored under the same project as the main repo (#1081, #1500, #1819) + const allProjects = Array.from(new Set([worktreeInfo.parentProjectName, cwdProjectName])); return { - primary, + primary: worktreeInfo.parentProjectName, parent: worktreeInfo.parentProjectName, isWorktree: true, - allProjects: [worktreeInfo.parentProjectName, primary] + allProjects }; } - return { primary, parent: null, isWorktree: false, allProjects: [primary] }; + return { primary: cwdProjectName, parent: null, isWorktree: false, allProjects: [cwdProjectName] }; } diff --git a/tests/utils/project-name.test.ts b/tests/utils/project-name.test.ts index 2354cbcd..435d5701 100644 --- a/tests/utils/project-name.test.ts +++ b/tests/utils/project-name.test.ts @@ -5,7 +5,7 @@ * Source: src/utils/project-name.ts */ -import { describe, it, expect } from 'bun:test'; +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; import { homedir } from 'os'; import { getProjectName, getProjectContext } from '../../src/utils/project-name.js'; @@ -96,4 +96,51 @@ describe('getProjectContext', () => { expect(ctx.primary).toBe('unknown-project'); expect(ctx.parent).toBeNull(); }); + + describe('worktree regression (#1081, #1500, #1819)', () => { + let tmp: string; + let mainRepo: string; + let worktreeCheckout: string; + + beforeAll(async () => { + const { mkdtempSync, mkdirSync, writeFileSync } = await import('fs'); + const { join } = await import('path'); + const { tmpdir } = await import('os'); + + tmp = mkdtempSync(join(tmpdir(), 'cm-wt-')); + mainRepo = join(tmp, 'main-repo'); + const worktreeGitDir = join(mainRepo, '.git', 'worktrees', 'my-worktree'); + worktreeCheckout = join(tmp, 'my-worktree'); + + mkdirSync(worktreeGitDir, { recursive: true }); + mkdirSync(worktreeCheckout, { recursive: true }); + writeFileSync( + join(worktreeCheckout, '.git'), + `gitdir: ${worktreeGitDir}\n` + ); + }); + + afterAll(async () => { + const { rmSync } = await import('fs'); + rmSync(tmp, { recursive: true, force: true }); + }); + + it('uses parent project name as primary when in a worktree', () => { + const ctx = getProjectContext(worktreeCheckout); + expect(ctx.isWorktree).toBe(true); + expect(ctx.primary).toBe('main-repo'); + expect(ctx.parent).toBe('main-repo'); + expect(ctx.allProjects).toEqual(['main-repo', 'my-worktree']); + }); + + it('write-path call sites resolve to parent project in worktrees', () => { + // Mirrors the pattern used by session-init.ts and SessionRoutes.ts: + // const project = getProjectContext(cwd).primary; + // This must resolve to the parent repo, not the worktree name, + // so observations are stored under the correct project. + const project = getProjectContext(worktreeCheckout).primary; + expect(project).toBe('main-repo'); + expect(project).not.toBe('my-worktree'); + }); + }); });