diff --git a/scripts/hooks/session-start.js b/scripts/hooks/session-start.js index a9943080..c95881ff 100644 --- a/scripts/hooks/session-start.js +++ b/scripts/hooks/session-start.js @@ -13,6 +13,7 @@ const { getSessionsDir, getSessionSearchDirs, getLearnedSkillsDir, + getProjectName, findFiles, ensureDir, readFile, @@ -23,6 +24,25 @@ const { getPackageManager, getSelectionPrompt } = require('../lib/package-manage const { listAliases } = require('../lib/session-aliases'); const { detectProjectType } = require('../lib/project-detect'); const path = require('path'); +const fs = require('fs'); + +/** + * Resolve a filesystem path to its canonical (real) form. + * + * Handles symlinks and, on case-insensitive filesystems (macOS, Windows), + * normalizes casing so that path comparisons are reliable. + * Falls back to the original path if resolution fails (e.g. path no longer exists). + * + * @param {string} p - The path to normalize. + * @returns {string} The canonical path, or the original if resolution fails. + */ +function normalizePath(p) { + try { + return fs.realpathSync(p); + } catch { + return p; + } +} function dedupeRecentSessions(searchDirs) { const recentSessionsByName = new Map(); @@ -53,6 +73,87 @@ function dedupeRecentSessions(searchDirs) { .sort((left, right) => right.mtime - left.mtime || left.dirIndex - right.dirIndex); } +/** + * Select the best matching session for the current working directory. + * + * Session files written by session-end.js contain header fields like: + * **Project:** my-project + * **Worktree:** /path/to/project + * + * This function reads each session file once, caching its content, and + * returns both the selected session object and its already-read content + * to avoid duplicate I/O in the caller. + * + * Priority (highest to lowest): + * 1. Exact worktree (cwd) match — most recent + * 2. Same project name match — most recent + * 3. Fallback to overall most recent (original behavior) + * + * Sessions are already sorted newest-first, so the first match in each + * category wins. + * + * @param {Array} sessions - Deduplicated session list, sorted newest-first. + * @param {string} cwd - Current working directory (process.cwd()). + * @param {string} currentProject - Current project name from getProjectName(). + * @returns {{ session: Object, content: string, matchReason: string } | null} + * The best matching session with its cached content and match reason, + * or null if the sessions array is empty or all files are unreadable. + */ +function selectMatchingSession(sessions, cwd, currentProject) { + if (sessions.length === 0) return null; + + // Normalize cwd once outside the loop to avoid repeated syscalls + const normalizedCwd = normalizePath(cwd); + + let projectMatch = null; + let projectMatchContent = null; + let fallbackSession = null; + let fallbackContent = null; + + for (const session of sessions) { + const content = readFile(session.path); + if (!content) continue; + + // Cache first readable session+content pair for fallback + if (!fallbackSession) { + fallbackSession = session; + fallbackContent = content; + } + + // Extract **Worktree:** field + const worktreeMatch = content.match(/\*\*Worktree:\*\*\s*(.+)$/m); + const sessionWorktree = worktreeMatch ? worktreeMatch[1].trim() : ''; + + // Exact worktree match — best possible, return immediately + // Normalize both paths to handle symlinks and case-insensitive filesystems + if (sessionWorktree && normalizePath(sessionWorktree) === normalizedCwd) { + return { session, content, matchReason: 'worktree' }; + } + + // Project name match — keep searching for a worktree match + if (!projectMatch && currentProject) { + const projectFieldMatch = content.match(/\*\*Project:\*\*\s*(.+)$/m); + const sessionProject = projectFieldMatch ? projectFieldMatch[1].trim() : ''; + if (sessionProject && sessionProject === currentProject) { + projectMatch = session; + projectMatchContent = content; + } + } + } + + if (projectMatch) { + return { session: projectMatch, content: projectMatchContent, matchReason: 'project' }; + } + + // Fallback: most recent readable session (original behavior) + if (fallbackSession) { + return { session: fallbackSession, content: fallbackContent, matchReason: 'recency-fallback' }; + } + + log('[SessionStart] All session files were unreadable'); + return null; +} + async function main() { const sessionsDir = getSessionsDir(); const learnedDir = getLearnedSkillsDir(); @@ -66,15 +167,26 @@ async function main() { const recentSessions = dedupeRecentSessions(getSessionSearchDirs()); if (recentSessions.length > 0) { - const latest = recentSessions[0]; log(`[SessionStart] Found ${recentSessions.length} recent session(s)`); - log(`[SessionStart] Latest: ${latest.path}`); - // Read and inject the latest session content into Claude's context - const content = stripAnsi(readFile(latest.path)); - if (content && !content.includes('[Session context goes here]')) { - // Only inject if the session has actual content (not the blank template) - additionalContextParts.push(`Previous session summary:\n${content}`); + // Prefer a session that matches the current working directory or project. + // Session files contain **Project:** and **Worktree:** header fields written + // by session-end.js, so we can match against them. + const cwd = process.cwd(); + const currentProject = getProjectName() || ''; + + const result = selectMatchingSession(recentSessions, cwd, currentProject); + + if (result) { + log(`[SessionStart] Selected: ${result.session.path} (match: ${result.matchReason})`); + + // Use the already-read content from selectMatchingSession (no duplicate I/O) + const content = stripAnsi(result.content); + if (content && !content.includes('[Session context goes here]')) { + additionalContextParts.push(`Previous session summary:\n${content}`); + } + } else { + log('[SessionStart] No matching session found'); } }