@tekmidian/pai 0.5.4 → 0.5.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/hooks/ts/session-start/load-project-context.ts", "../../src/hooks/ts/lib/project-utils.ts", "../../src/hooks/ts/lib/pai-paths.ts"],
4
- "sourcesContent": ["#!/usr/bin/env node\n\n/**\n * load-project-context.ts\n *\n * SessionStart hook that sets up project context:\n * - Checks for CLAUDE.md in various locations (Claude Code handles loading)\n * - Sets up Notes/ directory in ~/.claude/projects/{encoded-path}/\n * - Ensures TODO.md exists\n * - Sends ntfy.sh notification (mandatory)\n * - Displays session continuity info (like session-init.sh)\n *\n * This hook complements Claude Code's native CLAUDE.md loading by:\n * - Setting up the Notes infrastructure\n * - Showing the latest session note for continuity\n * - Sending ntfy.sh notifications\n */\n\nimport { existsSync, readdirSync, readFileSync, statSync } from 'fs';\nimport { join, basename, dirname } from 'path';\nimport { execSync } from 'child_process';\nimport {\n PAI_DIR,\n findNotesDir,\n getProjectDir,\n getCurrentNotePath,\n createSessionNote,\n findTodoPath,\n findAllClaudeMdPaths,\n sendNtfyNotification\n} from '../lib/project-utils';\n\n/**\n * Find the pai CLI binary path dynamically.\n * Tries `which pai` first, then common fallback locations.\n */\nfunction findPaiBinary(): string {\n try {\n return execSync('which pai', { encoding: 'utf-8' }).trim();\n } catch {\n // Fallback locations in order of preference\n const fallbacks = [\n '/usr/local/bin/pai',\n '/opt/homebrew/bin/pai',\n `${process.env.HOME}/.local/bin/pai`,\n ];\n for (const p of fallbacks) {\n if (existsSync(p)) return p;\n }\n }\n return 'pai'; // Last resort: rely on PATH at runtime\n}\n\n/**\n * Check session-routing.json for an active route.\n * Returns the routed Notes path if set, or null to use default behavior.\n */\nfunction getRoutedNotesPath(): string | null {\n const routingFile = join(PAI_DIR, 'session-routing.json');\n if (!existsSync(routingFile)) return null;\n\n try {\n const routing = JSON.parse(readFileSync(routingFile, 'utf-8'));\n const active = routing?.active_session;\n if (active?.notes_path) {\n return active.notes_path;\n }\n } catch {\n // Ignore parse errors\n }\n return null;\n}\n\ninterface HookInput {\n session_id: string;\n cwd: string;\n hook_event_name: string;\n}\n\nasync function main() {\n console.error('\\nload-project-context.ts starting...');\n\n // Read hook input from stdin\n let hookInput: HookInput | null = null;\n try {\n const chunks: Buffer[] = [];\n for await (const chunk of process.stdin) {\n chunks.push(chunk);\n }\n const input = Buffer.concat(chunks).toString('utf-8');\n if (input.trim()) {\n hookInput = JSON.parse(input);\n }\n } catch (error) {\n console.error('Could not parse hook input, using process.cwd()');\n }\n\n // Get current working directory\n const cwd = hookInput?.cwd || process.cwd();\n\n // Determine meaningful project name\n // If cwd is a Notes directory, use parent directory name instead\n let projectName = basename(cwd);\n if (projectName.toLowerCase() === 'notes') {\n projectName = basename(dirname(cwd));\n }\n\n console.error(`Working directory: ${cwd}`);\n console.error(`Project: ${projectName}`);\n\n // Check if this is a subagent session - skip for subagents\n const isSubagent = process.env.CLAUDE_AGENT_TYPE !== undefined ||\n (process.env.CLAUDE_PROJECT_DIR || '').includes('/.claude/agents/');\n\n if (isSubagent) {\n console.error('Subagent session - skipping project context setup');\n process.exit(0);\n }\n\n // 1. Find and READ all CLAUDE.md files - inject them into context\n // This ensures Claude actually processes the instructions, not just sees them in headers\n const claudeMdPaths = findAllClaudeMdPaths(cwd);\n const claudeMdContents: { path: string; content: string }[] = [];\n\n if (claudeMdPaths.length > 0) {\n console.error(`Found ${claudeMdPaths.length} CLAUDE.md file(s):`);\n for (const path of claudeMdPaths) {\n console.error(` - ${path}`);\n try {\n const content = readFileSync(path, 'utf-8');\n claudeMdContents.push({ path, content });\n console.error(` Read ${content.length} chars`);\n } catch (error) {\n console.error(` Could not read: ${error}`);\n }\n }\n } else {\n console.error('No CLAUDE.md found in project');\n console.error(' Consider creating one at ./CLAUDE.md or ./.claude/CLAUDE.md');\n }\n\n // 2. Find or create Notes directory\n // Priority:\n // 1. Active session routing (pai route <project>) \u2192 routed Obsidian path\n // 2. Local Notes/ in cwd \u2192 use it (git-trackable, e.g. symlink to Obsidian)\n // 3. Central ~/.claude/projects/.../Notes/ \u2192 fallback\n const routedPath = getRoutedNotesPath();\n let notesDir: string;\n\n if (routedPath) {\n // Routing is active - use the configured Obsidian Notes path\n const { mkdirSync } = await import('fs');\n if (!existsSync(routedPath)) {\n mkdirSync(routedPath, { recursive: true });\n console.error(`Created routed Notes: ${routedPath}`);\n } else {\n console.error(`Notes directory: ${routedPath} (routed via pai route)`);\n }\n notesDir = routedPath;\n } else {\n const notesInfo = findNotesDir(cwd);\n\n if (notesInfo.isLocal) {\n notesDir = notesInfo.path;\n console.error(`Notes directory: ${notesDir} (local)`);\n } else {\n // Create central Notes directory\n if (!existsSync(notesInfo.path)) {\n const { mkdirSync } = await import('fs');\n mkdirSync(notesInfo.path, { recursive: true });\n console.error(`Created central Notes: ${notesInfo.path}`);\n } else {\n console.error(`Notes directory: ${notesInfo.path} (central)`);\n }\n notesDir = notesInfo.path;\n }\n }\n\n // 3. Cleanup old .jsonl files from project root (move to sessions/)\n // Keep the newest one for potential resume, move older ones to sessions/\n const projectDir = getProjectDir(cwd);\n if (existsSync(projectDir)) {\n try {\n const files = readdirSync(projectDir);\n const jsonlFiles = files\n .filter(f => f.endsWith('.jsonl'))\n .map(f => ({\n name: f,\n path: join(projectDir, f),\n mtime: statSync(join(projectDir, f)).mtime.getTime()\n }))\n .sort((a, b) => b.mtime - a.mtime); // newest first\n\n if (jsonlFiles.length > 1) {\n const { mkdirSync, renameSync } = await import('fs');\n const sessionsDir = join(projectDir, 'sessions');\n if (!existsSync(sessionsDir)) {\n mkdirSync(sessionsDir, { recursive: true });\n }\n\n // Move all except the newest\n for (let i = 1; i < jsonlFiles.length; i++) {\n const file = jsonlFiles[i];\n const destPath = join(sessionsDir, file.name);\n if (!existsSync(destPath)) {\n renameSync(file.path, destPath);\n console.error(`Moved old session: ${file.name} \u2192 sessions/`);\n }\n }\n }\n } catch (error) {\n console.error(`Could not cleanup old .jsonl files: ${error}`);\n }\n }\n\n // 4. Find or create TODO.md\n const todoPath = findTodoPath(cwd);\n const hasTodo = existsSync(todoPath);\n if (hasTodo) {\n console.error(`TODO.md: ${todoPath}`);\n } else {\n // Create TODO.md in the Notes directory\n const newTodoPath = join(notesDir, 'TODO.md');\n const { writeFileSync } = await import('fs');\n writeFileSync(newTodoPath, `# TODO\\n\\n## Offen\\n\\n- [ ] \\n\\n---\\n\\n*Created: ${new Date().toISOString()}*\\n`);\n console.error(`Created TODO.md: ${newTodoPath}`);\n }\n\n // 5. Check for existing note or create new one\n let activeNotePath: string | null = null;\n\n if (notesDir) { // notesDir is always set now (local or central)\n const currentNotePath = getCurrentNotePath(notesDir);\n\n // Determine if we need a new note\n let needsNewNote = false;\n if (!currentNotePath) {\n needsNewNote = true;\n console.error('\\nNo previous session notes found - creating new one');\n } else {\n // Check if the existing note is completed\n try {\n const content = readFileSync(currentNotePath, 'utf-8');\n if (content.includes('**Status:** Completed') || content.includes('**Completed:**')) {\n needsNewNote = true;\n console.error(`\\nPrevious note completed - creating new one`);\n const summaryMatch = content.match(/## Next Steps\\n\\n([^\\n]+)/);\n if (summaryMatch) {\n console.error(` Previous: ${summaryMatch[1].substring(0, 60)}...`);\n }\n } else {\n console.error(`\\nContinuing session note: ${basename(currentNotePath)}`);\n }\n } catch {\n needsNewNote = true;\n }\n }\n\n // Create new note if needed\n if (needsNewNote) {\n activeNotePath = createSessionNote(notesDir, projectName);\n console.error(`Created: ${basename(activeNotePath)}`);\n } else {\n activeNotePath = currentNotePath!;\n // Show preview of current note\n try {\n const content = readFileSync(activeNotePath, 'utf-8');\n const lines = content.split('\\n').slice(0, 12);\n console.error('--- Current Note Preview ---');\n for (const line of lines) {\n console.error(line);\n }\n console.error('--- End Preview ---\\n');\n } catch {\n // Ignore read errors\n }\n }\n }\n\n // 6. Show TODO.md preview\n if (existsSync(todoPath)) {\n try {\n const todoContent = readFileSync(todoPath, 'utf-8');\n const todoLines = todoContent.split('\\n').filter(l => l.includes('[ ]')).slice(0, 5);\n if (todoLines.length > 0) {\n console.error('\\nOpen TODOs:');\n for (const line of todoLines) {\n console.error(` ${line.trim()}`);\n }\n }\n } catch {\n // Ignore read errors\n }\n }\n\n // 7. Send ntfy.sh notification (MANDATORY)\n await sendNtfyNotification(`Session started in ${projectName}`);\n\n // 7.5. Run pai project detect to identify the registered PAI project\n const paiBin = findPaiBinary();\n let paiProjectBlock = '';\n try {\n const { execFileSync } = await import('child_process');\n const raw = execFileSync(paiBin, ['project', 'detect', '--json', cwd], {\n encoding: 'utf-8',\n env: process.env,\n }).trim();\n\n if (raw) {\n const detected = JSON.parse(raw) as {\n slug?: string;\n display_name?: string;\n root_path?: string;\n match_type?: string;\n relative_path?: string | null;\n session_count?: number;\n status?: string;\n error?: string;\n cwd?: string;\n };\n\n if (detected.error === 'no_match') {\n paiProjectBlock = `PAI Project Registry: No registered project matches this directory.\nRun \"pai project add .\" to register this project, or use /route to tag the session.`;\n console.error('PAI detect: no match for', cwd);\n } else if (detected.slug) {\n const name = detected.display_name || detected.slug;\n const nameSlug = ` (slug: ${detected.slug})`;\n const matchDesc = detected.match_type === 'exact'\n ? 'exact'\n : `parent (+${detected.relative_path ?? ''})`;\n const statusFlag = detected.status && detected.status !== 'active'\n ? ` [${detected.status.toUpperCase()}]`\n : '';\n paiProjectBlock = `PAI Project Registry: ${name}${statusFlag}${nameSlug}\nMatch: ${matchDesc} | Sessions: ${detected.session_count ?? 0}${detected.status && detected.status !== 'active' ? `\\nWARNING: Project status is \"${detected.status}\". Run: pai project health --fix` : ''}`;\n console.error(`PAI detect: matched \"${detected.slug}\" (${detected.match_type})`);\n }\n }\n } catch (e) {\n // Non-fatal \u2014 don't break session start if pai is unavailable\n console.error('pai project detect failed:', e);\n }\n\n // 8. Output system reminder with session info\n const reminder = `\n<system-reminder>\nPROJECT CONTEXT LOADED\n\nProject: ${projectName}\nWorking Directory: ${cwd}\n${notesDir ? `Notes Directory: ${notesDir}${routedPath ? ' (routed via pai route)' : ''}` : 'Notes: disabled (no local Notes/ directory)'}\n${hasTodo ? `TODO: ${todoPath}` : 'TODO: not found'}\n${claudeMdPaths.length > 0 ? `CLAUDE.md: ${claudeMdPaths.join(', ')}` : 'No CLAUDE.md found'}\n${activeNotePath ? `Active Note: ${basename(activeNotePath)}` : ''}\n${routedPath ? `\\nNote Routing: ACTIVE (pai route is set - notes go to Obsidian vault)` : ''}\n${paiProjectBlock ? `\\n${paiProjectBlock}` : ''}\nSession Commands:\n- \"pause session\" \u2192 Save checkpoint, update TODO, exit (no compact)\n- \"end session\" \u2192 Finalize note, commit if needed, start fresh next time\n- \"pai route clear\" \u2192 Clear note routing (in a new session)\n</system-reminder>\n`;\n\n // Output to stdout for Claude to receive\n console.log(reminder);\n\n // 9. INJECT CLAUDE.md contents as system-reminders\n // This ensures Claude actually reads and processes the instructions\n for (const { path, content } of claudeMdContents) {\n const claudeMdReminder = `\n<system-reminder>\nLOCAL CLAUDE.md LOADED (MANDATORY - READ AND FOLLOW)\n\nSource: ${path}\n\n${content}\n\n---\nTHE ABOVE INSTRUCTIONS ARE MANDATORY. Follow them exactly.\n</system-reminder>\n`;\n console.log(claudeMdReminder);\n console.error(`Injected CLAUDE.md content from: ${path}`);\n }\n\n console.error('\\nProject context setup complete\\n');\n process.exit(0);\n}\n\nmain().catch(error => {\n console.error('load-project-context.ts error:', error);\n process.exit(0); // Don't block session start\n});\n", "/**\n * project-utils.ts - Shared utilities for project context management\n *\n * Provides:\n * - Path encoding (matching Claude Code's scheme)\n * - ntfy.sh notifications (mandatory, synchronous)\n * - Session notes management\n * - Session token calculation\n */\n\nimport { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync } from 'fs';\nimport { join, basename } from 'path';\n\n// Import from pai-paths which handles .env loading and path resolution\nimport { PAI_DIR } from './pai-paths.js';\n\n// Re-export PAI_DIR for consumers\nexport { PAI_DIR };\nexport const PROJECTS_DIR = join(PAI_DIR, 'projects');\n\n/**\n * Encode a path the same way Claude Code does:\n * - Replace / with -\n * - Replace . with - (hidden directories become --name)\n *\n * This matches Claude Code's internal encoding to ensure Notes\n * are stored in the same project directory as transcripts.\n */\nexport function encodePath(path: string): string {\n return path\n .replace(/\\//g, '-') // Slashes become dashes\n .replace(/\\./g, '-') // Dots also become dashes\n .replace(/ /g, '-'); // Spaces become dashes (matches Claude Code native encoding)\n}\n\n/**\n * Get the project directory for a given working directory\n */\nexport function getProjectDir(cwd: string): string {\n const encoded = encodePath(cwd);\n return join(PROJECTS_DIR, encoded);\n}\n\n/**\n * Get the Notes directory for a project (central location)\n */\nexport function getNotesDir(cwd: string): string {\n return join(getProjectDir(cwd), 'Notes');\n}\n\n/**\n * Find Notes directory - check local first, fallback to central\n * DOES NOT create the directory - just finds the right location\n *\n * Logic:\n * - If cwd itself IS a Notes directory \u2192 use it directly\n * - If local Notes/ exists \u2192 use it (can be checked into git)\n * - Otherwise \u2192 use central ~/.claude/projects/.../Notes/\n */\nexport function findNotesDir(cwd: string): { path: string; isLocal: boolean } {\n // FIRST: Check if cwd itself IS a Notes directory\n const cwdBasename = basename(cwd).toLowerCase();\n if (cwdBasename === 'notes' && existsSync(cwd)) {\n return { path: cwd, isLocal: true };\n }\n\n // Check local locations\n const localPaths = [\n join(cwd, 'Notes'),\n join(cwd, 'notes'),\n join(cwd, '.claude', 'Notes')\n ];\n\n for (const path of localPaths) {\n if (existsSync(path)) {\n return { path, isLocal: true };\n }\n }\n\n // Fallback to central location\n return { path: getNotesDir(cwd), isLocal: false };\n}\n\n/**\n * Get the Sessions directory for a project (stores .jsonl transcripts)\n */\nexport function getSessionsDir(cwd: string): string {\n return join(getProjectDir(cwd), 'sessions');\n}\n\n/**\n * Get the Sessions directory from a project directory path\n */\nexport function getSessionsDirFromProjectDir(projectDir: string): string {\n return join(projectDir, 'sessions');\n}\n\n/**\n * Check if WhatsApp (Whazaa) is configured as an enabled MCP server.\n *\n * Uses standard Claude Code config at ~/.claude/settings.json.\n * No PAI dependency \u2014 works for any Claude Code user with whazaa installed.\n */\nexport function isWhatsAppEnabled(): boolean {\n try {\n const { homedir } = require('os');\n const settingsPath = join(homedir(), '.claude', 'settings.json');\n if (!existsSync(settingsPath)) return false;\n\n const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));\n const enabled: string[] = settings.enabledMcpjsonServers || [];\n return enabled.includes('whazaa');\n } catch {\n return false;\n }\n}\n\n/**\n * Send push notification \u2014 WhatsApp-aware with ntfy fallback.\n *\n * When WhatsApp (Whazaa) is enabled in MCP config, ntfy is SKIPPED\n * because the AI sends WhatsApp messages directly via MCP. Sending both\n * would cause duplicate notifications.\n *\n * When WhatsApp is NOT configured, ntfy fires as the fallback channel.\n */\nexport async function sendNtfyNotification(message: string, retries = 2): Promise<boolean> {\n // Skip ntfy when WhatsApp is configured \u2014 the AI handles notifications via MCP\n if (isWhatsAppEnabled()) {\n console.error(`WhatsApp (Whazaa) enabled in MCP config \u2014 skipping ntfy`);\n return true;\n }\n\n const topic = process.env.NTFY_TOPIC;\n\n if (!topic) {\n console.error('NTFY_TOPIC not set and WhatsApp not active \u2014 notifications disabled');\n return false;\n }\n\n for (let attempt = 0; attempt <= retries; attempt++) {\n try {\n const response = await fetch(`https://ntfy.sh/${topic}`, {\n method: 'POST',\n body: message,\n headers: {\n 'Title': 'Claude Code',\n 'Priority': 'default'\n }\n });\n\n if (response.ok) {\n console.error(`ntfy.sh notification sent (WhatsApp inactive): \"${message}\"`);\n return true;\n } else {\n console.error(`ntfy.sh attempt ${attempt + 1} failed: ${response.status}`);\n }\n } catch (error) {\n console.error(`ntfy.sh attempt ${attempt + 1} error: ${error}`);\n }\n\n // Wait before retry\n if (attempt < retries) {\n await new Promise(resolve => setTimeout(resolve, 1000));\n }\n }\n\n console.error('ntfy.sh notification failed after all retries');\n return false;\n}\n\n/**\n * Ensure the Notes directory exists for a project\n * DEPRECATED: Use ensureNotesDirSmart() instead\n */\nexport function ensureNotesDir(cwd: string): string {\n const notesDir = getNotesDir(cwd);\n\n if (!existsSync(notesDir)) {\n mkdirSync(notesDir, { recursive: true });\n console.error(`Created Notes directory: ${notesDir}`);\n }\n\n return notesDir;\n}\n\n/**\n * Smart Notes directory handling:\n * - If local Notes/ exists \u2192 use it (don't create anything new)\n * - If no local Notes/ \u2192 ensure central exists and use that\n *\n * This respects the user's choice:\n * - Projects with local Notes/ keep notes there (git-trackable)\n * - Other directories don't get cluttered with auto-created Notes/\n */\nexport function ensureNotesDirSmart(cwd: string): { path: string; isLocal: boolean } {\n const found = findNotesDir(cwd);\n\n if (found.isLocal) {\n // Local Notes/ exists - use it as-is\n return found;\n }\n\n // No local Notes/ - ensure central exists\n if (!existsSync(found.path)) {\n mkdirSync(found.path, { recursive: true });\n console.error(`Created central Notes directory: ${found.path}`);\n }\n\n return found;\n}\n\n/**\n * Ensure the Sessions directory exists for a project\n */\nexport function ensureSessionsDir(cwd: string): string {\n const sessionsDir = getSessionsDir(cwd);\n\n if (!existsSync(sessionsDir)) {\n mkdirSync(sessionsDir, { recursive: true });\n console.error(`Created sessions directory: ${sessionsDir}`);\n }\n\n return sessionsDir;\n}\n\n/**\n * Ensure the Sessions directory exists (from project dir path)\n */\nexport function ensureSessionsDirFromProjectDir(projectDir: string): string {\n const sessionsDir = getSessionsDirFromProjectDir(projectDir);\n\n if (!existsSync(sessionsDir)) {\n mkdirSync(sessionsDir, { recursive: true });\n console.error(`Created sessions directory: ${sessionsDir}`);\n }\n\n return sessionsDir;\n}\n\n/**\n * Move all .jsonl session files from project root to sessions/ subdirectory\n * @param projectDir - The project directory path\n * @param excludeFile - Optional filename to exclude (e.g., current active session)\n * @param silent - If true, suppress console output\n * Returns the number of files moved\n */\nexport function moveSessionFilesToSessionsDir(\n projectDir: string,\n excludeFile?: string,\n silent: boolean = false\n): number {\n const sessionsDir = ensureSessionsDirFromProjectDir(projectDir);\n\n if (!existsSync(projectDir)) {\n return 0;\n }\n\n const files = readdirSync(projectDir);\n let movedCount = 0;\n\n for (const file of files) {\n // Match session files: uuid.jsonl or agent-*.jsonl\n // Skip the excluded file (typically the current active session)\n if (file.endsWith('.jsonl') && file !== excludeFile) {\n const sourcePath = join(projectDir, file);\n const destPath = join(sessionsDir, file);\n\n try {\n renameSync(sourcePath, destPath);\n if (!silent) {\n console.error(`Moved ${file} \u2192 sessions/`);\n }\n movedCount++;\n } catch (error) {\n if (!silent) {\n console.error(`Could not move ${file}: ${error}`);\n }\n }\n }\n }\n\n return movedCount;\n}\n\n/**\n * Get the YYYY/MM subdirectory for the current month inside notesDir.\n * Creates the directory if it doesn't exist.\n */\nfunction getMonthDir(notesDir: string): string {\n const now = new Date();\n const year = String(now.getFullYear());\n const month = String(now.getMonth() + 1).padStart(2, '0');\n const monthDir = join(notesDir, year, month);\n if (!existsSync(monthDir)) {\n mkdirSync(monthDir, { recursive: true });\n }\n return monthDir;\n}\n\n/**\n * Get the next note number (4-digit format: 0001, 0002, etc.)\n * ALWAYS uses 4-digit format with space-dash-space separators\n * Format: NNNN - YYYY-MM-DD - Description.md\n * Numbers reset per month (each YYYY/MM directory has its own sequence).\n */\nexport function getNextNoteNumber(notesDir: string): string {\n const monthDir = getMonthDir(notesDir);\n\n // Match CORRECT format: \"0001 - \" (4-digit with space-dash-space)\n // Also match legacy formats for backwards compatibility when detecting max number\n const files = readdirSync(monthDir)\n .filter(f => f.match(/^\\d{3,4}[\\s_-]/)) // Starts with 3-4 digits followed by separator\n .filter(f => f.endsWith('.md'))\n .sort();\n\n if (files.length === 0) {\n return '0001'; // Default to 4-digit\n }\n\n // Find the highest number across all formats\n let maxNumber = 0;\n for (const file of files) {\n const digitMatch = file.match(/^(\\d+)/);\n if (digitMatch) {\n const num = parseInt(digitMatch[1], 10);\n if (num > maxNumber) maxNumber = num;\n }\n }\n\n // ALWAYS return 4-digit format\n return String(maxNumber + 1).padStart(4, '0');\n}\n\n/**\n * Get the current (latest) note file path, or null if none exists.\n * Searches in the current month's YYYY/MM subdirectory first,\n * then falls back to previous month (for sessions spanning month boundaries),\n * then falls back to flat notesDir for legacy notes.\n * Supports multiple formats for backwards compatibility:\n * - CORRECT: \"0001 - YYYY-MM-DD - Description.md\" (space-dash-space)\n * - Legacy: \"001_YYYY-MM-DD_description.md\" (underscores)\n */\nexport function getCurrentNotePath(notesDir: string): string | null {\n if (!existsSync(notesDir)) {\n return null;\n }\n\n // Helper: find latest session note in a directory\n const findLatestIn = (dir: string): string | null => {\n if (!existsSync(dir)) return null;\n const files = readdirSync(dir)\n .filter(f => f.match(/^\\d{3,4}[\\s_-].*\\.md$/))\n .sort((a, b) => {\n const numA = parseInt(a.match(/^(\\d+)/)?.[1] || '0', 10);\n const numB = parseInt(b.match(/^(\\d+)/)?.[1] || '0', 10);\n return numA - numB;\n });\n if (files.length === 0) return null;\n return join(dir, files[files.length - 1]);\n };\n\n // 1. Check current month's YYYY/MM directory\n const now = new Date();\n const year = String(now.getFullYear());\n const month = String(now.getMonth() + 1).padStart(2, '0');\n const currentMonthDir = join(notesDir, year, month);\n const found = findLatestIn(currentMonthDir);\n if (found) return found;\n\n // 2. Check previous month (for sessions spanning month boundaries)\n const prevDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);\n const prevYear = String(prevDate.getFullYear());\n const prevMonth = String(prevDate.getMonth() + 1).padStart(2, '0');\n const prevMonthDir = join(notesDir, prevYear, prevMonth);\n const prevFound = findLatestIn(prevMonthDir);\n if (prevFound) return prevFound;\n\n // 3. Fallback: check flat notesDir (legacy notes not yet filed)\n return findLatestIn(notesDir);\n}\n\n/**\n * Create a new session note\n * CORRECT FORMAT: \"NNNN - YYYY-MM-DD - Description.md\"\n * - 4-digit zero-padded number\n * - Space-dash-space separators (NOT underscores)\n * - Title case description\n *\n * IMPORTANT: The initial description is just a PLACEHOLDER.\n * Claude MUST rename the file at session end with a meaningful description\n * based on the actual work done. Never leave it as \"New Session\" or project name.\n */\nexport function createSessionNote(notesDir: string, description: string): string {\n const noteNumber = getNextNoteNumber(notesDir);\n const date = new Date().toISOString().split('T')[0]; // YYYY-MM-DD\n\n // Use \"New Session\" as placeholder - Claude MUST rename at session end!\n // The project name alone is NOT descriptive enough.\n const safeDescription = 'New Session';\n\n // CORRECT FORMAT: space-dash-space separators, filed into YYYY/MM subdirectory\n const monthDir = getMonthDir(notesDir);\n const filename = `${noteNumber} - ${date} - ${safeDescription}.md`;\n const filepath = join(monthDir, filename);\n\n const content = `# Session ${noteNumber}: ${description}\n\n**Date:** ${date}\n**Status:** In Progress\n\n---\n\n## Work Done\n\n<!-- PAI will add completed work here during session -->\n\n---\n\n## Next Steps\n\n<!-- To be filled at session end -->\n\n---\n\n**Tags:** #Session\n`;\n\n writeFileSync(filepath, content);\n console.error(`Created session note: ${filename}`);\n\n return filepath;\n}\n\n/**\n * Append checkpoint to current session note\n */\nexport function appendCheckpoint(notePath: string, checkpoint: string): void {\n if (!existsSync(notePath)) {\n // Note vanished (cloud sync, cleanup, etc.) \u2014 recreate it\n console.error(`Note file not found, recreating: ${notePath}`);\n try {\n const parentDir = join(notePath, '..');\n if (!existsSync(parentDir)) {\n mkdirSync(parentDir, { recursive: true });\n }\n const noteFilename = basename(notePath);\n const numberMatch = noteFilename.match(/^(\\d+)/);\n const noteNumber = numberMatch ? numberMatch[1] : '0000';\n const date = new Date().toISOString().split('T')[0];\n const content = `# Session ${noteNumber}: Recovered\\n\\n**Date:** ${date}\\n**Status:** In Progress\\n\\n---\\n\\n## Work Done\\n\\n<!-- PAI will add completed work here during session -->\\n\\n---\\n\\n## Next Steps\\n\\n<!-- To be filled at session end -->\\n\\n---\\n\\n**Tags:** #Session\\n`;\n writeFileSync(notePath, content);\n console.error(`Recreated session note: ${noteFilename}`);\n } catch (err) {\n console.error(`Failed to recreate note: ${err}`);\n return;\n }\n }\n\n const content = readFileSync(notePath, 'utf-8');\n const timestamp = new Date().toISOString();\n const checkpointText = `\\n### Checkpoint ${timestamp}\\n\\n${checkpoint}\\n`;\n\n // Insert before \"## Next Steps\" if it exists, otherwise append\n const nextStepsIndex = content.indexOf('## Next Steps');\n let newContent: string;\n\n if (nextStepsIndex !== -1) {\n newContent = content.substring(0, nextStepsIndex) + checkpointText + content.substring(nextStepsIndex);\n } else {\n newContent = content + checkpointText;\n }\n\n writeFileSync(notePath, newContent);\n console.error(`Checkpoint added to: ${basename(notePath)}`);\n}\n\n/**\n * Work item for session notes\n */\nexport interface WorkItem {\n title: string;\n details?: string[];\n completed?: boolean;\n}\n\n/**\n * Add work items to the \"Work Done\" section of a session note\n * This is the main way to capture what was accomplished in a session\n */\nexport function addWorkToSessionNote(notePath: string, workItems: WorkItem[], sectionTitle?: string): void {\n if (!existsSync(notePath)) {\n console.error(`Note file not found: ${notePath}`);\n return;\n }\n\n let content = readFileSync(notePath, 'utf-8');\n\n // Build the work section content\n let workText = '';\n if (sectionTitle) {\n workText += `\\n### ${sectionTitle}\\n\\n`;\n }\n\n for (const item of workItems) {\n const checkbox = item.completed !== false ? '[x]' : '[ ]';\n workText += `- ${checkbox} **${item.title}**\\n`;\n if (item.details && item.details.length > 0) {\n for (const detail of item.details) {\n workText += ` - ${detail}\\n`;\n }\n }\n }\n\n // Find the Work Done section and insert after the comment/placeholder\n const workDoneMatch = content.match(/## Work Done\\n\\n(<!-- .*? -->)?/);\n if (workDoneMatch) {\n const insertPoint = content.indexOf(workDoneMatch[0]) + workDoneMatch[0].length;\n content = content.substring(0, insertPoint) + workText + content.substring(insertPoint);\n } else {\n // Fallback: insert before Next Steps\n const nextStepsIndex = content.indexOf('## Next Steps');\n if (nextStepsIndex !== -1) {\n content = content.substring(0, nextStepsIndex) + workText + '\\n' + content.substring(nextStepsIndex);\n }\n }\n\n writeFileSync(notePath, content);\n console.error(`Added ${workItems.length} work item(s) to: ${basename(notePath)}`);\n}\n\n/**\n * Update the session note title to be more descriptive\n * Called when we know what work was done\n */\nexport function updateSessionNoteTitle(notePath: string, newTitle: string): void {\n if (!existsSync(notePath)) {\n console.error(`Note file not found: ${notePath}`);\n return;\n }\n\n let content = readFileSync(notePath, 'utf-8');\n\n // Update the H1 title\n content = content.replace(/^# Session \\d+:.*$/m, (match) => {\n const sessionNum = match.match(/Session (\\d+)/)?.[1] || '';\n return `# Session ${sessionNum}: ${newTitle}`;\n });\n\n writeFileSync(notePath, content);\n\n // Also rename the file\n renameSessionNote(notePath, sanitizeForFilename(newTitle));\n}\n\n/**\n * Sanitize a string for use in a filename (exported for use elsewhere)\n */\nexport function sanitizeForFilename(str: string): string {\n return str\n .toLowerCase()\n .replace(/[^a-z0-9\\s-]/g, '') // Remove special chars\n .replace(/\\s+/g, '-') // Spaces to hyphens\n .replace(/-+/g, '-') // Collapse multiple hyphens\n .replace(/^-|-$/g, '') // Trim hyphens\n .substring(0, 50); // Limit length\n}\n\n/**\n * Extract a meaningful name from session note content\n * Looks at Work Done section and summary to generate a descriptive name\n */\nexport function extractMeaningfulName(noteContent: string, summary: string): string {\n // Try to extract from Work Done section headers (### headings)\n const workDoneMatch = noteContent.match(/## Work Done\\n\\n([\\s\\S]*?)(?=\\n---|\\n## Next)/);\n\n if (workDoneMatch) {\n const workDoneSection = workDoneMatch[1];\n\n // Look for ### subheadings which typically describe what was done\n const subheadings = workDoneSection.match(/### ([^\\n]+)/g);\n if (subheadings && subheadings.length > 0) {\n // Use the first subheading, clean it up\n const firstHeading = subheadings[0].replace('### ', '').trim();\n if (firstHeading.length > 5 && firstHeading.length < 60) {\n return sanitizeForFilename(firstHeading);\n }\n }\n\n // Look for bold text which often indicates key topics\n const boldMatches = workDoneSection.match(/\\*\\*([^*]+)\\*\\*/g);\n if (boldMatches && boldMatches.length > 0) {\n const firstBold = boldMatches[0].replace(/\\*\\*/g, '').trim();\n if (firstBold.length > 3 && firstBold.length < 50) {\n return sanitizeForFilename(firstBold);\n }\n }\n\n // Look for numbered list items (1. Something)\n const numberedItems = workDoneSection.match(/^\\d+\\.\\s+\\*\\*([^*]+)\\*\\*/m);\n if (numberedItems) {\n return sanitizeForFilename(numberedItems[1]);\n }\n }\n\n // Fall back to summary if provided\n if (summary && summary.length > 5 && summary !== 'Session completed.') {\n // Take first meaningful phrase from summary\n const cleanSummary = summary\n .replace(/[^\\w\\s-]/g, ' ')\n .trim()\n .split(/\\s+/)\n .slice(0, 5)\n .join(' ');\n\n if (cleanSummary.length > 3) {\n return sanitizeForFilename(cleanSummary);\n }\n }\n\n return '';\n}\n\n/**\n * Rename session note with a meaningful name\n * ALWAYS uses correct format: \"NNNN - YYYY-MM-DD - Description.md\"\n * Returns the new path, or original path if rename fails\n */\nexport function renameSessionNote(notePath: string, meaningfulName: string): string {\n if (!meaningfulName || !existsSync(notePath)) {\n return notePath;\n }\n\n const dir = join(notePath, '..');\n const oldFilename = basename(notePath);\n\n // Parse existing filename - support multiple formats:\n // CORRECT: \"0001 - 2026-01-02 - Description.md\"\n // Legacy: \"001_2026-01-02_description.md\"\n const correctMatch = oldFilename.match(/^(\\d{3,4}) - (\\d{4}-\\d{2}-\\d{2}) - .*\\.md$/);\n const legacyMatch = oldFilename.match(/^(\\d{3,4})_(\\d{4}-\\d{2}-\\d{2})_.*\\.md$/);\n\n const match = correctMatch || legacyMatch;\n if (!match) {\n return notePath; // Can't parse, don't rename\n }\n\n const [, noteNumber, date] = match;\n\n // Convert to Title Case\n const titleCaseName = meaningfulName\n .split(/[\\s_-]+/)\n .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())\n .join(' ')\n .trim();\n\n // ALWAYS use correct format with 4-digit number\n const paddedNumber = noteNumber.padStart(4, '0');\n const newFilename = `${paddedNumber} - ${date} - ${titleCaseName}.md`;\n const newPath = join(dir, newFilename);\n\n // Don't rename if name is the same\n if (newFilename === oldFilename) {\n return notePath;\n }\n\n try {\n renameSync(notePath, newPath);\n console.error(`Renamed note: ${oldFilename} \u2192 ${newFilename}`);\n return newPath;\n } catch (error) {\n console.error(`Could not rename note: ${error}`);\n return notePath;\n }\n}\n\n/**\n * Finalize session note (mark as complete, add summary, rename with meaningful name)\n * IDEMPOTENT: Will only finalize once, subsequent calls are no-ops\n * Returns the final path (may be renamed)\n */\nexport function finalizeSessionNote(notePath: string, summary: string): string {\n if (!existsSync(notePath)) {\n console.error(`Note file not found: ${notePath}`);\n return notePath;\n }\n\n let content = readFileSync(notePath, 'utf-8');\n\n // IDEMPOTENT CHECK: If already completed, don't modify again\n if (content.includes('**Status:** Completed')) {\n console.error(`Note already finalized: ${basename(notePath)}`);\n return notePath;\n }\n\n // Update status\n content = content.replace('**Status:** In Progress', '**Status:** Completed');\n\n // Add completion timestamp (only if not already present)\n if (!content.includes('**Completed:**')) {\n const completionTime = new Date().toISOString();\n content = content.replace(\n '---\\n\\n## Work Done',\n `**Completed:** ${completionTime}\\n\\n---\\n\\n## Work Done`\n );\n }\n\n // Add summary to Next Steps section (only if placeholder exists)\n const nextStepsMatch = content.match(/## Next Steps\\n\\n(<!-- .*? -->)/);\n if (nextStepsMatch) {\n content = content.replace(\n nextStepsMatch[0],\n `## Next Steps\\n\\n${summary || 'Session completed.'}`\n );\n }\n\n writeFileSync(notePath, content);\n console.error(`Session note finalized: ${basename(notePath)}`);\n\n // Extract meaningful name and rename the file\n const meaningfulName = extractMeaningfulName(content, summary);\n if (meaningfulName) {\n const newPath = renameSessionNote(notePath, meaningfulName);\n return newPath;\n }\n\n return notePath;\n}\n\n/**\n * Calculate total tokens from a session .jsonl file\n */\nexport function calculateSessionTokens(jsonlPath: string): number {\n if (!existsSync(jsonlPath)) {\n return 0;\n }\n\n try {\n const content = readFileSync(jsonlPath, 'utf-8');\n const lines = content.trim().split('\\n');\n let totalTokens = 0;\n\n for (const line of lines) {\n try {\n const entry = JSON.parse(line);\n if (entry.message?.usage) {\n const usage = entry.message.usage;\n totalTokens += (usage.input_tokens || 0);\n totalTokens += (usage.output_tokens || 0);\n totalTokens += (usage.cache_creation_input_tokens || 0);\n totalTokens += (usage.cache_read_input_tokens || 0);\n }\n } catch {\n // Skip invalid JSON lines\n }\n }\n\n return totalTokens;\n } catch (error) {\n console.error(`Error calculating tokens: ${error}`);\n return 0;\n }\n}\n\n/**\n * Find TODO.md - check local first, fallback to central\n */\nexport function findTodoPath(cwd: string): string {\n // Check local locations first\n const localPaths = [\n join(cwd, 'TODO.md'),\n join(cwd, 'notes', 'TODO.md'),\n join(cwd, 'Notes', 'TODO.md'),\n join(cwd, '.claude', 'TODO.md')\n ];\n\n for (const path of localPaths) {\n if (existsSync(path)) {\n return path;\n }\n }\n\n // Fallback to central location (inside Notes/)\n return join(getNotesDir(cwd), 'TODO.md');\n}\n\n/**\n * Find CLAUDE.md - check local locations\n * Returns the FIRST found path (for backwards compatibility)\n */\nexport function findClaudeMdPath(cwd: string): string | null {\n const paths = findAllClaudeMdPaths(cwd);\n return paths.length > 0 ? paths[0] : null;\n}\n\n/**\n * Find ALL CLAUDE.md files in local locations\n * Returns paths in priority order (most specific first):\n * 1. .claude/CLAUDE.md (project-specific config dir)\n * 2. CLAUDE.md (project root)\n * 3. Notes/CLAUDE.md (notes directory)\n * 4. Prompts/CLAUDE.md (prompts directory)\n *\n * All found files will be loaded and injected into context.\n */\nexport function findAllClaudeMdPaths(cwd: string): string[] {\n const foundPaths: string[] = [];\n\n // Priority order: most specific first\n const localPaths = [\n join(cwd, '.claude', 'CLAUDE.md'),\n join(cwd, 'CLAUDE.md'),\n join(cwd, 'Notes', 'CLAUDE.md'),\n join(cwd, 'notes', 'CLAUDE.md'),\n join(cwd, 'Prompts', 'CLAUDE.md'),\n join(cwd, 'prompts', 'CLAUDE.md')\n ];\n\n for (const path of localPaths) {\n if (existsSync(path)) {\n foundPaths.push(path);\n }\n }\n\n return foundPaths;\n}\n\n/**\n * Ensure TODO.md exists\n */\nexport function ensureTodoMd(cwd: string): string {\n const todoPath = findTodoPath(cwd);\n\n if (!existsSync(todoPath)) {\n // Ensure parent directory exists\n const parentDir = join(todoPath, '..');\n if (!existsSync(parentDir)) {\n mkdirSync(parentDir, { recursive: true });\n }\n\n const content = `# TODO\n\n## Current Session\n\n- [ ] (Tasks will be tracked here)\n\n## Backlog\n\n- [ ] (Future tasks)\n\n---\n\n*Last updated: ${new Date().toISOString()}*\n`;\n\n writeFileSync(todoPath, content);\n console.error(`Created TODO.md: ${todoPath}`);\n }\n\n return todoPath;\n}\n\n/**\n * Task item for TODO.md\n */\nexport interface TodoItem {\n content: string;\n completed: boolean;\n}\n\n/**\n * Update TODO.md with current session tasks\n * Preserves the Backlog section\n * Ensures only ONE timestamp line at the end\n */\nexport function updateTodoMd(cwd: string, tasks: TodoItem[], sessionSummary?: string): void {\n const todoPath = ensureTodoMd(cwd);\n const content = readFileSync(todoPath, 'utf-8');\n\n // Find Backlog section to preserve it (but strip any trailing timestamps/separators)\n const backlogMatch = content.match(/## Backlog[\\s\\S]*?(?=\\n---|\\n\\*Last updated|$)/);\n let backlogSection = backlogMatch ? backlogMatch[0].trim() : '## Backlog\\n\\n- [ ] (Future tasks)';\n\n // Format tasks\n const taskLines = tasks.length > 0\n ? tasks.map(t => `- [${t.completed ? 'x' : ' '}] ${t.content}`).join('\\n')\n : '- [ ] (No active tasks)';\n\n // Build new content with exactly ONE timestamp at the end\n const newContent = `# TODO\n\n## Current Session\n\n${taskLines}\n\n${sessionSummary ? `**Session Summary:** ${sessionSummary}\\n\\n` : ''}${backlogSection}\n\n---\n\n*Last updated: ${new Date().toISOString()}*\n`;\n\n writeFileSync(todoPath, newContent);\n console.error(`Updated TODO.md: ${todoPath}`);\n}\n\n/**\n * Add a checkpoint entry to TODO.md (without replacing tasks)\n * Ensures only ONE timestamp line at the end\n * Works regardless of TODO.md structure \u2014 appends if no known section found\n */\nexport function addTodoCheckpoint(cwd: string, checkpoint: string): void {\n const todoPath = ensureTodoMd(cwd);\n let content = readFileSync(todoPath, 'utf-8');\n\n // Remove ALL existing timestamp lines and trailing separators\n content = content.replace(/(\\n---\\s*)*(\\n\\*Last updated:.*\\*\\s*)+$/g, '');\n\n const checkpointText = `\\n**Checkpoint (${new Date().toISOString()}):** ${checkpoint}\\n\\n`;\n\n // Try to insert before Backlog section\n const backlogIndex = content.indexOf('## Backlog');\n if (backlogIndex !== -1) {\n content = content.substring(0, backlogIndex) + checkpointText + content.substring(backlogIndex);\n } else {\n // No Backlog section \u2014 try before Continue section, or just append\n const continueIndex = content.indexOf('## Continue');\n if (continueIndex !== -1) {\n // Insert after the Continue section (find the next ## or ---)\n const afterContinue = content.indexOf('\\n---', continueIndex);\n if (afterContinue !== -1) {\n const insertAt = afterContinue + 4; // after \\n---\n content = content.substring(0, insertAt) + '\\n' + checkpointText + content.substring(insertAt);\n } else {\n content = content.trimEnd() + '\\n' + checkpointText;\n }\n } else {\n // No known section \u2014 just append before the end\n content = content.trimEnd() + '\\n' + checkpointText;\n }\n }\n\n // Add exactly ONE timestamp at the end\n content = content.trimEnd() + `\\n\\n---\\n\\n*Last updated: ${new Date().toISOString()}*\\n`;\n\n writeFileSync(todoPath, content);\n console.error(`Checkpoint added to TODO.md`);\n}\n", "/**\n * PAI Path Resolution - Single Source of Truth\n *\n * This module provides consistent path resolution across all PAI hooks.\n * It handles PAI_DIR detection whether set explicitly or defaulting to ~/.claude\n *\n * ALSO loads .env file from PAI_DIR so all hooks get environment variables\n * without relying on Claude Code's settings.json injection.\n *\n * Usage in hooks:\n * import { PAI_DIR, HOOKS_DIR, SKILLS_DIR } from './lib/pai-paths';\n */\n\nimport { homedir } from 'os';\nimport { resolve, join } from 'path';\nimport { existsSync, readFileSync } from 'fs';\n\n/**\n * Load .env file and inject into process.env\n * Must run BEFORE PAI_DIR resolution so .env can set PAI_DIR if needed\n */\nfunction loadEnvFile(): void {\n // Check common locations for .env\n const possiblePaths = [\n resolve(process.env.PAI_DIR || '', '.env'),\n resolve(homedir(), '.claude', '.env'),\n ];\n\n for (const envPath of possiblePaths) {\n if (existsSync(envPath)) {\n try {\n const content = readFileSync(envPath, 'utf-8');\n for (const line of content.split('\\n')) {\n const trimmed = line.trim();\n // Skip comments and empty lines\n if (!trimmed || trimmed.startsWith('#')) continue;\n\n const eqIndex = trimmed.indexOf('=');\n if (eqIndex > 0) {\n const key = trimmed.substring(0, eqIndex).trim();\n let value = trimmed.substring(eqIndex + 1).trim();\n\n // Remove surrounding quotes if present\n if ((value.startsWith('\"') && value.endsWith('\"')) ||\n (value.startsWith(\"'\") && value.endsWith(\"'\"))) {\n value = value.slice(1, -1);\n }\n\n // Expand $HOME and ~ in values\n value = value.replace(/\\$HOME/g, homedir());\n value = value.replace(/^~(?=\\/|$)/, homedir());\n\n // Only set if not already defined (env vars take precedence)\n if (process.env[key] === undefined) {\n process.env[key] = value;\n }\n }\n }\n // Found and loaded, don't check other paths\n break;\n } catch {\n // Silently continue if .env can't be read\n }\n }\n }\n}\n\n// Load .env FIRST, before any other initialization\nloadEnvFile();\n\n/**\n * Smart PAI_DIR detection with fallback\n * Priority:\n * 1. PAI_DIR environment variable (if set)\n * 2. ~/.claude (standard location)\n */\nexport const PAI_DIR = process.env.PAI_DIR\n ? resolve(process.env.PAI_DIR)\n : resolve(homedir(), '.claude');\n\n/**\n * Common PAI directories\n */\nexport const HOOKS_DIR = join(PAI_DIR, 'Hooks');\nexport const SKILLS_DIR = join(PAI_DIR, 'Skills');\nexport const AGENTS_DIR = join(PAI_DIR, 'Agents');\nexport const HISTORY_DIR = join(PAI_DIR, 'History');\nexport const COMMANDS_DIR = join(PAI_DIR, 'Commands');\n\n/**\n * Validate PAI directory structure on first import\n * This fails fast with a clear error if PAI is misconfigured\n */\nfunction validatePAIStructure(): void {\n if (!existsSync(PAI_DIR)) {\n console.error(`PAI_DIR does not exist: ${PAI_DIR}`);\n console.error(` Expected ~/.claude or set PAI_DIR environment variable`);\n process.exit(1);\n }\n\n if (!existsSync(HOOKS_DIR)) {\n console.error(`PAI hooks directory not found: ${HOOKS_DIR}`);\n console.error(` Your PAI_DIR may be misconfigured`);\n console.error(` Current PAI_DIR: ${PAI_DIR}`);\n process.exit(1);\n }\n}\n\n// Run validation on module import\n// This ensures any hook that imports this module will fail fast if paths are wrong\nvalidatePAIStructure();\n\n/**\n * Helper to get history file path with date-based organization\n */\nexport function getHistoryFilePath(subdir: string, filename: string): string {\n const now = new Date();\n const tz = process.env.TIME_ZONE || Intl.DateTimeFormat().resolvedOptions().timeZone;\n const localDate = new Date(now.toLocaleString('en-US', { timeZone: tz }));\n const year = localDate.getFullYear();\n const month = String(localDate.getMonth() + 1).padStart(2, '0');\n\n return join(HISTORY_DIR, subdir, `${year}-${month}`, filename);\n}\n"],
4
+ "sourcesContent": ["#!/usr/bin/env node\n\n/**\n * load-project-context.ts\n *\n * SessionStart hook that sets up project context:\n * - Checks for CLAUDE.md in various locations (Claude Code handles loading)\n * - Sets up Notes/ directory in ~/.claude/projects/{encoded-path}/\n * - Ensures TODO.md exists\n * - Sends ntfy.sh notification (mandatory)\n * - Displays session continuity info (like session-init.sh)\n *\n * This hook complements Claude Code's native CLAUDE.md loading by:\n * - Setting up the Notes infrastructure\n * - Showing the latest session note for continuity\n * - Sending ntfy.sh notifications\n */\n\nimport { existsSync, readdirSync, readFileSync, statSync } from 'fs';\nimport { join, basename, dirname } from 'path';\nimport { execSync } from 'child_process';\nimport {\n PAI_DIR,\n findNotesDir,\n getProjectDir,\n getCurrentNotePath,\n createSessionNote,\n findTodoPath,\n findAllClaudeMdPaths,\n sendNtfyNotification\n} from '../lib/project-utils';\n\n/**\n * Find the pai CLI binary path dynamically.\n * Tries `which pai` first, then common fallback locations.\n */\nfunction findPaiBinary(): string {\n try {\n return execSync('which pai', { encoding: 'utf-8' }).trim();\n } catch {\n // Fallback locations in order of preference\n const fallbacks = [\n '/usr/local/bin/pai',\n '/opt/homebrew/bin/pai',\n `${process.env.HOME}/.local/bin/pai`,\n ];\n for (const p of fallbacks) {\n if (existsSync(p)) return p;\n }\n }\n return 'pai'; // Last resort: rely on PATH at runtime\n}\n\n/**\n * Check session-routing.json for an active route.\n * Returns the routed Notes path if set, or null to use default behavior.\n */\nfunction getRoutedNotesPath(): string | null {\n const routingFile = join(PAI_DIR, 'session-routing.json');\n if (!existsSync(routingFile)) return null;\n\n try {\n const routing = JSON.parse(readFileSync(routingFile, 'utf-8'));\n const active = routing?.active_session;\n if (active?.notes_path) {\n return active.notes_path;\n }\n } catch {\n // Ignore parse errors\n }\n return null;\n}\n\ninterface HookInput {\n session_id: string;\n cwd: string;\n hook_event_name: string;\n}\n\nasync function main() {\n console.error('\\nload-project-context.ts starting...');\n\n // Read hook input from stdin\n let hookInput: HookInput | null = null;\n try {\n const chunks: Buffer[] = [];\n for await (const chunk of process.stdin) {\n chunks.push(chunk);\n }\n const input = Buffer.concat(chunks).toString('utf-8');\n if (input.trim()) {\n hookInput = JSON.parse(input);\n }\n } catch (error) {\n console.error('Could not parse hook input, using process.cwd()');\n }\n\n // Get current working directory\n const cwd = hookInput?.cwd || process.cwd();\n\n // Determine meaningful project name\n // If cwd is a Notes directory, use parent directory name instead\n let projectName = basename(cwd);\n if (projectName.toLowerCase() === 'notes') {\n projectName = basename(dirname(cwd));\n }\n\n console.error(`Working directory: ${cwd}`);\n console.error(`Project: ${projectName}`);\n\n // Check if this is a subagent session - skip for subagents\n const isSubagent = process.env.CLAUDE_AGENT_TYPE !== undefined ||\n (process.env.CLAUDE_PROJECT_DIR || '').includes('/.claude/agents/');\n\n if (isSubagent) {\n console.error('Subagent session - skipping project context setup');\n process.exit(0);\n }\n\n // 1. Find and READ all CLAUDE.md files - inject them into context\n // This ensures Claude actually processes the instructions, not just sees them in headers\n const claudeMdPaths = findAllClaudeMdPaths(cwd);\n const claudeMdContents: { path: string; content: string }[] = [];\n\n if (claudeMdPaths.length > 0) {\n console.error(`Found ${claudeMdPaths.length} CLAUDE.md file(s):`);\n for (const path of claudeMdPaths) {\n console.error(` - ${path}`);\n try {\n const content = readFileSync(path, 'utf-8');\n claudeMdContents.push({ path, content });\n console.error(` Read ${content.length} chars`);\n } catch (error) {\n console.error(` Could not read: ${error}`);\n }\n }\n } else {\n console.error('No CLAUDE.md found in project');\n console.error(' Consider creating one at ./CLAUDE.md or ./.claude/CLAUDE.md');\n }\n\n // 2. Find or create Notes directory\n // Priority:\n // 1. Active session routing (pai route <project>) \u2192 routed Obsidian path\n // 2. Local Notes/ in cwd \u2192 use it (git-trackable, e.g. symlink to Obsidian)\n // 3. Central ~/.claude/projects/.../Notes/ \u2192 fallback\n const routedPath = getRoutedNotesPath();\n let notesDir: string;\n\n if (routedPath) {\n // Routing is active - use the configured Obsidian Notes path\n const { mkdirSync } = await import('fs');\n if (!existsSync(routedPath)) {\n mkdirSync(routedPath, { recursive: true });\n console.error(`Created routed Notes: ${routedPath}`);\n } else {\n console.error(`Notes directory: ${routedPath} (routed via pai route)`);\n }\n notesDir = routedPath;\n } else {\n const notesInfo = findNotesDir(cwd);\n\n if (notesInfo.isLocal) {\n notesDir = notesInfo.path;\n console.error(`Notes directory: ${notesDir} (local)`);\n } else {\n // Create central Notes directory\n if (!existsSync(notesInfo.path)) {\n const { mkdirSync } = await import('fs');\n mkdirSync(notesInfo.path, { recursive: true });\n console.error(`Created central Notes: ${notesInfo.path}`);\n } else {\n console.error(`Notes directory: ${notesInfo.path} (central)`);\n }\n notesDir = notesInfo.path;\n }\n }\n\n // 3. Cleanup old .jsonl files from project root (move to sessions/)\n // Keep the newest one for potential resume, move older ones to sessions/\n const projectDir = getProjectDir(cwd);\n if (existsSync(projectDir)) {\n try {\n const files = readdirSync(projectDir);\n const jsonlFiles = files\n .filter(f => f.endsWith('.jsonl'))\n .map(f => ({\n name: f,\n path: join(projectDir, f),\n mtime: statSync(join(projectDir, f)).mtime.getTime()\n }))\n .sort((a, b) => b.mtime - a.mtime); // newest first\n\n if (jsonlFiles.length > 1) {\n const { mkdirSync, renameSync } = await import('fs');\n const sessionsDir = join(projectDir, 'sessions');\n if (!existsSync(sessionsDir)) {\n mkdirSync(sessionsDir, { recursive: true });\n }\n\n // Move all except the newest\n for (let i = 1; i < jsonlFiles.length; i++) {\n const file = jsonlFiles[i];\n const destPath = join(sessionsDir, file.name);\n if (!existsSync(destPath)) {\n renameSync(file.path, destPath);\n console.error(`Moved old session: ${file.name} \u2192 sessions/`);\n }\n }\n }\n } catch (error) {\n console.error(`Could not cleanup old .jsonl files: ${error}`);\n }\n }\n\n // 4. Find or create TODO.md\n const todoPath = findTodoPath(cwd);\n const hasTodo = existsSync(todoPath);\n if (hasTodo) {\n console.error(`TODO.md: ${todoPath}`);\n } else {\n // Create TODO.md in the Notes directory\n const newTodoPath = join(notesDir, 'TODO.md');\n const { writeFileSync } = await import('fs');\n writeFileSync(newTodoPath, `# TODO\\n\\n## Offen\\n\\n- [ ] \\n\\n---\\n\\n*Created: ${new Date().toISOString()}*\\n`);\n console.error(`Created TODO.md: ${newTodoPath}`);\n }\n\n // 5. Check for existing note or create new one\n let activeNotePath: string | null = null;\n\n if (notesDir) { // notesDir is always set now (local or central)\n const currentNotePath = getCurrentNotePath(notesDir);\n\n // Determine if we need a new note\n let needsNewNote = false;\n if (!currentNotePath) {\n needsNewNote = true;\n console.error('\\nNo previous session notes found - creating new one');\n } else {\n // Check if the existing note is completed\n try {\n const content = readFileSync(currentNotePath, 'utf-8');\n if (content.includes('**Status:** Completed') || content.includes('**Completed:**')) {\n needsNewNote = true;\n console.error(`\\nPrevious note completed - creating new one`);\n const summaryMatch = content.match(/## Next Steps\\n\\n([^\\n]+)/);\n if (summaryMatch) {\n console.error(` Previous: ${summaryMatch[1].substring(0, 60)}...`);\n }\n } else {\n console.error(`\\nContinuing session note: ${basename(currentNotePath)}`);\n }\n } catch {\n needsNewNote = true;\n }\n }\n\n // Create new note if needed\n if (needsNewNote) {\n activeNotePath = createSessionNote(notesDir, projectName);\n console.error(`Created: ${basename(activeNotePath)}`);\n } else {\n activeNotePath = currentNotePath!;\n // Show preview of current note\n try {\n const content = readFileSync(activeNotePath, 'utf-8');\n const lines = content.split('\\n').slice(0, 12);\n console.error('--- Current Note Preview ---');\n for (const line of lines) {\n console.error(line);\n }\n console.error('--- End Preview ---\\n');\n } catch {\n // Ignore read errors\n }\n }\n }\n\n // 6. Show TODO.md preview\n if (existsSync(todoPath)) {\n try {\n const todoContent = readFileSync(todoPath, 'utf-8');\n const todoLines = todoContent.split('\\n').filter(l => l.includes('[ ]')).slice(0, 5);\n if (todoLines.length > 0) {\n console.error('\\nOpen TODOs:');\n for (const line of todoLines) {\n console.error(` ${line.trim()}`);\n }\n }\n } catch {\n // Ignore read errors\n }\n }\n\n // 7. Send ntfy.sh notification (MANDATORY)\n await sendNtfyNotification(`Session started in ${projectName}`);\n\n // 7.5. Run pai project detect to identify the registered PAI project\n const paiBin = findPaiBinary();\n let paiProjectBlock = '';\n try {\n const { execFileSync } = await import('child_process');\n const raw = execFileSync(paiBin, ['project', 'detect', '--json', cwd], {\n encoding: 'utf-8',\n env: process.env,\n }).trim();\n\n if (raw) {\n const detected = JSON.parse(raw) as {\n slug?: string;\n display_name?: string;\n root_path?: string;\n match_type?: string;\n relative_path?: string | null;\n session_count?: number;\n status?: string;\n error?: string;\n cwd?: string;\n };\n\n if (detected.error === 'no_match') {\n paiProjectBlock = `PAI Project Registry: No registered project matches this directory.\nRun \"pai project add .\" to register this project, or use /route to tag the session.`;\n console.error('PAI detect: no match for', cwd);\n } else if (detected.slug) {\n const name = detected.display_name || detected.slug;\n const nameSlug = ` (slug: ${detected.slug})`;\n const matchDesc = detected.match_type === 'exact'\n ? 'exact'\n : `parent (+${detected.relative_path ?? ''})`;\n const statusFlag = detected.status && detected.status !== 'active'\n ? ` [${detected.status.toUpperCase()}]`\n : '';\n paiProjectBlock = `PAI Project Registry: ${name}${statusFlag}${nameSlug}\nMatch: ${matchDesc} | Sessions: ${detected.session_count ?? 0}${detected.status && detected.status !== 'active' ? `\\nWARNING: Project status is \"${detected.status}\". Run: pai project health --fix` : ''}`;\n console.error(`PAI detect: matched \"${detected.slug}\" (${detected.match_type})`);\n }\n }\n } catch (e) {\n // Non-fatal \u2014 don't break session start if pai is unavailable\n console.error('pai project detect failed:', e);\n }\n\n // 8. Output system reminder with session info\n const reminder = `\n<system-reminder>\nPROJECT CONTEXT LOADED\n\nProject: ${projectName}\nWorking Directory: ${cwd}\n${notesDir ? `Notes Directory: ${notesDir}${routedPath ? ' (routed via pai route)' : ''}` : 'Notes: disabled (no local Notes/ directory)'}\n${hasTodo ? `TODO: ${todoPath}` : 'TODO: not found'}\n${claudeMdPaths.length > 0 ? `CLAUDE.md: ${claudeMdPaths.join(', ')}` : 'No CLAUDE.md found'}\n${activeNotePath ? `Active Note: ${basename(activeNotePath)}` : ''}\n${routedPath ? `\\nNote Routing: ACTIVE (pai route is set - notes go to Obsidian vault)` : ''}\n${paiProjectBlock ? `\\n${paiProjectBlock}` : ''}\nSession Commands:\n- \"pause session\" \u2192 Save checkpoint, update TODO, exit (no compact)\n- \"end session\" \u2192 Finalize note, commit if needed, start fresh next time\n- \"pai route clear\" \u2192 Clear note routing (in a new session)\n</system-reminder>\n`;\n\n // Output to stdout for Claude to receive\n console.log(reminder);\n\n // 9. INJECT CLAUDE.md contents as system-reminders\n // This ensures Claude actually reads and processes the instructions\n for (const { path, content } of claudeMdContents) {\n const claudeMdReminder = `\n<system-reminder>\nLOCAL CLAUDE.md LOADED (MANDATORY - READ AND FOLLOW)\n\nSource: ${path}\n\n${content}\n\n---\nTHE ABOVE INSTRUCTIONS ARE MANDATORY. Follow them exactly.\n</system-reminder>\n`;\n console.log(claudeMdReminder);\n console.error(`Injected CLAUDE.md content from: ${path}`);\n }\n\n console.error('\\nProject context setup complete\\n');\n process.exit(0);\n}\n\nmain().catch(error => {\n console.error('load-project-context.ts error:', error);\n process.exit(0); // Don't block session start\n});\n", "/**\n * project-utils.ts - Shared utilities for project context management\n *\n * Provides:\n * - Path encoding (matching Claude Code's scheme)\n * - ntfy.sh notifications (mandatory, synchronous)\n * - Session notes management\n * - Session token calculation\n */\n\nimport { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync } from 'fs';\nimport { join, basename } from 'path';\n\n// Import from pai-paths which handles .env loading and path resolution\nimport { PAI_DIR } from './pai-paths.js';\n\n// Re-export PAI_DIR for consumers\nexport { PAI_DIR };\nexport const PROJECTS_DIR = join(PAI_DIR, 'projects');\n\n/**\n * Encode a path the same way Claude Code does:\n * - Replace / with -\n * - Replace . with - (hidden directories become --name)\n *\n * This matches Claude Code's internal encoding to ensure Notes\n * are stored in the same project directory as transcripts.\n */\nexport function encodePath(path: string): string {\n return path\n .replace(/\\//g, '-') // Slashes become dashes\n .replace(/\\./g, '-') // Dots also become dashes\n .replace(/ /g, '-'); // Spaces become dashes (matches Claude Code native encoding)\n}\n\n/**\n * Get the project directory for a given working directory\n */\nexport function getProjectDir(cwd: string): string {\n const encoded = encodePath(cwd);\n return join(PROJECTS_DIR, encoded);\n}\n\n/**\n * Get the Notes directory for a project (central location)\n */\nexport function getNotesDir(cwd: string): string {\n return join(getProjectDir(cwd), 'Notes');\n}\n\n/**\n * Find Notes directory - check local first, fallback to central\n * DOES NOT create the directory - just finds the right location\n *\n * Logic:\n * - If cwd itself IS a Notes directory \u2192 use it directly\n * - If local Notes/ exists \u2192 use it (can be checked into git)\n * - Otherwise \u2192 use central ~/.claude/projects/.../Notes/\n */\nexport function findNotesDir(cwd: string): { path: string; isLocal: boolean } {\n // FIRST: Check if cwd itself IS a Notes directory\n const cwdBasename = basename(cwd).toLowerCase();\n if (cwdBasename === 'notes' && existsSync(cwd)) {\n return { path: cwd, isLocal: true };\n }\n\n // Check local locations\n const localPaths = [\n join(cwd, 'Notes'),\n join(cwd, 'notes'),\n join(cwd, '.claude', 'Notes')\n ];\n\n for (const path of localPaths) {\n if (existsSync(path)) {\n return { path, isLocal: true };\n }\n }\n\n // Fallback to central location\n return { path: getNotesDir(cwd), isLocal: false };\n}\n\n/**\n * Get the Sessions directory for a project (stores .jsonl transcripts)\n */\nexport function getSessionsDir(cwd: string): string {\n return join(getProjectDir(cwd), 'sessions');\n}\n\n/**\n * Get the Sessions directory from a project directory path\n */\nexport function getSessionsDirFromProjectDir(projectDir: string): string {\n return join(projectDir, 'sessions');\n}\n\n/**\n * Check if WhatsApp (Whazaa) is configured as an enabled MCP server.\n *\n * Uses standard Claude Code config at ~/.claude/settings.json.\n * No PAI dependency \u2014 works for any Claude Code user with whazaa installed.\n */\nexport function isWhatsAppEnabled(): boolean {\n try {\n const { homedir } = require('os');\n const settingsPath = join(homedir(), '.claude', 'settings.json');\n if (!existsSync(settingsPath)) return false;\n\n const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));\n const enabled: string[] = settings.enabledMcpjsonServers || [];\n return enabled.includes('whazaa');\n } catch {\n return false;\n }\n}\n\n/**\n * Send push notification \u2014 WhatsApp-aware with ntfy fallback.\n *\n * When WhatsApp (Whazaa) is enabled in MCP config, ntfy is SKIPPED\n * because the AI sends WhatsApp messages directly via MCP. Sending both\n * would cause duplicate notifications.\n *\n * When WhatsApp is NOT configured, ntfy fires as the fallback channel.\n */\nexport async function sendNtfyNotification(message: string, retries = 2): Promise<boolean> {\n // Skip ntfy when WhatsApp is configured \u2014 the AI handles notifications via MCP\n if (isWhatsAppEnabled()) {\n console.error(`WhatsApp (Whazaa) enabled in MCP config \u2014 skipping ntfy`);\n return true;\n }\n\n const topic = process.env.NTFY_TOPIC;\n\n if (!topic) {\n console.error('NTFY_TOPIC not set and WhatsApp not active \u2014 notifications disabled');\n return false;\n }\n\n for (let attempt = 0; attempt <= retries; attempt++) {\n try {\n const response = await fetch(`https://ntfy.sh/${topic}`, {\n method: 'POST',\n body: message,\n headers: {\n 'Title': 'Claude Code',\n 'Priority': 'default'\n }\n });\n\n if (response.ok) {\n console.error(`ntfy.sh notification sent (WhatsApp inactive): \"${message}\"`);\n return true;\n } else {\n console.error(`ntfy.sh attempt ${attempt + 1} failed: ${response.status}`);\n }\n } catch (error) {\n console.error(`ntfy.sh attempt ${attempt + 1} error: ${error}`);\n }\n\n // Wait before retry\n if (attempt < retries) {\n await new Promise(resolve => setTimeout(resolve, 1000));\n }\n }\n\n console.error('ntfy.sh notification failed after all retries');\n return false;\n}\n\n/**\n * Ensure the Notes directory exists for a project\n * DEPRECATED: Use ensureNotesDirSmart() instead\n */\nexport function ensureNotesDir(cwd: string): string {\n const notesDir = getNotesDir(cwd);\n\n if (!existsSync(notesDir)) {\n mkdirSync(notesDir, { recursive: true });\n console.error(`Created Notes directory: ${notesDir}`);\n }\n\n return notesDir;\n}\n\n/**\n * Smart Notes directory handling:\n * - If local Notes/ exists \u2192 use it (don't create anything new)\n * - If no local Notes/ \u2192 ensure central exists and use that\n *\n * This respects the user's choice:\n * - Projects with local Notes/ keep notes there (git-trackable)\n * - Other directories don't get cluttered with auto-created Notes/\n */\nexport function ensureNotesDirSmart(cwd: string): { path: string; isLocal: boolean } {\n const found = findNotesDir(cwd);\n\n if (found.isLocal) {\n // Local Notes/ exists - use it as-is\n return found;\n }\n\n // No local Notes/ - ensure central exists\n if (!existsSync(found.path)) {\n mkdirSync(found.path, { recursive: true });\n console.error(`Created central Notes directory: ${found.path}`);\n }\n\n return found;\n}\n\n/**\n * Ensure the Sessions directory exists for a project\n */\nexport function ensureSessionsDir(cwd: string): string {\n const sessionsDir = getSessionsDir(cwd);\n\n if (!existsSync(sessionsDir)) {\n mkdirSync(sessionsDir, { recursive: true });\n console.error(`Created sessions directory: ${sessionsDir}`);\n }\n\n return sessionsDir;\n}\n\n/**\n * Ensure the Sessions directory exists (from project dir path)\n */\nexport function ensureSessionsDirFromProjectDir(projectDir: string): string {\n const sessionsDir = getSessionsDirFromProjectDir(projectDir);\n\n if (!existsSync(sessionsDir)) {\n mkdirSync(sessionsDir, { recursive: true });\n console.error(`Created sessions directory: ${sessionsDir}`);\n }\n\n return sessionsDir;\n}\n\n/**\n * Move all .jsonl session files from project root to sessions/ subdirectory\n * @param projectDir - The project directory path\n * @param excludeFile - Optional filename to exclude (e.g., current active session)\n * @param silent - If true, suppress console output\n * Returns the number of files moved\n */\nexport function moveSessionFilesToSessionsDir(\n projectDir: string,\n excludeFile?: string,\n silent: boolean = false\n): number {\n const sessionsDir = ensureSessionsDirFromProjectDir(projectDir);\n\n if (!existsSync(projectDir)) {\n return 0;\n }\n\n const files = readdirSync(projectDir);\n let movedCount = 0;\n\n for (const file of files) {\n // Match session files: uuid.jsonl or agent-*.jsonl\n // Skip the excluded file (typically the current active session)\n if (file.endsWith('.jsonl') && file !== excludeFile) {\n const sourcePath = join(projectDir, file);\n const destPath = join(sessionsDir, file);\n\n try {\n renameSync(sourcePath, destPath);\n if (!silent) {\n console.error(`Moved ${file} \u2192 sessions/`);\n }\n movedCount++;\n } catch (error) {\n if (!silent) {\n console.error(`Could not move ${file}: ${error}`);\n }\n }\n }\n }\n\n return movedCount;\n}\n\n/**\n * Get the YYYY/MM subdirectory for the current month inside notesDir.\n * Creates the directory if it doesn't exist.\n */\nfunction getMonthDir(notesDir: string): string {\n const now = new Date();\n const year = String(now.getFullYear());\n const month = String(now.getMonth() + 1).padStart(2, '0');\n const monthDir = join(notesDir, year, month);\n if (!existsSync(monthDir)) {\n mkdirSync(monthDir, { recursive: true });\n }\n return monthDir;\n}\n\n/**\n * Get the next note number (4-digit format: 0001, 0002, etc.)\n * ALWAYS uses 4-digit format with space-dash-space separators\n * Format: NNNN - YYYY-MM-DD - Description.md\n * Numbers reset per month (each YYYY/MM directory has its own sequence).\n */\nexport function getNextNoteNumber(notesDir: string): string {\n const monthDir = getMonthDir(notesDir);\n\n // Match CORRECT format: \"0001 - \" (4-digit with space-dash-space)\n // Also match legacy formats for backwards compatibility when detecting max number\n const files = readdirSync(monthDir)\n .filter(f => f.match(/^\\d{3,4}[\\s_-]/)) // Starts with 3-4 digits followed by separator\n .filter(f => f.endsWith('.md'))\n .sort();\n\n if (files.length === 0) {\n return '0001'; // Default to 4-digit\n }\n\n // Find the highest number across all formats\n let maxNumber = 0;\n for (const file of files) {\n const digitMatch = file.match(/^(\\d+)/);\n if (digitMatch) {\n const num = parseInt(digitMatch[1], 10);\n if (num > maxNumber) maxNumber = num;\n }\n }\n\n // ALWAYS return 4-digit format\n return String(maxNumber + 1).padStart(4, '0');\n}\n\n/**\n * Get the current (latest) note file path, or null if none exists.\n * Searches in the current month's YYYY/MM subdirectory first,\n * then falls back to previous month (for sessions spanning month boundaries),\n * then falls back to flat notesDir for legacy notes.\n * Supports multiple formats for backwards compatibility:\n * - CORRECT: \"0001 - YYYY-MM-DD - Description.md\" (space-dash-space)\n * - Legacy: \"001_YYYY-MM-DD_description.md\" (underscores)\n */\nexport function getCurrentNotePath(notesDir: string): string | null {\n if (!existsSync(notesDir)) {\n return null;\n }\n\n // Helper: find latest session note in a directory\n const findLatestIn = (dir: string): string | null => {\n if (!existsSync(dir)) return null;\n const files = readdirSync(dir)\n .filter(f => f.match(/^\\d{3,4}[\\s_-].*\\.md$/))\n .sort((a, b) => {\n const numA = parseInt(a.match(/^(\\d+)/)?.[1] || '0', 10);\n const numB = parseInt(b.match(/^(\\d+)/)?.[1] || '0', 10);\n return numA - numB;\n });\n if (files.length === 0) return null;\n return join(dir, files[files.length - 1]);\n };\n\n // 1. Check current month's YYYY/MM directory\n const now = new Date();\n const year = String(now.getFullYear());\n const month = String(now.getMonth() + 1).padStart(2, '0');\n const currentMonthDir = join(notesDir, year, month);\n const found = findLatestIn(currentMonthDir);\n if (found) return found;\n\n // 2. Check previous month (for sessions spanning month boundaries)\n const prevDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);\n const prevYear = String(prevDate.getFullYear());\n const prevMonth = String(prevDate.getMonth() + 1).padStart(2, '0');\n const prevMonthDir = join(notesDir, prevYear, prevMonth);\n const prevFound = findLatestIn(prevMonthDir);\n if (prevFound) return prevFound;\n\n // 3. Fallback: check flat notesDir (legacy notes not yet filed)\n return findLatestIn(notesDir);\n}\n\n/**\n * Create a new session note\n * CORRECT FORMAT: \"NNNN - YYYY-MM-DD - Description.md\"\n * - 4-digit zero-padded number\n * - Space-dash-space separators (NOT underscores)\n * - Title case description\n *\n * IMPORTANT: The initial description is just a PLACEHOLDER.\n * Claude MUST rename the file at session end with a meaningful description\n * based on the actual work done. Never leave it as \"New Session\" or project name.\n */\nexport function createSessionNote(notesDir: string, description: string): string {\n const noteNumber = getNextNoteNumber(notesDir);\n const date = new Date().toISOString().split('T')[0]; // YYYY-MM-DD\n\n // Use \"New Session\" as placeholder - Claude MUST rename at session end!\n // The project name alone is NOT descriptive enough.\n const safeDescription = 'New Session';\n\n // CORRECT FORMAT: space-dash-space separators, filed into YYYY/MM subdirectory\n const monthDir = getMonthDir(notesDir);\n const filename = `${noteNumber} - ${date} - ${safeDescription}.md`;\n const filepath = join(monthDir, filename);\n\n const content = `# Session ${noteNumber}: ${description}\n\n**Date:** ${date}\n**Status:** In Progress\n\n---\n\n## Work Done\n\n<!-- PAI will add completed work here during session -->\n\n---\n\n## Next Steps\n\n<!-- To be filled at session end -->\n\n---\n\n**Tags:** #Session\n`;\n\n writeFileSync(filepath, content);\n console.error(`Created session note: ${filename}`);\n\n return filepath;\n}\n\n/**\n * Append checkpoint to current session note\n */\nexport function appendCheckpoint(notePath: string, checkpoint: string): void {\n if (!existsSync(notePath)) {\n // Note vanished (cloud sync, cleanup, etc.) \u2014 recreate it\n console.error(`Note file not found, recreating: ${notePath}`);\n try {\n const parentDir = join(notePath, '..');\n if (!existsSync(parentDir)) {\n mkdirSync(parentDir, { recursive: true });\n }\n const noteFilename = basename(notePath);\n const numberMatch = noteFilename.match(/^(\\d+)/);\n const noteNumber = numberMatch ? numberMatch[1] : '0000';\n const date = new Date().toISOString().split('T')[0];\n const content = `# Session ${noteNumber}: Recovered\\n\\n**Date:** ${date}\\n**Status:** In Progress\\n\\n---\\n\\n## Work Done\\n\\n<!-- PAI will add completed work here during session -->\\n\\n---\\n\\n## Next Steps\\n\\n<!-- To be filled at session end -->\\n\\n---\\n\\n**Tags:** #Session\\n`;\n writeFileSync(notePath, content);\n console.error(`Recreated session note: ${noteFilename}`);\n } catch (err) {\n console.error(`Failed to recreate note: ${err}`);\n return;\n }\n }\n\n const content = readFileSync(notePath, 'utf-8');\n const timestamp = new Date().toISOString();\n const checkpointText = `\\n### Checkpoint ${timestamp}\\n\\n${checkpoint}\\n`;\n\n // Insert before \"## Next Steps\" if it exists, otherwise append\n const nextStepsIndex = content.indexOf('## Next Steps');\n let newContent: string;\n\n if (nextStepsIndex !== -1) {\n newContent = content.substring(0, nextStepsIndex) + checkpointText + content.substring(nextStepsIndex);\n } else {\n newContent = content + checkpointText;\n }\n\n writeFileSync(notePath, newContent);\n console.error(`Checkpoint added to: ${basename(notePath)}`);\n}\n\n/**\n * Work item for session notes\n */\nexport interface WorkItem {\n title: string;\n details?: string[];\n completed?: boolean;\n}\n\n/**\n * Add work items to the \"Work Done\" section of a session note\n * This is the main way to capture what was accomplished in a session\n */\nexport function addWorkToSessionNote(notePath: string, workItems: WorkItem[], sectionTitle?: string): void {\n if (!existsSync(notePath)) {\n console.error(`Note file not found: ${notePath}`);\n return;\n }\n\n let content = readFileSync(notePath, 'utf-8');\n\n // Build the work section content\n let workText = '';\n if (sectionTitle) {\n workText += `\\n### ${sectionTitle}\\n\\n`;\n }\n\n for (const item of workItems) {\n const checkbox = item.completed !== false ? '[x]' : '[ ]';\n workText += `- ${checkbox} **${item.title}**\\n`;\n if (item.details && item.details.length > 0) {\n for (const detail of item.details) {\n workText += ` - ${detail}\\n`;\n }\n }\n }\n\n // Find the Work Done section and insert after the comment/placeholder\n const workDoneMatch = content.match(/## Work Done\\n\\n(<!-- .*? -->)?/);\n if (workDoneMatch) {\n const insertPoint = content.indexOf(workDoneMatch[0]) + workDoneMatch[0].length;\n content = content.substring(0, insertPoint) + workText + content.substring(insertPoint);\n } else {\n // Fallback: insert before Next Steps\n const nextStepsIndex = content.indexOf('## Next Steps');\n if (nextStepsIndex !== -1) {\n content = content.substring(0, nextStepsIndex) + workText + '\\n' + content.substring(nextStepsIndex);\n }\n }\n\n writeFileSync(notePath, content);\n console.error(`Added ${workItems.length} work item(s) to: ${basename(notePath)}`);\n}\n\n/**\n * Update the session note title to be more descriptive\n * Called when we know what work was done\n */\nexport function updateSessionNoteTitle(notePath: string, newTitle: string): void {\n if (!existsSync(notePath)) {\n console.error(`Note file not found: ${notePath}`);\n return;\n }\n\n let content = readFileSync(notePath, 'utf-8');\n\n // Update the H1 title\n content = content.replace(/^# Session \\d+:.*$/m, (match) => {\n const sessionNum = match.match(/Session (\\d+)/)?.[1] || '';\n return `# Session ${sessionNum}: ${newTitle}`;\n });\n\n writeFileSync(notePath, content);\n\n // Also rename the file\n renameSessionNote(notePath, sanitizeForFilename(newTitle));\n}\n\n/**\n * Sanitize a string for use in a filename (exported for use elsewhere)\n */\nexport function sanitizeForFilename(str: string): string {\n return str\n .toLowerCase()\n .replace(/[^a-z0-9\\s-]/g, '') // Remove special chars\n .replace(/\\s+/g, '-') // Spaces to hyphens\n .replace(/-+/g, '-') // Collapse multiple hyphens\n .replace(/^-|-$/g, '') // Trim hyphens\n .substring(0, 50); // Limit length\n}\n\n/**\n * Extract a meaningful name from session note content\n * Looks at Work Done section and summary to generate a descriptive name\n */\nexport function extractMeaningfulName(noteContent: string, summary: string): string {\n // Try to extract from Work Done section headers (### headings)\n const workDoneMatch = noteContent.match(/## Work Done\\n\\n([\\s\\S]*?)(?=\\n---|\\n## Next)/);\n\n if (workDoneMatch) {\n const workDoneSection = workDoneMatch[1];\n\n // Look for ### subheadings which typically describe what was done\n const subheadings = workDoneSection.match(/### ([^\\n]+)/g);\n if (subheadings && subheadings.length > 0) {\n // Use the first subheading, clean it up\n const firstHeading = subheadings[0].replace('### ', '').trim();\n if (firstHeading.length > 5 && firstHeading.length < 60) {\n return sanitizeForFilename(firstHeading);\n }\n }\n\n // Look for bold text which often indicates key topics\n const boldMatches = workDoneSection.match(/\\*\\*([^*]+)\\*\\*/g);\n if (boldMatches && boldMatches.length > 0) {\n const firstBold = boldMatches[0].replace(/\\*\\*/g, '').trim();\n if (firstBold.length > 3 && firstBold.length < 50) {\n return sanitizeForFilename(firstBold);\n }\n }\n\n // Look for numbered list items (1. Something)\n const numberedItems = workDoneSection.match(/^\\d+\\.\\s+\\*\\*([^*]+)\\*\\*/m);\n if (numberedItems) {\n return sanitizeForFilename(numberedItems[1]);\n }\n }\n\n // Fall back to summary if provided\n if (summary && summary.length > 5 && summary !== 'Session completed.') {\n // Take first meaningful phrase from summary\n const cleanSummary = summary\n .replace(/[^\\w\\s-]/g, ' ')\n .trim()\n .split(/\\s+/)\n .slice(0, 5)\n .join(' ');\n\n if (cleanSummary.length > 3) {\n return sanitizeForFilename(cleanSummary);\n }\n }\n\n return '';\n}\n\n/**\n * Rename session note with a meaningful name\n * ALWAYS uses correct format: \"NNNN - YYYY-MM-DD - Description.md\"\n * Returns the new path, or original path if rename fails\n */\nexport function renameSessionNote(notePath: string, meaningfulName: string): string {\n if (!meaningfulName || !existsSync(notePath)) {\n return notePath;\n }\n\n const dir = join(notePath, '..');\n const oldFilename = basename(notePath);\n\n // Parse existing filename - support multiple formats:\n // CORRECT: \"0001 - 2026-01-02 - Description.md\"\n // Legacy: \"001_2026-01-02_description.md\"\n const correctMatch = oldFilename.match(/^(\\d{3,4}) - (\\d{4}-\\d{2}-\\d{2}) - .*\\.md$/);\n const legacyMatch = oldFilename.match(/^(\\d{3,4})_(\\d{4}-\\d{2}-\\d{2})_.*\\.md$/);\n\n const match = correctMatch || legacyMatch;\n if (!match) {\n return notePath; // Can't parse, don't rename\n }\n\n const [, noteNumber, date] = match;\n\n // Convert to Title Case\n const titleCaseName = meaningfulName\n .split(/[\\s_-]+/)\n .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())\n .join(' ')\n .trim();\n\n // ALWAYS use correct format with 4-digit number\n const paddedNumber = noteNumber.padStart(4, '0');\n const newFilename = `${paddedNumber} - ${date} - ${titleCaseName}.md`;\n const newPath = join(dir, newFilename);\n\n // Don't rename if name is the same\n if (newFilename === oldFilename) {\n return notePath;\n }\n\n try {\n renameSync(notePath, newPath);\n console.error(`Renamed note: ${oldFilename} \u2192 ${newFilename}`);\n return newPath;\n } catch (error) {\n console.error(`Could not rename note: ${error}`);\n return notePath;\n }\n}\n\n/**\n * Finalize session note (mark as complete, add summary, rename with meaningful name)\n * IDEMPOTENT: Will only finalize once, subsequent calls are no-ops\n * Returns the final path (may be renamed)\n */\nexport function finalizeSessionNote(notePath: string, summary: string): string {\n if (!existsSync(notePath)) {\n console.error(`Note file not found: ${notePath}`);\n return notePath;\n }\n\n let content = readFileSync(notePath, 'utf-8');\n\n // IDEMPOTENT CHECK: If already completed, don't modify again\n if (content.includes('**Status:** Completed')) {\n console.error(`Note already finalized: ${basename(notePath)}`);\n return notePath;\n }\n\n // Update status\n content = content.replace('**Status:** In Progress', '**Status:** Completed');\n\n // Add completion timestamp (only if not already present)\n if (!content.includes('**Completed:**')) {\n const completionTime = new Date().toISOString();\n content = content.replace(\n '---\\n\\n## Work Done',\n `**Completed:** ${completionTime}\\n\\n---\\n\\n## Work Done`\n );\n }\n\n // Add summary to Next Steps section (only if placeholder exists)\n const nextStepsMatch = content.match(/## Next Steps\\n\\n(<!-- .*? -->)/);\n if (nextStepsMatch) {\n content = content.replace(\n nextStepsMatch[0],\n `## Next Steps\\n\\n${summary || 'Session completed.'}`\n );\n }\n\n writeFileSync(notePath, content);\n console.error(`Session note finalized: ${basename(notePath)}`);\n\n // Extract meaningful name and rename the file\n const meaningfulName = extractMeaningfulName(content, summary);\n if (meaningfulName) {\n const newPath = renameSessionNote(notePath, meaningfulName);\n return newPath;\n }\n\n return notePath;\n}\n\n/**\n * Calculate total tokens from a session .jsonl file\n */\nexport function calculateSessionTokens(jsonlPath: string): number {\n if (!existsSync(jsonlPath)) {\n return 0;\n }\n\n try {\n const content = readFileSync(jsonlPath, 'utf-8');\n const lines = content.trim().split('\\n');\n let totalTokens = 0;\n\n for (const line of lines) {\n try {\n const entry = JSON.parse(line);\n if (entry.message?.usage) {\n const usage = entry.message.usage;\n totalTokens += (usage.input_tokens || 0);\n totalTokens += (usage.output_tokens || 0);\n totalTokens += (usage.cache_creation_input_tokens || 0);\n totalTokens += (usage.cache_read_input_tokens || 0);\n }\n } catch {\n // Skip invalid JSON lines\n }\n }\n\n return totalTokens;\n } catch (error) {\n console.error(`Error calculating tokens: ${error}`);\n return 0;\n }\n}\n\n/**\n * Find TODO.md - check local first, fallback to central\n */\nexport function findTodoPath(cwd: string): string {\n // Check local locations first\n const localPaths = [\n join(cwd, 'TODO.md'),\n join(cwd, 'notes', 'TODO.md'),\n join(cwd, 'Notes', 'TODO.md'),\n join(cwd, '.claude', 'TODO.md')\n ];\n\n for (const path of localPaths) {\n if (existsSync(path)) {\n return path;\n }\n }\n\n // Fallback to central location (inside Notes/)\n return join(getNotesDir(cwd), 'TODO.md');\n}\n\n/**\n * Find CLAUDE.md - check local locations\n * Returns the FIRST found path (for backwards compatibility)\n */\nexport function findClaudeMdPath(cwd: string): string | null {\n const paths = findAllClaudeMdPaths(cwd);\n return paths.length > 0 ? paths[0] : null;\n}\n\n/**\n * Find ALL CLAUDE.md files in local locations\n * Returns paths in priority order (most specific first):\n * 1. .claude/CLAUDE.md (project-specific config dir)\n * 2. CLAUDE.md (project root)\n * 3. Notes/CLAUDE.md (notes directory)\n * 4. Prompts/CLAUDE.md (prompts directory)\n *\n * All found files will be loaded and injected into context.\n */\nexport function findAllClaudeMdPaths(cwd: string): string[] {\n const foundPaths: string[] = [];\n\n // Priority order: most specific first\n const localPaths = [\n join(cwd, '.claude', 'CLAUDE.md'),\n join(cwd, 'CLAUDE.md'),\n join(cwd, 'Notes', 'CLAUDE.md'),\n join(cwd, 'notes', 'CLAUDE.md'),\n join(cwd, 'Prompts', 'CLAUDE.md'),\n join(cwd, 'prompts', 'CLAUDE.md')\n ];\n\n for (const path of localPaths) {\n if (existsSync(path)) {\n foundPaths.push(path);\n }\n }\n\n return foundPaths;\n}\n\n/**\n * Ensure TODO.md exists\n */\nexport function ensureTodoMd(cwd: string): string {\n const todoPath = findTodoPath(cwd);\n\n if (!existsSync(todoPath)) {\n // Ensure parent directory exists\n const parentDir = join(todoPath, '..');\n if (!existsSync(parentDir)) {\n mkdirSync(parentDir, { recursive: true });\n }\n\n const content = `# TODO\n\n## Current Session\n\n- [ ] (Tasks will be tracked here)\n\n## Backlog\n\n- [ ] (Future tasks)\n\n---\n\n*Last updated: ${new Date().toISOString()}*\n`;\n\n writeFileSync(todoPath, content);\n console.error(`Created TODO.md: ${todoPath}`);\n }\n\n return todoPath;\n}\n\n/**\n * Task item for TODO.md\n */\nexport interface TodoItem {\n content: string;\n completed: boolean;\n}\n\n/**\n * Update TODO.md with current session tasks\n * Preserves the Backlog section\n * Ensures only ONE timestamp line at the end\n */\nexport function updateTodoMd(cwd: string, tasks: TodoItem[], sessionSummary?: string): void {\n const todoPath = ensureTodoMd(cwd);\n const content = readFileSync(todoPath, 'utf-8');\n\n // Find Backlog section to preserve it (but strip any trailing timestamps/separators)\n const backlogMatch = content.match(/## Backlog[\\s\\S]*?(?=\\n---|\\n\\*Last updated|$)/);\n let backlogSection = backlogMatch ? backlogMatch[0].trim() : '## Backlog\\n\\n- [ ] (Future tasks)';\n\n // Format tasks\n const taskLines = tasks.length > 0\n ? tasks.map(t => `- [${t.completed ? 'x' : ' '}] ${t.content}`).join('\\n')\n : '- [ ] (No active tasks)';\n\n // Build new content with exactly ONE timestamp at the end\n const newContent = `# TODO\n\n## Current Session\n\n${taskLines}\n\n${sessionSummary ? `**Session Summary:** ${sessionSummary}\\n\\n` : ''}${backlogSection}\n\n---\n\n*Last updated: ${new Date().toISOString()}*\n`;\n\n writeFileSync(todoPath, newContent);\n console.error(`Updated TODO.md: ${todoPath}`);\n}\n\n/**\n * Add a checkpoint entry to TODO.md (without replacing tasks)\n * Ensures only ONE timestamp line at the end\n * Works regardless of TODO.md structure \u2014 appends if no known section found\n */\nexport function addTodoCheckpoint(cwd: string, checkpoint: string): void {\n const todoPath = ensureTodoMd(cwd);\n let content = readFileSync(todoPath, 'utf-8');\n\n // Remove ALL existing timestamp lines and trailing separators\n content = content.replace(/(\\n---\\s*)*(\\n\\*Last updated:.*\\*\\s*)+$/g, '');\n\n const checkpointText = `\\n**Checkpoint (${new Date().toISOString()}):** ${checkpoint}\\n\\n`;\n\n // Try to insert before Backlog section\n const backlogIndex = content.indexOf('## Backlog');\n if (backlogIndex !== -1) {\n content = content.substring(0, backlogIndex) + checkpointText + content.substring(backlogIndex);\n } else {\n // No Backlog section \u2014 try before Continue section, or just append\n const continueIndex = content.indexOf('## Continue');\n if (continueIndex !== -1) {\n // Insert after the Continue section (find the next ## or ---)\n const afterContinue = content.indexOf('\\n---', continueIndex);\n if (afterContinue !== -1) {\n const insertAt = afterContinue + 4; // after \\n---\n content = content.substring(0, insertAt) + '\\n' + checkpointText + content.substring(insertAt);\n } else {\n content = content.trimEnd() + '\\n' + checkpointText;\n }\n } else {\n // No known section \u2014 just append before the end\n content = content.trimEnd() + '\\n' + checkpointText;\n }\n }\n\n // Add exactly ONE timestamp at the end\n content = content.trimEnd() + `\\n\\n---\\n\\n*Last updated: ${new Date().toISOString()}*\\n`;\n\n writeFileSync(todoPath, content);\n console.error(`Checkpoint added to TODO.md`);\n}\n\n/**\n * Update the ## Continue section at the top of TODO.md.\n * This mirrors \"pause session\" behavior \u2014 gives the next session a starting point.\n * Replaces any existing ## Continue section.\n */\nexport function updateTodoContinue(\n cwd: string,\n noteFilename: string,\n state: string | null,\n tokenDisplay: string\n): void {\n const todoPath = ensureTodoMd(cwd);\n let content = readFileSync(todoPath, 'utf-8');\n\n // Remove existing ## Continue section (from ## Continue to the first standalone --- line)\n content = content.replace(/## Continue\\n[\\s\\S]*?\\n---\\n+/, '');\n\n const now = new Date().toISOString();\n const stateLines = state\n ? state.split('\\n').filter(l => l.trim()).slice(0, 10).map(l => `> ${l}`).join('\\n')\n : `> Check the latest session note for details.`;\n\n const continueSection = `## Continue\n\n> **Last session:** ${noteFilename.replace('.md', '')}\n> **Paused at:** ${now}\n>\n${stateLines}\n\n---\n\n`;\n\n // Remove leading whitespace from content\n content = content.replace(/^\\s+/, '');\n\n // If content starts with # title, insert after it\n const titleMatch = content.match(/^(# [^\\n]+\\n+)/);\n if (titleMatch) {\n content = titleMatch[1] + continueSection + content.substring(titleMatch[0].length);\n } else {\n content = continueSection + content;\n }\n\n // Clean up trailing timestamps and add fresh one\n content = content.replace(/(\\n---\\s*)*(\\n\\*Last updated:.*\\*\\s*)+$/g, '');\n content = content.trimEnd() + `\\n\\n---\\n\\n*Last updated: ${now}*\\n`;\n\n writeFileSync(todoPath, content);\n console.error('TODO.md ## Continue section updated');\n}\n", "/**\n * PAI Path Resolution - Single Source of Truth\n *\n * This module provides consistent path resolution across all PAI hooks.\n * It handles PAI_DIR detection whether set explicitly or defaulting to ~/.claude\n *\n * ALSO loads .env file from PAI_DIR so all hooks get environment variables\n * without relying on Claude Code's settings.json injection.\n *\n * Usage in hooks:\n * import { PAI_DIR, HOOKS_DIR, SKILLS_DIR } from './lib/pai-paths';\n */\n\nimport { homedir } from 'os';\nimport { resolve, join } from 'path';\nimport { existsSync, readFileSync } from 'fs';\n\n/**\n * Load .env file and inject into process.env\n * Must run BEFORE PAI_DIR resolution so .env can set PAI_DIR if needed\n */\nfunction loadEnvFile(): void {\n // Check common locations for .env\n const possiblePaths = [\n resolve(process.env.PAI_DIR || '', '.env'),\n resolve(homedir(), '.claude', '.env'),\n ];\n\n for (const envPath of possiblePaths) {\n if (existsSync(envPath)) {\n try {\n const content = readFileSync(envPath, 'utf-8');\n for (const line of content.split('\\n')) {\n const trimmed = line.trim();\n // Skip comments and empty lines\n if (!trimmed || trimmed.startsWith('#')) continue;\n\n const eqIndex = trimmed.indexOf('=');\n if (eqIndex > 0) {\n const key = trimmed.substring(0, eqIndex).trim();\n let value = trimmed.substring(eqIndex + 1).trim();\n\n // Remove surrounding quotes if present\n if ((value.startsWith('\"') && value.endsWith('\"')) ||\n (value.startsWith(\"'\") && value.endsWith(\"'\"))) {\n value = value.slice(1, -1);\n }\n\n // Expand $HOME and ~ in values\n value = value.replace(/\\$HOME/g, homedir());\n value = value.replace(/^~(?=\\/|$)/, homedir());\n\n // Only set if not already defined (env vars take precedence)\n if (process.env[key] === undefined) {\n process.env[key] = value;\n }\n }\n }\n // Found and loaded, don't check other paths\n break;\n } catch {\n // Silently continue if .env can't be read\n }\n }\n }\n}\n\n// Load .env FIRST, before any other initialization\nloadEnvFile();\n\n/**\n * Smart PAI_DIR detection with fallback\n * Priority:\n * 1. PAI_DIR environment variable (if set)\n * 2. ~/.claude (standard location)\n */\nexport const PAI_DIR = process.env.PAI_DIR\n ? resolve(process.env.PAI_DIR)\n : resolve(homedir(), '.claude');\n\n/**\n * Common PAI directories\n */\nexport const HOOKS_DIR = join(PAI_DIR, 'Hooks');\nexport const SKILLS_DIR = join(PAI_DIR, 'Skills');\nexport const AGENTS_DIR = join(PAI_DIR, 'Agents');\nexport const HISTORY_DIR = join(PAI_DIR, 'History');\nexport const COMMANDS_DIR = join(PAI_DIR, 'Commands');\n\n/**\n * Validate PAI directory structure on first import\n * This fails fast with a clear error if PAI is misconfigured\n */\nfunction validatePAIStructure(): void {\n if (!existsSync(PAI_DIR)) {\n console.error(`PAI_DIR does not exist: ${PAI_DIR}`);\n console.error(` Expected ~/.claude or set PAI_DIR environment variable`);\n process.exit(1);\n }\n\n if (!existsSync(HOOKS_DIR)) {\n console.error(`PAI hooks directory not found: ${HOOKS_DIR}`);\n console.error(` Your PAI_DIR may be misconfigured`);\n console.error(` Current PAI_DIR: ${PAI_DIR}`);\n process.exit(1);\n }\n}\n\n// Run validation on module import\n// This ensures any hook that imports this module will fail fast if paths are wrong\nvalidatePAIStructure();\n\n/**\n * Helper to get history file path with date-based organization\n */\nexport function getHistoryFilePath(subdir: string, filename: string): string {\n const now = new Date();\n const tz = process.env.TIME_ZONE || Intl.DateTimeFormat().resolvedOptions().timeZone;\n const localDate = new Date(now.toLocaleString('en-US', { timeZone: tz }));\n const year = localDate.getFullYear();\n const month = String(localDate.getMonth() + 1).padStart(2, '0');\n\n return join(HISTORY_DIR, subdir, `${year}-${month}`, filename);\n}\n"],
5
5
  "mappings": "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",
6
6
  "names": ["existsSync", "readdirSync", "readFileSync", "join", "basename", "existsSync", "readFileSync", "join", "join", "existsSync", "homedir", "join", "existsSync", "readFileSync", "resolve", "join", "existsSync", "join", "existsSync", "join", "existsSync", "existsSync", "join", "readFileSync", "basename", "mkdirSync", "readdirSync", "renameSync", "writeFileSync"]
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../src/hooks/ts/stop/stop-hook.ts", "../../src/hooks/ts/lib/project-utils.ts", "../../src/hooks/ts/lib/pai-paths.ts"],
4
- "sourcesContent": ["#!/usr/bin/env node\n\nimport { readFileSync } from 'fs';\nimport { join, basename, dirname } from 'path';\nimport {\n sendNtfyNotification,\n getCurrentNotePath,\n finalizeSessionNote,\n moveSessionFilesToSessionsDir,\n addWorkToSessionNote,\n findNotesDir,\n WorkItem\n} from '../lib/project-utils';\n\n/**\n * Extract work items from transcript for session note\n * Looks for SUMMARY, ACTIONS, RESULTS sections in assistant responses\n */\nfunction extractWorkFromTranscript(lines: string[]): WorkItem[] {\n const workItems: WorkItem[] = [];\n const seenSummaries = new Set<string>();\n\n // Process all assistant messages to find work summaries\n for (const line of lines) {\n try {\n const entry = JSON.parse(line);\n if (entry.type === 'assistant' && entry.message?.content) {\n const content = contentToText(entry.message.content);\n\n // Look for SUMMARY: lines (our standard format)\n const summaryMatch = content.match(/SUMMARY:\\s*(.+?)(?:\\n|$)/i);\n if (summaryMatch) {\n const summary = summaryMatch[1].trim();\n if (summary && !seenSummaries.has(summary) && summary.length > 5) {\n seenSummaries.add(summary);\n\n // Try to extract details from ACTIONS or RESULTS\n const details: string[] = [];\n\n const actionsMatch = content.match(/ACTIONS:\\s*(.+?)(?=\\n[A-Z]+:|$)/is);\n if (actionsMatch) {\n // Extract bullet points or numbered items\n const actionLines = actionsMatch[1].split('\\n')\n .map(l => l.replace(/^[-*\u2022]\\s*/, '').replace(/^\\d+\\.\\s*/, '').trim())\n .filter(l => l.length > 3 && l.length < 100);\n details.push(...actionLines.slice(0, 3)); // Max 3 action items\n }\n\n workItems.push({\n title: summary,\n details: details.length > 0 ? details : undefined,\n completed: true\n });\n }\n }\n\n // Also look for COMPLETED: lines as backup\n const completedMatch = content.match(/COMPLETED:\\s*(.+?)(?:\\n|$)/i);\n if (completedMatch && workItems.length === 0) {\n const completed = completedMatch[1].trim().replace(/\\*+/g, '');\n if (completed && !seenSummaries.has(completed) && completed.length > 5) {\n seenSummaries.add(completed);\n workItems.push({\n title: completed,\n completed: true\n });\n }\n }\n }\n } catch {\n // Skip invalid JSON lines\n }\n }\n\n return workItems;\n}\n\n/**\n * Generate 4-word tab title summarizing what was done\n */\nfunction generateTabTitle(prompt: string, completedLine?: string): string {\n // If we have a completed line, try to use it for a better summary\n if (completedLine) {\n const cleanCompleted = completedLine\n .replace(/\\*+/g, '')\n .replace(/\\[.*?\\]/g, '')\n .replace(/COMPLETED:\\s*/gi, '')\n .trim();\n\n // Extract meaningful words from the completed line\n const completedWords = cleanCompleted.split(/\\s+/)\n .filter(word => word.length > 2 &&\n !['the', 'and', 'but', 'for', 'are', 'with', 'his', 'her', 'this', 'that', 'you', 'can', 'will', 'have', 'been', 'your', 'from', 'they', 'were', 'said', 'what', 'them', 'just', 'told', 'how', 'does', 'into', 'about', 'completed'].includes(word.toLowerCase()))\n .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());\n\n if (completedWords.length >= 2) {\n // Build a 4-word summary from completed line\n const summary = completedWords.slice(0, 4);\n while (summary.length < 4) {\n summary.push('Done');\n }\n return summary.slice(0, 4).join(' ');\n }\n }\n\n // Fall back to parsing the prompt\n const cleanPrompt = prompt.replace(/[^\\w\\s]/g, ' ').trim();\n const words = cleanPrompt.split(/\\s+/).filter(word =>\n word.length > 2 &&\n !['the', 'and', 'but', 'for', 'are', 'with', 'his', 'her', 'this', 'that', 'you', 'can', 'will', 'have', 'been', 'your', 'from', 'they', 'were', 'said', 'what', 'them', 'just', 'told', 'how', 'does', 'into', 'about'].includes(word.toLowerCase())\n );\n\n const lowerPrompt = prompt.toLowerCase();\n\n // Find action verb if present\n const actionVerbs = ['test', 'rename', 'fix', 'debug', 'research', 'write', 'create', 'make', 'build', 'implement', 'analyze', 'review', 'update', 'modify', 'generate', 'develop', 'design', 'deploy', 'configure', 'setup', 'install', 'remove', 'delete', 'add', 'check', 'verify', 'validate', 'optimize', 'refactor', 'enhance', 'improve', 'send', 'email', 'help', 'updated', 'fixed', 'created', 'built', 'added'];\n\n let titleWords: string[] = [];\n\n // Check for action verb\n for (const verb of actionVerbs) {\n if (lowerPrompt.includes(verb)) {\n // Convert to past tense for summary\n let pastTense = verb;\n if (verb === 'write') pastTense = 'Wrote';\n else if (verb === 'make') pastTense = 'Made';\n else if (verb === 'send') pastTense = 'Sent';\n else if (verb.endsWith('e')) pastTense = verb.charAt(0).toUpperCase() + verb.slice(1, -1) + 'ed';\n else pastTense = verb.charAt(0).toUpperCase() + verb.slice(1) + 'ed';\n\n titleWords.push(pastTense);\n break;\n }\n }\n\n // Add most meaningful remaining words\n const remainingWords = words\n .filter(word => !actionVerbs.includes(word.toLowerCase()))\n .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());\n\n // Fill up to 4 words total\n for (const word of remainingWords) {\n if (titleWords.length < 4) {\n titleWords.push(word);\n } else {\n break;\n }\n }\n\n // If we don't have enough words, add generic ones\n if (titleWords.length === 0) {\n titleWords.push('Completed');\n }\n if (titleWords.length === 1) {\n titleWords.push('Task');\n }\n if (titleWords.length === 2) {\n titleWords.push('Successfully');\n }\n if (titleWords.length === 3) {\n titleWords.push('Done');\n }\n\n return titleWords.slice(0, 4).join(' ');\n}\n\n/**\n * Set terminal tab title (works with Kitty, Ghostty, iTerm2, etc.)\n */\nfunction setTerminalTabTitle(title: string): void {\n const term = process.env.TERM || '';\n\n if (term.includes('ghostty')) {\n process.stderr.write(`\\x1b]2;${title}\\x07`);\n process.stderr.write(`\\x1b]0;${title}\\x07`);\n process.stderr.write(`\\x1b]7;${title}\\x07`);\n process.stderr.write(`\\x1b]2;${title}\\x1b\\\\`);\n } else if (term.includes('kitty')) {\n process.stderr.write(`\\x1b]0;${title}\\x07`);\n process.stderr.write(`\\x1b]2;${title}\\x07`);\n process.stderr.write(`\\x1b]30;${title}\\x07`);\n } else {\n process.stderr.write(`\\x1b]0;${title}\\x07`);\n process.stderr.write(`\\x1b]2;${title}\\x07`);\n }\n\n if (process.stderr.isTTY) {\n process.stderr.write('');\n }\n}\n\n// Helper to safely turn Claude content (string or array of blocks) into plain text\nfunction contentToText(content: any): string {\n if (typeof content === 'string') return content;\n if (Array.isArray(content)) {\n return content\n .map((c) => {\n if (typeof c === 'string') return c;\n if (c?.text) return c.text;\n if (c?.content) return String(c.content);\n return '';\n })\n .join(' ')\n .trim();\n }\n return '';\n}\n\nasync function main() {\n const timestamp = new Date().toISOString();\n console.error(`\\nSTOP-HOOK TRIGGERED AT ${timestamp}`);\n\n // Get input\n let input = '';\n const decoder = new TextDecoder();\n\n try {\n for await (const chunk of process.stdin) {\n input += decoder.decode(chunk, { stream: true });\n }\n } catch (e) {\n console.error(`Error reading input: ${e}`);\n process.exit(0);\n }\n\n if (!input) {\n console.error('No input received');\n process.exit(0);\n }\n\n let transcriptPath: string;\n let cwd: string;\n try {\n const parsed = JSON.parse(input);\n transcriptPath = parsed.transcript_path;\n cwd = parsed.cwd || process.cwd();\n console.error(`Transcript path: ${transcriptPath}`);\n console.error(`Working directory: ${cwd}`);\n } catch (e) {\n console.error(`Error parsing input JSON: ${e}`);\n process.exit(0);\n }\n\n if (!transcriptPath) {\n console.error('No transcript_path in input');\n process.exit(0);\n }\n\n // Read the transcript\n let transcript;\n try {\n transcript = readFileSync(transcriptPath, 'utf-8');\n console.error(`Transcript loaded: ${transcript.split('\\n').length} lines`);\n } catch (e) {\n console.error(`Error reading transcript: ${e}`);\n process.exit(0);\n }\n\n // Parse the JSON lines to find what happened in this session\n const lines = transcript.trim().split('\\n');\n\n // Get the last user query for context\n let lastUserQuery = '';\n for (let i = lines.length - 1; i >= 0; i--) {\n try {\n const entry = JSON.parse(lines[i]);\n if (entry.type === 'user' && entry.message?.content) {\n const content = entry.message.content;\n if (typeof content === 'string') {\n lastUserQuery = content;\n } else if (Array.isArray(content)) {\n for (const item of content) {\n if (item.type === 'text' && item.text) {\n lastUserQuery = item.text;\n break;\n }\n }\n }\n if (lastUserQuery) break;\n }\n } catch (e) {\n // Skip invalid JSON\n }\n }\n\n // Extract the completion message from the last assistant response\n let message = '';\n\n const lastResponse = lines[lines.length - 1];\n try {\n const entry = JSON.parse(lastResponse);\n if (entry.type === 'assistant' && entry.message?.content) {\n const content = contentToText(entry.message.content);\n\n // Look for COMPLETED line\n const completedMatch = content.match(/COMPLETED:\\s*(.+?)(?:\\n|$)/i);\n if (completedMatch) {\n message = completedMatch[1].trim()\n .replace(/\\*+/g, '')\n .replace(/\\[.*?\\]/g, '')\n .trim();\n console.error(`COMPLETION: ${message}`);\n }\n }\n } catch (e) {\n console.error('Error parsing assistant response:', e);\n }\n\n // Set tab title\n let tabTitle = message || '';\n\n if (!tabTitle && lastUserQuery) {\n try {\n const entry = JSON.parse(lastResponse);\n if (entry.type === 'assistant' && entry.message?.content) {\n const content = contentToText(entry.message.content);\n const completedMatch = content.match(/COMPLETED:\\s*(.+?)(?:\\n|$)/im);\n if (completedMatch) {\n tabTitle = completedMatch[1].trim()\n .replace(/\\*+/g, '')\n .replace(/\\[.*?\\]/g, '')\n .trim();\n }\n }\n } catch (e) {}\n\n if (!tabTitle) {\n tabTitle = generateTabTitle(lastUserQuery, '');\n }\n }\n\n if (tabTitle) {\n try {\n const escapedTitle = tabTitle.replace(/'/g, \"'\\\\''\");\n const { execSync } = await import('child_process');\n execSync(`printf '\\\\033]0;${escapedTitle}\\\\007' >&2`);\n execSync(`printf '\\\\033]2;${escapedTitle}\\\\007' >&2`);\n execSync(`printf '\\\\033]30;${escapedTitle}\\\\007' >&2`);\n console.error(`Tab title set to: \"${tabTitle}\"`);\n } catch (e) {\n console.error(`Failed to set tab title: ${e}`);\n }\n }\n\n console.error(`User query: ${lastUserQuery || 'No query found'}`);\n console.error(`Message: ${message || 'No completion message'}`);\n\n // Final tab title override as the very last action\n if (message) {\n const finalTabTitle = message.slice(0, 50);\n process.stderr.write(`\\x1b]2;${finalTabTitle}\\x07`);\n }\n\n // Send ntfy.sh notification\n if (message) {\n await sendNtfyNotification(message);\n } else {\n await sendNtfyNotification('Session ended');\n }\n\n // Finalize session note if one exists\n try {\n const notesInfo = findNotesDir(cwd);\n console.error(`Notes directory: ${notesInfo.path} (${notesInfo.isLocal ? 'local' : 'central'})`);\n const currentNotePath = getCurrentNotePath(notesInfo.path);\n\n if (currentNotePath) {\n // FIRST: Extract and add work items from transcript\n const workItems = extractWorkFromTranscript(lines);\n if (workItems.length > 0) {\n addWorkToSessionNote(currentNotePath, workItems);\n console.error(`Added ${workItems.length} work item(s) to session note`);\n } else {\n // If no structured work items found, at least add the completion message\n if (message) {\n addWorkToSessionNote(currentNotePath, [{\n title: message,\n completed: true\n }]);\n console.error(`Added completion message to session note`);\n }\n }\n\n // THEN: Finalize the note\n const summary = message || 'Session completed.';\n finalizeSessionNote(currentNotePath, summary);\n console.error(`Session note finalized: ${basename(currentNotePath)}`);\n }\n } catch (noteError) {\n console.error(`Could not finalize session note: ${noteError}`);\n }\n\n // Move all session .jsonl files to sessions/ subdirectory\n try {\n const transcriptDir = dirname(transcriptPath);\n const movedCount = moveSessionFilesToSessionsDir(transcriptDir);\n if (movedCount > 0) {\n console.error(`Moved ${movedCount} session file(s) to sessions/`);\n }\n } catch (moveError) {\n console.error(`Could not move session files: ${moveError}`);\n }\n\n console.error(`STOP-HOOK COMPLETED SUCCESSFULLY at ${new Date().toISOString()}\\n`);\n}\n\nmain().catch(() => {});\n", "/**\n * project-utils.ts - Shared utilities for project context management\n *\n * Provides:\n * - Path encoding (matching Claude Code's scheme)\n * - ntfy.sh notifications (mandatory, synchronous)\n * - Session notes management\n * - Session token calculation\n */\n\nimport { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync } from 'fs';\nimport { join, basename } from 'path';\n\n// Import from pai-paths which handles .env loading and path resolution\nimport { PAI_DIR } from './pai-paths.js';\n\n// Re-export PAI_DIR for consumers\nexport { PAI_DIR };\nexport const PROJECTS_DIR = join(PAI_DIR, 'projects');\n\n/**\n * Encode a path the same way Claude Code does:\n * - Replace / with -\n * - Replace . with - (hidden directories become --name)\n *\n * This matches Claude Code's internal encoding to ensure Notes\n * are stored in the same project directory as transcripts.\n */\nexport function encodePath(path: string): string {\n return path\n .replace(/\\//g, '-') // Slashes become dashes\n .replace(/\\./g, '-') // Dots also become dashes\n .replace(/ /g, '-'); // Spaces become dashes (matches Claude Code native encoding)\n}\n\n/**\n * Get the project directory for a given working directory\n */\nexport function getProjectDir(cwd: string): string {\n const encoded = encodePath(cwd);\n return join(PROJECTS_DIR, encoded);\n}\n\n/**\n * Get the Notes directory for a project (central location)\n */\nexport function getNotesDir(cwd: string): string {\n return join(getProjectDir(cwd), 'Notes');\n}\n\n/**\n * Find Notes directory - check local first, fallback to central\n * DOES NOT create the directory - just finds the right location\n *\n * Logic:\n * - If cwd itself IS a Notes directory \u2192 use it directly\n * - If local Notes/ exists \u2192 use it (can be checked into git)\n * - Otherwise \u2192 use central ~/.claude/projects/.../Notes/\n */\nexport function findNotesDir(cwd: string): { path: string; isLocal: boolean } {\n // FIRST: Check if cwd itself IS a Notes directory\n const cwdBasename = basename(cwd).toLowerCase();\n if (cwdBasename === 'notes' && existsSync(cwd)) {\n return { path: cwd, isLocal: true };\n }\n\n // Check local locations\n const localPaths = [\n join(cwd, 'Notes'),\n join(cwd, 'notes'),\n join(cwd, '.claude', 'Notes')\n ];\n\n for (const path of localPaths) {\n if (existsSync(path)) {\n return { path, isLocal: true };\n }\n }\n\n // Fallback to central location\n return { path: getNotesDir(cwd), isLocal: false };\n}\n\n/**\n * Get the Sessions directory for a project (stores .jsonl transcripts)\n */\nexport function getSessionsDir(cwd: string): string {\n return join(getProjectDir(cwd), 'sessions');\n}\n\n/**\n * Get the Sessions directory from a project directory path\n */\nexport function getSessionsDirFromProjectDir(projectDir: string): string {\n return join(projectDir, 'sessions');\n}\n\n/**\n * Check if WhatsApp (Whazaa) is configured as an enabled MCP server.\n *\n * Uses standard Claude Code config at ~/.claude/settings.json.\n * No PAI dependency \u2014 works for any Claude Code user with whazaa installed.\n */\nexport function isWhatsAppEnabled(): boolean {\n try {\n const { homedir } = require('os');\n const settingsPath = join(homedir(), '.claude', 'settings.json');\n if (!existsSync(settingsPath)) return false;\n\n const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));\n const enabled: string[] = settings.enabledMcpjsonServers || [];\n return enabled.includes('whazaa');\n } catch {\n return false;\n }\n}\n\n/**\n * Send push notification \u2014 WhatsApp-aware with ntfy fallback.\n *\n * When WhatsApp (Whazaa) is enabled in MCP config, ntfy is SKIPPED\n * because the AI sends WhatsApp messages directly via MCP. Sending both\n * would cause duplicate notifications.\n *\n * When WhatsApp is NOT configured, ntfy fires as the fallback channel.\n */\nexport async function sendNtfyNotification(message: string, retries = 2): Promise<boolean> {\n // Skip ntfy when WhatsApp is configured \u2014 the AI handles notifications via MCP\n if (isWhatsAppEnabled()) {\n console.error(`WhatsApp (Whazaa) enabled in MCP config \u2014 skipping ntfy`);\n return true;\n }\n\n const topic = process.env.NTFY_TOPIC;\n\n if (!topic) {\n console.error('NTFY_TOPIC not set and WhatsApp not active \u2014 notifications disabled');\n return false;\n }\n\n for (let attempt = 0; attempt <= retries; attempt++) {\n try {\n const response = await fetch(`https://ntfy.sh/${topic}`, {\n method: 'POST',\n body: message,\n headers: {\n 'Title': 'Claude Code',\n 'Priority': 'default'\n }\n });\n\n if (response.ok) {\n console.error(`ntfy.sh notification sent (WhatsApp inactive): \"${message}\"`);\n return true;\n } else {\n console.error(`ntfy.sh attempt ${attempt + 1} failed: ${response.status}`);\n }\n } catch (error) {\n console.error(`ntfy.sh attempt ${attempt + 1} error: ${error}`);\n }\n\n // Wait before retry\n if (attempt < retries) {\n await new Promise(resolve => setTimeout(resolve, 1000));\n }\n }\n\n console.error('ntfy.sh notification failed after all retries');\n return false;\n}\n\n/**\n * Ensure the Notes directory exists for a project\n * DEPRECATED: Use ensureNotesDirSmart() instead\n */\nexport function ensureNotesDir(cwd: string): string {\n const notesDir = getNotesDir(cwd);\n\n if (!existsSync(notesDir)) {\n mkdirSync(notesDir, { recursive: true });\n console.error(`Created Notes directory: ${notesDir}`);\n }\n\n return notesDir;\n}\n\n/**\n * Smart Notes directory handling:\n * - If local Notes/ exists \u2192 use it (don't create anything new)\n * - If no local Notes/ \u2192 ensure central exists and use that\n *\n * This respects the user's choice:\n * - Projects with local Notes/ keep notes there (git-trackable)\n * - Other directories don't get cluttered with auto-created Notes/\n */\nexport function ensureNotesDirSmart(cwd: string): { path: string; isLocal: boolean } {\n const found = findNotesDir(cwd);\n\n if (found.isLocal) {\n // Local Notes/ exists - use it as-is\n return found;\n }\n\n // No local Notes/ - ensure central exists\n if (!existsSync(found.path)) {\n mkdirSync(found.path, { recursive: true });\n console.error(`Created central Notes directory: ${found.path}`);\n }\n\n return found;\n}\n\n/**\n * Ensure the Sessions directory exists for a project\n */\nexport function ensureSessionsDir(cwd: string): string {\n const sessionsDir = getSessionsDir(cwd);\n\n if (!existsSync(sessionsDir)) {\n mkdirSync(sessionsDir, { recursive: true });\n console.error(`Created sessions directory: ${sessionsDir}`);\n }\n\n return sessionsDir;\n}\n\n/**\n * Ensure the Sessions directory exists (from project dir path)\n */\nexport function ensureSessionsDirFromProjectDir(projectDir: string): string {\n const sessionsDir = getSessionsDirFromProjectDir(projectDir);\n\n if (!existsSync(sessionsDir)) {\n mkdirSync(sessionsDir, { recursive: true });\n console.error(`Created sessions directory: ${sessionsDir}`);\n }\n\n return sessionsDir;\n}\n\n/**\n * Move all .jsonl session files from project root to sessions/ subdirectory\n * @param projectDir - The project directory path\n * @param excludeFile - Optional filename to exclude (e.g., current active session)\n * @param silent - If true, suppress console output\n * Returns the number of files moved\n */\nexport function moveSessionFilesToSessionsDir(\n projectDir: string,\n excludeFile?: string,\n silent: boolean = false\n): number {\n const sessionsDir = ensureSessionsDirFromProjectDir(projectDir);\n\n if (!existsSync(projectDir)) {\n return 0;\n }\n\n const files = readdirSync(projectDir);\n let movedCount = 0;\n\n for (const file of files) {\n // Match session files: uuid.jsonl or agent-*.jsonl\n // Skip the excluded file (typically the current active session)\n if (file.endsWith('.jsonl') && file !== excludeFile) {\n const sourcePath = join(projectDir, file);\n const destPath = join(sessionsDir, file);\n\n try {\n renameSync(sourcePath, destPath);\n if (!silent) {\n console.error(`Moved ${file} \u2192 sessions/`);\n }\n movedCount++;\n } catch (error) {\n if (!silent) {\n console.error(`Could not move ${file}: ${error}`);\n }\n }\n }\n }\n\n return movedCount;\n}\n\n/**\n * Get the YYYY/MM subdirectory for the current month inside notesDir.\n * Creates the directory if it doesn't exist.\n */\nfunction getMonthDir(notesDir: string): string {\n const now = new Date();\n const year = String(now.getFullYear());\n const month = String(now.getMonth() + 1).padStart(2, '0');\n const monthDir = join(notesDir, year, month);\n if (!existsSync(monthDir)) {\n mkdirSync(monthDir, { recursive: true });\n }\n return monthDir;\n}\n\n/**\n * Get the next note number (4-digit format: 0001, 0002, etc.)\n * ALWAYS uses 4-digit format with space-dash-space separators\n * Format: NNNN - YYYY-MM-DD - Description.md\n * Numbers reset per month (each YYYY/MM directory has its own sequence).\n */\nexport function getNextNoteNumber(notesDir: string): string {\n const monthDir = getMonthDir(notesDir);\n\n // Match CORRECT format: \"0001 - \" (4-digit with space-dash-space)\n // Also match legacy formats for backwards compatibility when detecting max number\n const files = readdirSync(monthDir)\n .filter(f => f.match(/^\\d{3,4}[\\s_-]/)) // Starts with 3-4 digits followed by separator\n .filter(f => f.endsWith('.md'))\n .sort();\n\n if (files.length === 0) {\n return '0001'; // Default to 4-digit\n }\n\n // Find the highest number across all formats\n let maxNumber = 0;\n for (const file of files) {\n const digitMatch = file.match(/^(\\d+)/);\n if (digitMatch) {\n const num = parseInt(digitMatch[1], 10);\n if (num > maxNumber) maxNumber = num;\n }\n }\n\n // ALWAYS return 4-digit format\n return String(maxNumber + 1).padStart(4, '0');\n}\n\n/**\n * Get the current (latest) note file path, or null if none exists.\n * Searches in the current month's YYYY/MM subdirectory first,\n * then falls back to previous month (for sessions spanning month boundaries),\n * then falls back to flat notesDir for legacy notes.\n * Supports multiple formats for backwards compatibility:\n * - CORRECT: \"0001 - YYYY-MM-DD - Description.md\" (space-dash-space)\n * - Legacy: \"001_YYYY-MM-DD_description.md\" (underscores)\n */\nexport function getCurrentNotePath(notesDir: string): string | null {\n if (!existsSync(notesDir)) {\n return null;\n }\n\n // Helper: find latest session note in a directory\n const findLatestIn = (dir: string): string | null => {\n if (!existsSync(dir)) return null;\n const files = readdirSync(dir)\n .filter(f => f.match(/^\\d{3,4}[\\s_-].*\\.md$/))\n .sort((a, b) => {\n const numA = parseInt(a.match(/^(\\d+)/)?.[1] || '0', 10);\n const numB = parseInt(b.match(/^(\\d+)/)?.[1] || '0', 10);\n return numA - numB;\n });\n if (files.length === 0) return null;\n return join(dir, files[files.length - 1]);\n };\n\n // 1. Check current month's YYYY/MM directory\n const now = new Date();\n const year = String(now.getFullYear());\n const month = String(now.getMonth() + 1).padStart(2, '0');\n const currentMonthDir = join(notesDir, year, month);\n const found = findLatestIn(currentMonthDir);\n if (found) return found;\n\n // 2. Check previous month (for sessions spanning month boundaries)\n const prevDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);\n const prevYear = String(prevDate.getFullYear());\n const prevMonth = String(prevDate.getMonth() + 1).padStart(2, '0');\n const prevMonthDir = join(notesDir, prevYear, prevMonth);\n const prevFound = findLatestIn(prevMonthDir);\n if (prevFound) return prevFound;\n\n // 3. Fallback: check flat notesDir (legacy notes not yet filed)\n return findLatestIn(notesDir);\n}\n\n/**\n * Create a new session note\n * CORRECT FORMAT: \"NNNN - YYYY-MM-DD - Description.md\"\n * - 4-digit zero-padded number\n * - Space-dash-space separators (NOT underscores)\n * - Title case description\n *\n * IMPORTANT: The initial description is just a PLACEHOLDER.\n * Claude MUST rename the file at session end with a meaningful description\n * based on the actual work done. Never leave it as \"New Session\" or project name.\n */\nexport function createSessionNote(notesDir: string, description: string): string {\n const noteNumber = getNextNoteNumber(notesDir);\n const date = new Date().toISOString().split('T')[0]; // YYYY-MM-DD\n\n // Use \"New Session\" as placeholder - Claude MUST rename at session end!\n // The project name alone is NOT descriptive enough.\n const safeDescription = 'New Session';\n\n // CORRECT FORMAT: space-dash-space separators, filed into YYYY/MM subdirectory\n const monthDir = getMonthDir(notesDir);\n const filename = `${noteNumber} - ${date} - ${safeDescription}.md`;\n const filepath = join(monthDir, filename);\n\n const content = `# Session ${noteNumber}: ${description}\n\n**Date:** ${date}\n**Status:** In Progress\n\n---\n\n## Work Done\n\n<!-- PAI will add completed work here during session -->\n\n---\n\n## Next Steps\n\n<!-- To be filled at session end -->\n\n---\n\n**Tags:** #Session\n`;\n\n writeFileSync(filepath, content);\n console.error(`Created session note: ${filename}`);\n\n return filepath;\n}\n\n/**\n * Append checkpoint to current session note\n */\nexport function appendCheckpoint(notePath: string, checkpoint: string): void {\n if (!existsSync(notePath)) {\n // Note vanished (cloud sync, cleanup, etc.) \u2014 recreate it\n console.error(`Note file not found, recreating: ${notePath}`);\n try {\n const parentDir = join(notePath, '..');\n if (!existsSync(parentDir)) {\n mkdirSync(parentDir, { recursive: true });\n }\n const noteFilename = basename(notePath);\n const numberMatch = noteFilename.match(/^(\\d+)/);\n const noteNumber = numberMatch ? numberMatch[1] : '0000';\n const date = new Date().toISOString().split('T')[0];\n const content = `# Session ${noteNumber}: Recovered\\n\\n**Date:** ${date}\\n**Status:** In Progress\\n\\n---\\n\\n## Work Done\\n\\n<!-- PAI will add completed work here during session -->\\n\\n---\\n\\n## Next Steps\\n\\n<!-- To be filled at session end -->\\n\\n---\\n\\n**Tags:** #Session\\n`;\n writeFileSync(notePath, content);\n console.error(`Recreated session note: ${noteFilename}`);\n } catch (err) {\n console.error(`Failed to recreate note: ${err}`);\n return;\n }\n }\n\n const content = readFileSync(notePath, 'utf-8');\n const timestamp = new Date().toISOString();\n const checkpointText = `\\n### Checkpoint ${timestamp}\\n\\n${checkpoint}\\n`;\n\n // Insert before \"## Next Steps\" if it exists, otherwise append\n const nextStepsIndex = content.indexOf('## Next Steps');\n let newContent: string;\n\n if (nextStepsIndex !== -1) {\n newContent = content.substring(0, nextStepsIndex) + checkpointText + content.substring(nextStepsIndex);\n } else {\n newContent = content + checkpointText;\n }\n\n writeFileSync(notePath, newContent);\n console.error(`Checkpoint added to: ${basename(notePath)}`);\n}\n\n/**\n * Work item for session notes\n */\nexport interface WorkItem {\n title: string;\n details?: string[];\n completed?: boolean;\n}\n\n/**\n * Add work items to the \"Work Done\" section of a session note\n * This is the main way to capture what was accomplished in a session\n */\nexport function addWorkToSessionNote(notePath: string, workItems: WorkItem[], sectionTitle?: string): void {\n if (!existsSync(notePath)) {\n console.error(`Note file not found: ${notePath}`);\n return;\n }\n\n let content = readFileSync(notePath, 'utf-8');\n\n // Build the work section content\n let workText = '';\n if (sectionTitle) {\n workText += `\\n### ${sectionTitle}\\n\\n`;\n }\n\n for (const item of workItems) {\n const checkbox = item.completed !== false ? '[x]' : '[ ]';\n workText += `- ${checkbox} **${item.title}**\\n`;\n if (item.details && item.details.length > 0) {\n for (const detail of item.details) {\n workText += ` - ${detail}\\n`;\n }\n }\n }\n\n // Find the Work Done section and insert after the comment/placeholder\n const workDoneMatch = content.match(/## Work Done\\n\\n(<!-- .*? -->)?/);\n if (workDoneMatch) {\n const insertPoint = content.indexOf(workDoneMatch[0]) + workDoneMatch[0].length;\n content = content.substring(0, insertPoint) + workText + content.substring(insertPoint);\n } else {\n // Fallback: insert before Next Steps\n const nextStepsIndex = content.indexOf('## Next Steps');\n if (nextStepsIndex !== -1) {\n content = content.substring(0, nextStepsIndex) + workText + '\\n' + content.substring(nextStepsIndex);\n }\n }\n\n writeFileSync(notePath, content);\n console.error(`Added ${workItems.length} work item(s) to: ${basename(notePath)}`);\n}\n\n/**\n * Update the session note title to be more descriptive\n * Called when we know what work was done\n */\nexport function updateSessionNoteTitle(notePath: string, newTitle: string): void {\n if (!existsSync(notePath)) {\n console.error(`Note file not found: ${notePath}`);\n return;\n }\n\n let content = readFileSync(notePath, 'utf-8');\n\n // Update the H1 title\n content = content.replace(/^# Session \\d+:.*$/m, (match) => {\n const sessionNum = match.match(/Session (\\d+)/)?.[1] || '';\n return `# Session ${sessionNum}: ${newTitle}`;\n });\n\n writeFileSync(notePath, content);\n\n // Also rename the file\n renameSessionNote(notePath, sanitizeForFilename(newTitle));\n}\n\n/**\n * Sanitize a string for use in a filename (exported for use elsewhere)\n */\nexport function sanitizeForFilename(str: string): string {\n return str\n .toLowerCase()\n .replace(/[^a-z0-9\\s-]/g, '') // Remove special chars\n .replace(/\\s+/g, '-') // Spaces to hyphens\n .replace(/-+/g, '-') // Collapse multiple hyphens\n .replace(/^-|-$/g, '') // Trim hyphens\n .substring(0, 50); // Limit length\n}\n\n/**\n * Extract a meaningful name from session note content\n * Looks at Work Done section and summary to generate a descriptive name\n */\nexport function extractMeaningfulName(noteContent: string, summary: string): string {\n // Try to extract from Work Done section headers (### headings)\n const workDoneMatch = noteContent.match(/## Work Done\\n\\n([\\s\\S]*?)(?=\\n---|\\n## Next)/);\n\n if (workDoneMatch) {\n const workDoneSection = workDoneMatch[1];\n\n // Look for ### subheadings which typically describe what was done\n const subheadings = workDoneSection.match(/### ([^\\n]+)/g);\n if (subheadings && subheadings.length > 0) {\n // Use the first subheading, clean it up\n const firstHeading = subheadings[0].replace('### ', '').trim();\n if (firstHeading.length > 5 && firstHeading.length < 60) {\n return sanitizeForFilename(firstHeading);\n }\n }\n\n // Look for bold text which often indicates key topics\n const boldMatches = workDoneSection.match(/\\*\\*([^*]+)\\*\\*/g);\n if (boldMatches && boldMatches.length > 0) {\n const firstBold = boldMatches[0].replace(/\\*\\*/g, '').trim();\n if (firstBold.length > 3 && firstBold.length < 50) {\n return sanitizeForFilename(firstBold);\n }\n }\n\n // Look for numbered list items (1. Something)\n const numberedItems = workDoneSection.match(/^\\d+\\.\\s+\\*\\*([^*]+)\\*\\*/m);\n if (numberedItems) {\n return sanitizeForFilename(numberedItems[1]);\n }\n }\n\n // Fall back to summary if provided\n if (summary && summary.length > 5 && summary !== 'Session completed.') {\n // Take first meaningful phrase from summary\n const cleanSummary = summary\n .replace(/[^\\w\\s-]/g, ' ')\n .trim()\n .split(/\\s+/)\n .slice(0, 5)\n .join(' ');\n\n if (cleanSummary.length > 3) {\n return sanitizeForFilename(cleanSummary);\n }\n }\n\n return '';\n}\n\n/**\n * Rename session note with a meaningful name\n * ALWAYS uses correct format: \"NNNN - YYYY-MM-DD - Description.md\"\n * Returns the new path, or original path if rename fails\n */\nexport function renameSessionNote(notePath: string, meaningfulName: string): string {\n if (!meaningfulName || !existsSync(notePath)) {\n return notePath;\n }\n\n const dir = join(notePath, '..');\n const oldFilename = basename(notePath);\n\n // Parse existing filename - support multiple formats:\n // CORRECT: \"0001 - 2026-01-02 - Description.md\"\n // Legacy: \"001_2026-01-02_description.md\"\n const correctMatch = oldFilename.match(/^(\\d{3,4}) - (\\d{4}-\\d{2}-\\d{2}) - .*\\.md$/);\n const legacyMatch = oldFilename.match(/^(\\d{3,4})_(\\d{4}-\\d{2}-\\d{2})_.*\\.md$/);\n\n const match = correctMatch || legacyMatch;\n if (!match) {\n return notePath; // Can't parse, don't rename\n }\n\n const [, noteNumber, date] = match;\n\n // Convert to Title Case\n const titleCaseName = meaningfulName\n .split(/[\\s_-]+/)\n .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())\n .join(' ')\n .trim();\n\n // ALWAYS use correct format with 4-digit number\n const paddedNumber = noteNumber.padStart(4, '0');\n const newFilename = `${paddedNumber} - ${date} - ${titleCaseName}.md`;\n const newPath = join(dir, newFilename);\n\n // Don't rename if name is the same\n if (newFilename === oldFilename) {\n return notePath;\n }\n\n try {\n renameSync(notePath, newPath);\n console.error(`Renamed note: ${oldFilename} \u2192 ${newFilename}`);\n return newPath;\n } catch (error) {\n console.error(`Could not rename note: ${error}`);\n return notePath;\n }\n}\n\n/**\n * Finalize session note (mark as complete, add summary, rename with meaningful name)\n * IDEMPOTENT: Will only finalize once, subsequent calls are no-ops\n * Returns the final path (may be renamed)\n */\nexport function finalizeSessionNote(notePath: string, summary: string): string {\n if (!existsSync(notePath)) {\n console.error(`Note file not found: ${notePath}`);\n return notePath;\n }\n\n let content = readFileSync(notePath, 'utf-8');\n\n // IDEMPOTENT CHECK: If already completed, don't modify again\n if (content.includes('**Status:** Completed')) {\n console.error(`Note already finalized: ${basename(notePath)}`);\n return notePath;\n }\n\n // Update status\n content = content.replace('**Status:** In Progress', '**Status:** Completed');\n\n // Add completion timestamp (only if not already present)\n if (!content.includes('**Completed:**')) {\n const completionTime = new Date().toISOString();\n content = content.replace(\n '---\\n\\n## Work Done',\n `**Completed:** ${completionTime}\\n\\n---\\n\\n## Work Done`\n );\n }\n\n // Add summary to Next Steps section (only if placeholder exists)\n const nextStepsMatch = content.match(/## Next Steps\\n\\n(<!-- .*? -->)/);\n if (nextStepsMatch) {\n content = content.replace(\n nextStepsMatch[0],\n `## Next Steps\\n\\n${summary || 'Session completed.'}`\n );\n }\n\n writeFileSync(notePath, content);\n console.error(`Session note finalized: ${basename(notePath)}`);\n\n // Extract meaningful name and rename the file\n const meaningfulName = extractMeaningfulName(content, summary);\n if (meaningfulName) {\n const newPath = renameSessionNote(notePath, meaningfulName);\n return newPath;\n }\n\n return notePath;\n}\n\n/**\n * Calculate total tokens from a session .jsonl file\n */\nexport function calculateSessionTokens(jsonlPath: string): number {\n if (!existsSync(jsonlPath)) {\n return 0;\n }\n\n try {\n const content = readFileSync(jsonlPath, 'utf-8');\n const lines = content.trim().split('\\n');\n let totalTokens = 0;\n\n for (const line of lines) {\n try {\n const entry = JSON.parse(line);\n if (entry.message?.usage) {\n const usage = entry.message.usage;\n totalTokens += (usage.input_tokens || 0);\n totalTokens += (usage.output_tokens || 0);\n totalTokens += (usage.cache_creation_input_tokens || 0);\n totalTokens += (usage.cache_read_input_tokens || 0);\n }\n } catch {\n // Skip invalid JSON lines\n }\n }\n\n return totalTokens;\n } catch (error) {\n console.error(`Error calculating tokens: ${error}`);\n return 0;\n }\n}\n\n/**\n * Find TODO.md - check local first, fallback to central\n */\nexport function findTodoPath(cwd: string): string {\n // Check local locations first\n const localPaths = [\n join(cwd, 'TODO.md'),\n join(cwd, 'notes', 'TODO.md'),\n join(cwd, 'Notes', 'TODO.md'),\n join(cwd, '.claude', 'TODO.md')\n ];\n\n for (const path of localPaths) {\n if (existsSync(path)) {\n return path;\n }\n }\n\n // Fallback to central location (inside Notes/)\n return join(getNotesDir(cwd), 'TODO.md');\n}\n\n/**\n * Find CLAUDE.md - check local locations\n * Returns the FIRST found path (for backwards compatibility)\n */\nexport function findClaudeMdPath(cwd: string): string | null {\n const paths = findAllClaudeMdPaths(cwd);\n return paths.length > 0 ? paths[0] : null;\n}\n\n/**\n * Find ALL CLAUDE.md files in local locations\n * Returns paths in priority order (most specific first):\n * 1. .claude/CLAUDE.md (project-specific config dir)\n * 2. CLAUDE.md (project root)\n * 3. Notes/CLAUDE.md (notes directory)\n * 4. Prompts/CLAUDE.md (prompts directory)\n *\n * All found files will be loaded and injected into context.\n */\nexport function findAllClaudeMdPaths(cwd: string): string[] {\n const foundPaths: string[] = [];\n\n // Priority order: most specific first\n const localPaths = [\n join(cwd, '.claude', 'CLAUDE.md'),\n join(cwd, 'CLAUDE.md'),\n join(cwd, 'Notes', 'CLAUDE.md'),\n join(cwd, 'notes', 'CLAUDE.md'),\n join(cwd, 'Prompts', 'CLAUDE.md'),\n join(cwd, 'prompts', 'CLAUDE.md')\n ];\n\n for (const path of localPaths) {\n if (existsSync(path)) {\n foundPaths.push(path);\n }\n }\n\n return foundPaths;\n}\n\n/**\n * Ensure TODO.md exists\n */\nexport function ensureTodoMd(cwd: string): string {\n const todoPath = findTodoPath(cwd);\n\n if (!existsSync(todoPath)) {\n // Ensure parent directory exists\n const parentDir = join(todoPath, '..');\n if (!existsSync(parentDir)) {\n mkdirSync(parentDir, { recursive: true });\n }\n\n const content = `# TODO\n\n## Current Session\n\n- [ ] (Tasks will be tracked here)\n\n## Backlog\n\n- [ ] (Future tasks)\n\n---\n\n*Last updated: ${new Date().toISOString()}*\n`;\n\n writeFileSync(todoPath, content);\n console.error(`Created TODO.md: ${todoPath}`);\n }\n\n return todoPath;\n}\n\n/**\n * Task item for TODO.md\n */\nexport interface TodoItem {\n content: string;\n completed: boolean;\n}\n\n/**\n * Update TODO.md with current session tasks\n * Preserves the Backlog section\n * Ensures only ONE timestamp line at the end\n */\nexport function updateTodoMd(cwd: string, tasks: TodoItem[], sessionSummary?: string): void {\n const todoPath = ensureTodoMd(cwd);\n const content = readFileSync(todoPath, 'utf-8');\n\n // Find Backlog section to preserve it (but strip any trailing timestamps/separators)\n const backlogMatch = content.match(/## Backlog[\\s\\S]*?(?=\\n---|\\n\\*Last updated|$)/);\n let backlogSection = backlogMatch ? backlogMatch[0].trim() : '## Backlog\\n\\n- [ ] (Future tasks)';\n\n // Format tasks\n const taskLines = tasks.length > 0\n ? tasks.map(t => `- [${t.completed ? 'x' : ' '}] ${t.content}`).join('\\n')\n : '- [ ] (No active tasks)';\n\n // Build new content with exactly ONE timestamp at the end\n const newContent = `# TODO\n\n## Current Session\n\n${taskLines}\n\n${sessionSummary ? `**Session Summary:** ${sessionSummary}\\n\\n` : ''}${backlogSection}\n\n---\n\n*Last updated: ${new Date().toISOString()}*\n`;\n\n writeFileSync(todoPath, newContent);\n console.error(`Updated TODO.md: ${todoPath}`);\n}\n\n/**\n * Add a checkpoint entry to TODO.md (without replacing tasks)\n * Ensures only ONE timestamp line at the end\n * Works regardless of TODO.md structure \u2014 appends if no known section found\n */\nexport function addTodoCheckpoint(cwd: string, checkpoint: string): void {\n const todoPath = ensureTodoMd(cwd);\n let content = readFileSync(todoPath, 'utf-8');\n\n // Remove ALL existing timestamp lines and trailing separators\n content = content.replace(/(\\n---\\s*)*(\\n\\*Last updated:.*\\*\\s*)+$/g, '');\n\n const checkpointText = `\\n**Checkpoint (${new Date().toISOString()}):** ${checkpoint}\\n\\n`;\n\n // Try to insert before Backlog section\n const backlogIndex = content.indexOf('## Backlog');\n if (backlogIndex !== -1) {\n content = content.substring(0, backlogIndex) + checkpointText + content.substring(backlogIndex);\n } else {\n // No Backlog section \u2014 try before Continue section, or just append\n const continueIndex = content.indexOf('## Continue');\n if (continueIndex !== -1) {\n // Insert after the Continue section (find the next ## or ---)\n const afterContinue = content.indexOf('\\n---', continueIndex);\n if (afterContinue !== -1) {\n const insertAt = afterContinue + 4; // after \\n---\n content = content.substring(0, insertAt) + '\\n' + checkpointText + content.substring(insertAt);\n } else {\n content = content.trimEnd() + '\\n' + checkpointText;\n }\n } else {\n // No known section \u2014 just append before the end\n content = content.trimEnd() + '\\n' + checkpointText;\n }\n }\n\n // Add exactly ONE timestamp at the end\n content = content.trimEnd() + `\\n\\n---\\n\\n*Last updated: ${new Date().toISOString()}*\\n`;\n\n writeFileSync(todoPath, content);\n console.error(`Checkpoint added to TODO.md`);\n}\n", "/**\n * PAI Path Resolution - Single Source of Truth\n *\n * This module provides consistent path resolution across all PAI hooks.\n * It handles PAI_DIR detection whether set explicitly or defaulting to ~/.claude\n *\n * ALSO loads .env file from PAI_DIR so all hooks get environment variables\n * without relying on Claude Code's settings.json injection.\n *\n * Usage in hooks:\n * import { PAI_DIR, HOOKS_DIR, SKILLS_DIR } from './lib/pai-paths';\n */\n\nimport { homedir } from 'os';\nimport { resolve, join } from 'path';\nimport { existsSync, readFileSync } from 'fs';\n\n/**\n * Load .env file and inject into process.env\n * Must run BEFORE PAI_DIR resolution so .env can set PAI_DIR if needed\n */\nfunction loadEnvFile(): void {\n // Check common locations for .env\n const possiblePaths = [\n resolve(process.env.PAI_DIR || '', '.env'),\n resolve(homedir(), '.claude', '.env'),\n ];\n\n for (const envPath of possiblePaths) {\n if (existsSync(envPath)) {\n try {\n const content = readFileSync(envPath, 'utf-8');\n for (const line of content.split('\\n')) {\n const trimmed = line.trim();\n // Skip comments and empty lines\n if (!trimmed || trimmed.startsWith('#')) continue;\n\n const eqIndex = trimmed.indexOf('=');\n if (eqIndex > 0) {\n const key = trimmed.substring(0, eqIndex).trim();\n let value = trimmed.substring(eqIndex + 1).trim();\n\n // Remove surrounding quotes if present\n if ((value.startsWith('\"') && value.endsWith('\"')) ||\n (value.startsWith(\"'\") && value.endsWith(\"'\"))) {\n value = value.slice(1, -1);\n }\n\n // Expand $HOME and ~ in values\n value = value.replace(/\\$HOME/g, homedir());\n value = value.replace(/^~(?=\\/|$)/, homedir());\n\n // Only set if not already defined (env vars take precedence)\n if (process.env[key] === undefined) {\n process.env[key] = value;\n }\n }\n }\n // Found and loaded, don't check other paths\n break;\n } catch {\n // Silently continue if .env can't be read\n }\n }\n }\n}\n\n// Load .env FIRST, before any other initialization\nloadEnvFile();\n\n/**\n * Smart PAI_DIR detection with fallback\n * Priority:\n * 1. PAI_DIR environment variable (if set)\n * 2. ~/.claude (standard location)\n */\nexport const PAI_DIR = process.env.PAI_DIR\n ? resolve(process.env.PAI_DIR)\n : resolve(homedir(), '.claude');\n\n/**\n * Common PAI directories\n */\nexport const HOOKS_DIR = join(PAI_DIR, 'Hooks');\nexport const SKILLS_DIR = join(PAI_DIR, 'Skills');\nexport const AGENTS_DIR = join(PAI_DIR, 'Agents');\nexport const HISTORY_DIR = join(PAI_DIR, 'History');\nexport const COMMANDS_DIR = join(PAI_DIR, 'Commands');\n\n/**\n * Validate PAI directory structure on first import\n * This fails fast with a clear error if PAI is misconfigured\n */\nfunction validatePAIStructure(): void {\n if (!existsSync(PAI_DIR)) {\n console.error(`PAI_DIR does not exist: ${PAI_DIR}`);\n console.error(` Expected ~/.claude or set PAI_DIR environment variable`);\n process.exit(1);\n }\n\n if (!existsSync(HOOKS_DIR)) {\n console.error(`PAI hooks directory not found: ${HOOKS_DIR}`);\n console.error(` Your PAI_DIR may be misconfigured`);\n console.error(` Current PAI_DIR: ${PAI_DIR}`);\n process.exit(1);\n }\n}\n\n// Run validation on module import\n// This ensures any hook that imports this module will fail fast if paths are wrong\nvalidatePAIStructure();\n\n/**\n * Helper to get history file path with date-based organization\n */\nexport function getHistoryFilePath(subdir: string, filename: string): string {\n const now = new Date();\n const tz = process.env.TIME_ZONE || Intl.DateTimeFormat().resolvedOptions().timeZone;\n const localDate = new Date(now.toLocaleString('en-US', { timeZone: tz }));\n const year = localDate.getFullYear();\n const month = String(localDate.getMonth() + 1).padStart(2, '0');\n\n return join(HISTORY_DIR, subdir, `${year}-${month}`, filename);\n}\n"],
4
+ "sourcesContent": ["#!/usr/bin/env node\n\nimport { readFileSync } from 'fs';\nimport { join, basename, dirname } from 'path';\nimport {\n sendNtfyNotification,\n getCurrentNotePath,\n finalizeSessionNote,\n moveSessionFilesToSessionsDir,\n addWorkToSessionNote,\n findNotesDir,\n WorkItem\n} from '../lib/project-utils';\n\n/**\n * Extract work items from transcript for session note\n * Looks for SUMMARY, ACTIONS, RESULTS sections in assistant responses\n */\nfunction extractWorkFromTranscript(lines: string[]): WorkItem[] {\n const workItems: WorkItem[] = [];\n const seenSummaries = new Set<string>();\n\n // Process all assistant messages to find work summaries\n for (const line of lines) {\n try {\n const entry = JSON.parse(line);\n if (entry.type === 'assistant' && entry.message?.content) {\n const content = contentToText(entry.message.content);\n\n // Look for SUMMARY: lines (our standard format)\n const summaryMatch = content.match(/SUMMARY:\\s*(.+?)(?:\\n|$)/i);\n if (summaryMatch) {\n const summary = summaryMatch[1].trim();\n if (summary && !seenSummaries.has(summary) && summary.length > 5) {\n seenSummaries.add(summary);\n\n // Try to extract details from ACTIONS or RESULTS\n const details: string[] = [];\n\n const actionsMatch = content.match(/ACTIONS:\\s*(.+?)(?=\\n[A-Z]+:|$)/is);\n if (actionsMatch) {\n // Extract bullet points or numbered items\n const actionLines = actionsMatch[1].split('\\n')\n .map(l => l.replace(/^[-*\u2022]\\s*/, '').replace(/^\\d+\\.\\s*/, '').trim())\n .filter(l => l.length > 3 && l.length < 100);\n details.push(...actionLines.slice(0, 3)); // Max 3 action items\n }\n\n workItems.push({\n title: summary,\n details: details.length > 0 ? details : undefined,\n completed: true\n });\n }\n }\n\n // Also look for COMPLETED: lines as backup\n const completedMatch = content.match(/COMPLETED:\\s*(.+?)(?:\\n|$)/i);\n if (completedMatch && workItems.length === 0) {\n const completed = completedMatch[1].trim().replace(/\\*+/g, '');\n if (completed && !seenSummaries.has(completed) && completed.length > 5) {\n seenSummaries.add(completed);\n workItems.push({\n title: completed,\n completed: true\n });\n }\n }\n }\n } catch {\n // Skip invalid JSON lines\n }\n }\n\n return workItems;\n}\n\n/**\n * Generate 4-word tab title summarizing what was done\n */\nfunction generateTabTitle(prompt: string, completedLine?: string): string {\n // If we have a completed line, try to use it for a better summary\n if (completedLine) {\n const cleanCompleted = completedLine\n .replace(/\\*+/g, '')\n .replace(/\\[.*?\\]/g, '')\n .replace(/COMPLETED:\\s*/gi, '')\n .trim();\n\n // Extract meaningful words from the completed line\n const completedWords = cleanCompleted.split(/\\s+/)\n .filter(word => word.length > 2 &&\n !['the', 'and', 'but', 'for', 'are', 'with', 'his', 'her', 'this', 'that', 'you', 'can', 'will', 'have', 'been', 'your', 'from', 'they', 'were', 'said', 'what', 'them', 'just', 'told', 'how', 'does', 'into', 'about', 'completed'].includes(word.toLowerCase()))\n .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());\n\n if (completedWords.length >= 2) {\n // Build a 4-word summary from completed line\n const summary = completedWords.slice(0, 4);\n while (summary.length < 4) {\n summary.push('Done');\n }\n return summary.slice(0, 4).join(' ');\n }\n }\n\n // Fall back to parsing the prompt\n const cleanPrompt = prompt.replace(/[^\\w\\s]/g, ' ').trim();\n const words = cleanPrompt.split(/\\s+/).filter(word =>\n word.length > 2 &&\n !['the', 'and', 'but', 'for', 'are', 'with', 'his', 'her', 'this', 'that', 'you', 'can', 'will', 'have', 'been', 'your', 'from', 'they', 'were', 'said', 'what', 'them', 'just', 'told', 'how', 'does', 'into', 'about'].includes(word.toLowerCase())\n );\n\n const lowerPrompt = prompt.toLowerCase();\n\n // Find action verb if present\n const actionVerbs = ['test', 'rename', 'fix', 'debug', 'research', 'write', 'create', 'make', 'build', 'implement', 'analyze', 'review', 'update', 'modify', 'generate', 'develop', 'design', 'deploy', 'configure', 'setup', 'install', 'remove', 'delete', 'add', 'check', 'verify', 'validate', 'optimize', 'refactor', 'enhance', 'improve', 'send', 'email', 'help', 'updated', 'fixed', 'created', 'built', 'added'];\n\n let titleWords: string[] = [];\n\n // Check for action verb\n for (const verb of actionVerbs) {\n if (lowerPrompt.includes(verb)) {\n // Convert to past tense for summary\n let pastTense = verb;\n if (verb === 'write') pastTense = 'Wrote';\n else if (verb === 'make') pastTense = 'Made';\n else if (verb === 'send') pastTense = 'Sent';\n else if (verb.endsWith('e')) pastTense = verb.charAt(0).toUpperCase() + verb.slice(1, -1) + 'ed';\n else pastTense = verb.charAt(0).toUpperCase() + verb.slice(1) + 'ed';\n\n titleWords.push(pastTense);\n break;\n }\n }\n\n // Add most meaningful remaining words\n const remainingWords = words\n .filter(word => !actionVerbs.includes(word.toLowerCase()))\n .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());\n\n // Fill up to 4 words total\n for (const word of remainingWords) {\n if (titleWords.length < 4) {\n titleWords.push(word);\n } else {\n break;\n }\n }\n\n // If we don't have enough words, add generic ones\n if (titleWords.length === 0) {\n titleWords.push('Completed');\n }\n if (titleWords.length === 1) {\n titleWords.push('Task');\n }\n if (titleWords.length === 2) {\n titleWords.push('Successfully');\n }\n if (titleWords.length === 3) {\n titleWords.push('Done');\n }\n\n return titleWords.slice(0, 4).join(' ');\n}\n\n/**\n * Set terminal tab title (works with Kitty, Ghostty, iTerm2, etc.)\n */\nfunction setTerminalTabTitle(title: string): void {\n const term = process.env.TERM || '';\n\n if (term.includes('ghostty')) {\n process.stderr.write(`\\x1b]2;${title}\\x07`);\n process.stderr.write(`\\x1b]0;${title}\\x07`);\n process.stderr.write(`\\x1b]7;${title}\\x07`);\n process.stderr.write(`\\x1b]2;${title}\\x1b\\\\`);\n } else if (term.includes('kitty')) {\n process.stderr.write(`\\x1b]0;${title}\\x07`);\n process.stderr.write(`\\x1b]2;${title}\\x07`);\n process.stderr.write(`\\x1b]30;${title}\\x07`);\n } else {\n process.stderr.write(`\\x1b]0;${title}\\x07`);\n process.stderr.write(`\\x1b]2;${title}\\x07`);\n }\n\n if (process.stderr.isTTY) {\n process.stderr.write('');\n }\n}\n\n// Helper to safely turn Claude content (string or array of blocks) into plain text\nfunction contentToText(content: any): string {\n if (typeof content === 'string') return content;\n if (Array.isArray(content)) {\n return content\n .map((c) => {\n if (typeof c === 'string') return c;\n if (c?.text) return c.text;\n if (c?.content) return String(c.content);\n return '';\n })\n .join(' ')\n .trim();\n }\n return '';\n}\n\nasync function main() {\n const timestamp = new Date().toISOString();\n console.error(`\\nSTOP-HOOK TRIGGERED AT ${timestamp}`);\n\n // Get input\n let input = '';\n const decoder = new TextDecoder();\n\n try {\n for await (const chunk of process.stdin) {\n input += decoder.decode(chunk, { stream: true });\n }\n } catch (e) {\n console.error(`Error reading input: ${e}`);\n process.exit(0);\n }\n\n if (!input) {\n console.error('No input received');\n process.exit(0);\n }\n\n let transcriptPath: string;\n let cwd: string;\n try {\n const parsed = JSON.parse(input);\n transcriptPath = parsed.transcript_path;\n cwd = parsed.cwd || process.cwd();\n console.error(`Transcript path: ${transcriptPath}`);\n console.error(`Working directory: ${cwd}`);\n } catch (e) {\n console.error(`Error parsing input JSON: ${e}`);\n process.exit(0);\n }\n\n if (!transcriptPath) {\n console.error('No transcript_path in input');\n process.exit(0);\n }\n\n // Read the transcript\n let transcript;\n try {\n transcript = readFileSync(transcriptPath, 'utf-8');\n console.error(`Transcript loaded: ${transcript.split('\\n').length} lines`);\n } catch (e) {\n console.error(`Error reading transcript: ${e}`);\n process.exit(0);\n }\n\n // Parse the JSON lines to find what happened in this session\n const lines = transcript.trim().split('\\n');\n\n // Get the last user query for context\n let lastUserQuery = '';\n for (let i = lines.length - 1; i >= 0; i--) {\n try {\n const entry = JSON.parse(lines[i]);\n if (entry.type === 'user' && entry.message?.content) {\n const content = entry.message.content;\n if (typeof content === 'string') {\n lastUserQuery = content;\n } else if (Array.isArray(content)) {\n for (const item of content) {\n if (item.type === 'text' && item.text) {\n lastUserQuery = item.text;\n break;\n }\n }\n }\n if (lastUserQuery) break;\n }\n } catch (e) {\n // Skip invalid JSON\n }\n }\n\n // Extract the completion message from the last assistant response\n let message = '';\n\n const lastResponse = lines[lines.length - 1];\n try {\n const entry = JSON.parse(lastResponse);\n if (entry.type === 'assistant' && entry.message?.content) {\n const content = contentToText(entry.message.content);\n\n // Look for COMPLETED line\n const completedMatch = content.match(/COMPLETED:\\s*(.+?)(?:\\n|$)/i);\n if (completedMatch) {\n message = completedMatch[1].trim()\n .replace(/\\*+/g, '')\n .replace(/\\[.*?\\]/g, '')\n .trim();\n console.error(`COMPLETION: ${message}`);\n }\n }\n } catch (e) {\n console.error('Error parsing assistant response:', e);\n }\n\n // Set tab title\n let tabTitle = message || '';\n\n if (!tabTitle && lastUserQuery) {\n try {\n const entry = JSON.parse(lastResponse);\n if (entry.type === 'assistant' && entry.message?.content) {\n const content = contentToText(entry.message.content);\n const completedMatch = content.match(/COMPLETED:\\s*(.+?)(?:\\n|$)/im);\n if (completedMatch) {\n tabTitle = completedMatch[1].trim()\n .replace(/\\*+/g, '')\n .replace(/\\[.*?\\]/g, '')\n .trim();\n }\n }\n } catch (e) {}\n\n if (!tabTitle) {\n tabTitle = generateTabTitle(lastUserQuery, '');\n }\n }\n\n if (tabTitle) {\n try {\n const escapedTitle = tabTitle.replace(/'/g, \"'\\\\''\");\n const { execSync } = await import('child_process');\n execSync(`printf '\\\\033]0;${escapedTitle}\\\\007' >&2`);\n execSync(`printf '\\\\033]2;${escapedTitle}\\\\007' >&2`);\n execSync(`printf '\\\\033]30;${escapedTitle}\\\\007' >&2`);\n console.error(`Tab title set to: \"${tabTitle}\"`);\n } catch (e) {\n console.error(`Failed to set tab title: ${e}`);\n }\n }\n\n console.error(`User query: ${lastUserQuery || 'No query found'}`);\n console.error(`Message: ${message || 'No completion message'}`);\n\n // Final tab title override as the very last action\n if (message) {\n const finalTabTitle = message.slice(0, 50);\n process.stderr.write(`\\x1b]2;${finalTabTitle}\\x07`);\n }\n\n // Send ntfy.sh notification\n if (message) {\n await sendNtfyNotification(message);\n } else {\n await sendNtfyNotification('Session ended');\n }\n\n // Finalize session note if one exists\n try {\n const notesInfo = findNotesDir(cwd);\n console.error(`Notes directory: ${notesInfo.path} (${notesInfo.isLocal ? 'local' : 'central'})`);\n const currentNotePath = getCurrentNotePath(notesInfo.path);\n\n if (currentNotePath) {\n // FIRST: Extract and add work items from transcript\n const workItems = extractWorkFromTranscript(lines);\n if (workItems.length > 0) {\n addWorkToSessionNote(currentNotePath, workItems);\n console.error(`Added ${workItems.length} work item(s) to session note`);\n } else {\n // If no structured work items found, at least add the completion message\n if (message) {\n addWorkToSessionNote(currentNotePath, [{\n title: message,\n completed: true\n }]);\n console.error(`Added completion message to session note`);\n }\n }\n\n // THEN: Finalize the note\n const summary = message || 'Session completed.';\n finalizeSessionNote(currentNotePath, summary);\n console.error(`Session note finalized: ${basename(currentNotePath)}`);\n }\n } catch (noteError) {\n console.error(`Could not finalize session note: ${noteError}`);\n }\n\n // Move all session .jsonl files to sessions/ subdirectory\n try {\n const transcriptDir = dirname(transcriptPath);\n const movedCount = moveSessionFilesToSessionsDir(transcriptDir);\n if (movedCount > 0) {\n console.error(`Moved ${movedCount} session file(s) to sessions/`);\n }\n } catch (moveError) {\n console.error(`Could not move session files: ${moveError}`);\n }\n\n console.error(`STOP-HOOK COMPLETED SUCCESSFULLY at ${new Date().toISOString()}\\n`);\n}\n\nmain().catch(() => {});\n", "/**\n * project-utils.ts - Shared utilities for project context management\n *\n * Provides:\n * - Path encoding (matching Claude Code's scheme)\n * - ntfy.sh notifications (mandatory, synchronous)\n * - Session notes management\n * - Session token calculation\n */\n\nimport { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync } from 'fs';\nimport { join, basename } from 'path';\n\n// Import from pai-paths which handles .env loading and path resolution\nimport { PAI_DIR } from './pai-paths.js';\n\n// Re-export PAI_DIR for consumers\nexport { PAI_DIR };\nexport const PROJECTS_DIR = join(PAI_DIR, 'projects');\n\n/**\n * Encode a path the same way Claude Code does:\n * - Replace / with -\n * - Replace . with - (hidden directories become --name)\n *\n * This matches Claude Code's internal encoding to ensure Notes\n * are stored in the same project directory as transcripts.\n */\nexport function encodePath(path: string): string {\n return path\n .replace(/\\//g, '-') // Slashes become dashes\n .replace(/\\./g, '-') // Dots also become dashes\n .replace(/ /g, '-'); // Spaces become dashes (matches Claude Code native encoding)\n}\n\n/**\n * Get the project directory for a given working directory\n */\nexport function getProjectDir(cwd: string): string {\n const encoded = encodePath(cwd);\n return join(PROJECTS_DIR, encoded);\n}\n\n/**\n * Get the Notes directory for a project (central location)\n */\nexport function getNotesDir(cwd: string): string {\n return join(getProjectDir(cwd), 'Notes');\n}\n\n/**\n * Find Notes directory - check local first, fallback to central\n * DOES NOT create the directory - just finds the right location\n *\n * Logic:\n * - If cwd itself IS a Notes directory \u2192 use it directly\n * - If local Notes/ exists \u2192 use it (can be checked into git)\n * - Otherwise \u2192 use central ~/.claude/projects/.../Notes/\n */\nexport function findNotesDir(cwd: string): { path: string; isLocal: boolean } {\n // FIRST: Check if cwd itself IS a Notes directory\n const cwdBasename = basename(cwd).toLowerCase();\n if (cwdBasename === 'notes' && existsSync(cwd)) {\n return { path: cwd, isLocal: true };\n }\n\n // Check local locations\n const localPaths = [\n join(cwd, 'Notes'),\n join(cwd, 'notes'),\n join(cwd, '.claude', 'Notes')\n ];\n\n for (const path of localPaths) {\n if (existsSync(path)) {\n return { path, isLocal: true };\n }\n }\n\n // Fallback to central location\n return { path: getNotesDir(cwd), isLocal: false };\n}\n\n/**\n * Get the Sessions directory for a project (stores .jsonl transcripts)\n */\nexport function getSessionsDir(cwd: string): string {\n return join(getProjectDir(cwd), 'sessions');\n}\n\n/**\n * Get the Sessions directory from a project directory path\n */\nexport function getSessionsDirFromProjectDir(projectDir: string): string {\n return join(projectDir, 'sessions');\n}\n\n/**\n * Check if WhatsApp (Whazaa) is configured as an enabled MCP server.\n *\n * Uses standard Claude Code config at ~/.claude/settings.json.\n * No PAI dependency \u2014 works for any Claude Code user with whazaa installed.\n */\nexport function isWhatsAppEnabled(): boolean {\n try {\n const { homedir } = require('os');\n const settingsPath = join(homedir(), '.claude', 'settings.json');\n if (!existsSync(settingsPath)) return false;\n\n const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));\n const enabled: string[] = settings.enabledMcpjsonServers || [];\n return enabled.includes('whazaa');\n } catch {\n return false;\n }\n}\n\n/**\n * Send push notification \u2014 WhatsApp-aware with ntfy fallback.\n *\n * When WhatsApp (Whazaa) is enabled in MCP config, ntfy is SKIPPED\n * because the AI sends WhatsApp messages directly via MCP. Sending both\n * would cause duplicate notifications.\n *\n * When WhatsApp is NOT configured, ntfy fires as the fallback channel.\n */\nexport async function sendNtfyNotification(message: string, retries = 2): Promise<boolean> {\n // Skip ntfy when WhatsApp is configured \u2014 the AI handles notifications via MCP\n if (isWhatsAppEnabled()) {\n console.error(`WhatsApp (Whazaa) enabled in MCP config \u2014 skipping ntfy`);\n return true;\n }\n\n const topic = process.env.NTFY_TOPIC;\n\n if (!topic) {\n console.error('NTFY_TOPIC not set and WhatsApp not active \u2014 notifications disabled');\n return false;\n }\n\n for (let attempt = 0; attempt <= retries; attempt++) {\n try {\n const response = await fetch(`https://ntfy.sh/${topic}`, {\n method: 'POST',\n body: message,\n headers: {\n 'Title': 'Claude Code',\n 'Priority': 'default'\n }\n });\n\n if (response.ok) {\n console.error(`ntfy.sh notification sent (WhatsApp inactive): \"${message}\"`);\n return true;\n } else {\n console.error(`ntfy.sh attempt ${attempt + 1} failed: ${response.status}`);\n }\n } catch (error) {\n console.error(`ntfy.sh attempt ${attempt + 1} error: ${error}`);\n }\n\n // Wait before retry\n if (attempt < retries) {\n await new Promise(resolve => setTimeout(resolve, 1000));\n }\n }\n\n console.error('ntfy.sh notification failed after all retries');\n return false;\n}\n\n/**\n * Ensure the Notes directory exists for a project\n * DEPRECATED: Use ensureNotesDirSmart() instead\n */\nexport function ensureNotesDir(cwd: string): string {\n const notesDir = getNotesDir(cwd);\n\n if (!existsSync(notesDir)) {\n mkdirSync(notesDir, { recursive: true });\n console.error(`Created Notes directory: ${notesDir}`);\n }\n\n return notesDir;\n}\n\n/**\n * Smart Notes directory handling:\n * - If local Notes/ exists \u2192 use it (don't create anything new)\n * - If no local Notes/ \u2192 ensure central exists and use that\n *\n * This respects the user's choice:\n * - Projects with local Notes/ keep notes there (git-trackable)\n * - Other directories don't get cluttered with auto-created Notes/\n */\nexport function ensureNotesDirSmart(cwd: string): { path: string; isLocal: boolean } {\n const found = findNotesDir(cwd);\n\n if (found.isLocal) {\n // Local Notes/ exists - use it as-is\n return found;\n }\n\n // No local Notes/ - ensure central exists\n if (!existsSync(found.path)) {\n mkdirSync(found.path, { recursive: true });\n console.error(`Created central Notes directory: ${found.path}`);\n }\n\n return found;\n}\n\n/**\n * Ensure the Sessions directory exists for a project\n */\nexport function ensureSessionsDir(cwd: string): string {\n const sessionsDir = getSessionsDir(cwd);\n\n if (!existsSync(sessionsDir)) {\n mkdirSync(sessionsDir, { recursive: true });\n console.error(`Created sessions directory: ${sessionsDir}`);\n }\n\n return sessionsDir;\n}\n\n/**\n * Ensure the Sessions directory exists (from project dir path)\n */\nexport function ensureSessionsDirFromProjectDir(projectDir: string): string {\n const sessionsDir = getSessionsDirFromProjectDir(projectDir);\n\n if (!existsSync(sessionsDir)) {\n mkdirSync(sessionsDir, { recursive: true });\n console.error(`Created sessions directory: ${sessionsDir}`);\n }\n\n return sessionsDir;\n}\n\n/**\n * Move all .jsonl session files from project root to sessions/ subdirectory\n * @param projectDir - The project directory path\n * @param excludeFile - Optional filename to exclude (e.g., current active session)\n * @param silent - If true, suppress console output\n * Returns the number of files moved\n */\nexport function moveSessionFilesToSessionsDir(\n projectDir: string,\n excludeFile?: string,\n silent: boolean = false\n): number {\n const sessionsDir = ensureSessionsDirFromProjectDir(projectDir);\n\n if (!existsSync(projectDir)) {\n return 0;\n }\n\n const files = readdirSync(projectDir);\n let movedCount = 0;\n\n for (const file of files) {\n // Match session files: uuid.jsonl or agent-*.jsonl\n // Skip the excluded file (typically the current active session)\n if (file.endsWith('.jsonl') && file !== excludeFile) {\n const sourcePath = join(projectDir, file);\n const destPath = join(sessionsDir, file);\n\n try {\n renameSync(sourcePath, destPath);\n if (!silent) {\n console.error(`Moved ${file} \u2192 sessions/`);\n }\n movedCount++;\n } catch (error) {\n if (!silent) {\n console.error(`Could not move ${file}: ${error}`);\n }\n }\n }\n }\n\n return movedCount;\n}\n\n/**\n * Get the YYYY/MM subdirectory for the current month inside notesDir.\n * Creates the directory if it doesn't exist.\n */\nfunction getMonthDir(notesDir: string): string {\n const now = new Date();\n const year = String(now.getFullYear());\n const month = String(now.getMonth() + 1).padStart(2, '0');\n const monthDir = join(notesDir, year, month);\n if (!existsSync(monthDir)) {\n mkdirSync(monthDir, { recursive: true });\n }\n return monthDir;\n}\n\n/**\n * Get the next note number (4-digit format: 0001, 0002, etc.)\n * ALWAYS uses 4-digit format with space-dash-space separators\n * Format: NNNN - YYYY-MM-DD - Description.md\n * Numbers reset per month (each YYYY/MM directory has its own sequence).\n */\nexport function getNextNoteNumber(notesDir: string): string {\n const monthDir = getMonthDir(notesDir);\n\n // Match CORRECT format: \"0001 - \" (4-digit with space-dash-space)\n // Also match legacy formats for backwards compatibility when detecting max number\n const files = readdirSync(monthDir)\n .filter(f => f.match(/^\\d{3,4}[\\s_-]/)) // Starts with 3-4 digits followed by separator\n .filter(f => f.endsWith('.md'))\n .sort();\n\n if (files.length === 0) {\n return '0001'; // Default to 4-digit\n }\n\n // Find the highest number across all formats\n let maxNumber = 0;\n for (const file of files) {\n const digitMatch = file.match(/^(\\d+)/);\n if (digitMatch) {\n const num = parseInt(digitMatch[1], 10);\n if (num > maxNumber) maxNumber = num;\n }\n }\n\n // ALWAYS return 4-digit format\n return String(maxNumber + 1).padStart(4, '0');\n}\n\n/**\n * Get the current (latest) note file path, or null if none exists.\n * Searches in the current month's YYYY/MM subdirectory first,\n * then falls back to previous month (for sessions spanning month boundaries),\n * then falls back to flat notesDir for legacy notes.\n * Supports multiple formats for backwards compatibility:\n * - CORRECT: \"0001 - YYYY-MM-DD - Description.md\" (space-dash-space)\n * - Legacy: \"001_YYYY-MM-DD_description.md\" (underscores)\n */\nexport function getCurrentNotePath(notesDir: string): string | null {\n if (!existsSync(notesDir)) {\n return null;\n }\n\n // Helper: find latest session note in a directory\n const findLatestIn = (dir: string): string | null => {\n if (!existsSync(dir)) return null;\n const files = readdirSync(dir)\n .filter(f => f.match(/^\\d{3,4}[\\s_-].*\\.md$/))\n .sort((a, b) => {\n const numA = parseInt(a.match(/^(\\d+)/)?.[1] || '0', 10);\n const numB = parseInt(b.match(/^(\\d+)/)?.[1] || '0', 10);\n return numA - numB;\n });\n if (files.length === 0) return null;\n return join(dir, files[files.length - 1]);\n };\n\n // 1. Check current month's YYYY/MM directory\n const now = new Date();\n const year = String(now.getFullYear());\n const month = String(now.getMonth() + 1).padStart(2, '0');\n const currentMonthDir = join(notesDir, year, month);\n const found = findLatestIn(currentMonthDir);\n if (found) return found;\n\n // 2. Check previous month (for sessions spanning month boundaries)\n const prevDate = new Date(now.getFullYear(), now.getMonth() - 1, 1);\n const prevYear = String(prevDate.getFullYear());\n const prevMonth = String(prevDate.getMonth() + 1).padStart(2, '0');\n const prevMonthDir = join(notesDir, prevYear, prevMonth);\n const prevFound = findLatestIn(prevMonthDir);\n if (prevFound) return prevFound;\n\n // 3. Fallback: check flat notesDir (legacy notes not yet filed)\n return findLatestIn(notesDir);\n}\n\n/**\n * Create a new session note\n * CORRECT FORMAT: \"NNNN - YYYY-MM-DD - Description.md\"\n * - 4-digit zero-padded number\n * - Space-dash-space separators (NOT underscores)\n * - Title case description\n *\n * IMPORTANT: The initial description is just a PLACEHOLDER.\n * Claude MUST rename the file at session end with a meaningful description\n * based on the actual work done. Never leave it as \"New Session\" or project name.\n */\nexport function createSessionNote(notesDir: string, description: string): string {\n const noteNumber = getNextNoteNumber(notesDir);\n const date = new Date().toISOString().split('T')[0]; // YYYY-MM-DD\n\n // Use \"New Session\" as placeholder - Claude MUST rename at session end!\n // The project name alone is NOT descriptive enough.\n const safeDescription = 'New Session';\n\n // CORRECT FORMAT: space-dash-space separators, filed into YYYY/MM subdirectory\n const monthDir = getMonthDir(notesDir);\n const filename = `${noteNumber} - ${date} - ${safeDescription}.md`;\n const filepath = join(monthDir, filename);\n\n const content = `# Session ${noteNumber}: ${description}\n\n**Date:** ${date}\n**Status:** In Progress\n\n---\n\n## Work Done\n\n<!-- PAI will add completed work here during session -->\n\n---\n\n## Next Steps\n\n<!-- To be filled at session end -->\n\n---\n\n**Tags:** #Session\n`;\n\n writeFileSync(filepath, content);\n console.error(`Created session note: ${filename}`);\n\n return filepath;\n}\n\n/**\n * Append checkpoint to current session note\n */\nexport function appendCheckpoint(notePath: string, checkpoint: string): void {\n if (!existsSync(notePath)) {\n // Note vanished (cloud sync, cleanup, etc.) \u2014 recreate it\n console.error(`Note file not found, recreating: ${notePath}`);\n try {\n const parentDir = join(notePath, '..');\n if (!existsSync(parentDir)) {\n mkdirSync(parentDir, { recursive: true });\n }\n const noteFilename = basename(notePath);\n const numberMatch = noteFilename.match(/^(\\d+)/);\n const noteNumber = numberMatch ? numberMatch[1] : '0000';\n const date = new Date().toISOString().split('T')[0];\n const content = `# Session ${noteNumber}: Recovered\\n\\n**Date:** ${date}\\n**Status:** In Progress\\n\\n---\\n\\n## Work Done\\n\\n<!-- PAI will add completed work here during session -->\\n\\n---\\n\\n## Next Steps\\n\\n<!-- To be filled at session end -->\\n\\n---\\n\\n**Tags:** #Session\\n`;\n writeFileSync(notePath, content);\n console.error(`Recreated session note: ${noteFilename}`);\n } catch (err) {\n console.error(`Failed to recreate note: ${err}`);\n return;\n }\n }\n\n const content = readFileSync(notePath, 'utf-8');\n const timestamp = new Date().toISOString();\n const checkpointText = `\\n### Checkpoint ${timestamp}\\n\\n${checkpoint}\\n`;\n\n // Insert before \"## Next Steps\" if it exists, otherwise append\n const nextStepsIndex = content.indexOf('## Next Steps');\n let newContent: string;\n\n if (nextStepsIndex !== -1) {\n newContent = content.substring(0, nextStepsIndex) + checkpointText + content.substring(nextStepsIndex);\n } else {\n newContent = content + checkpointText;\n }\n\n writeFileSync(notePath, newContent);\n console.error(`Checkpoint added to: ${basename(notePath)}`);\n}\n\n/**\n * Work item for session notes\n */\nexport interface WorkItem {\n title: string;\n details?: string[];\n completed?: boolean;\n}\n\n/**\n * Add work items to the \"Work Done\" section of a session note\n * This is the main way to capture what was accomplished in a session\n */\nexport function addWorkToSessionNote(notePath: string, workItems: WorkItem[], sectionTitle?: string): void {\n if (!existsSync(notePath)) {\n console.error(`Note file not found: ${notePath}`);\n return;\n }\n\n let content = readFileSync(notePath, 'utf-8');\n\n // Build the work section content\n let workText = '';\n if (sectionTitle) {\n workText += `\\n### ${sectionTitle}\\n\\n`;\n }\n\n for (const item of workItems) {\n const checkbox = item.completed !== false ? '[x]' : '[ ]';\n workText += `- ${checkbox} **${item.title}**\\n`;\n if (item.details && item.details.length > 0) {\n for (const detail of item.details) {\n workText += ` - ${detail}\\n`;\n }\n }\n }\n\n // Find the Work Done section and insert after the comment/placeholder\n const workDoneMatch = content.match(/## Work Done\\n\\n(<!-- .*? -->)?/);\n if (workDoneMatch) {\n const insertPoint = content.indexOf(workDoneMatch[0]) + workDoneMatch[0].length;\n content = content.substring(0, insertPoint) + workText + content.substring(insertPoint);\n } else {\n // Fallback: insert before Next Steps\n const nextStepsIndex = content.indexOf('## Next Steps');\n if (nextStepsIndex !== -1) {\n content = content.substring(0, nextStepsIndex) + workText + '\\n' + content.substring(nextStepsIndex);\n }\n }\n\n writeFileSync(notePath, content);\n console.error(`Added ${workItems.length} work item(s) to: ${basename(notePath)}`);\n}\n\n/**\n * Update the session note title to be more descriptive\n * Called when we know what work was done\n */\nexport function updateSessionNoteTitle(notePath: string, newTitle: string): void {\n if (!existsSync(notePath)) {\n console.error(`Note file not found: ${notePath}`);\n return;\n }\n\n let content = readFileSync(notePath, 'utf-8');\n\n // Update the H1 title\n content = content.replace(/^# Session \\d+:.*$/m, (match) => {\n const sessionNum = match.match(/Session (\\d+)/)?.[1] || '';\n return `# Session ${sessionNum}: ${newTitle}`;\n });\n\n writeFileSync(notePath, content);\n\n // Also rename the file\n renameSessionNote(notePath, sanitizeForFilename(newTitle));\n}\n\n/**\n * Sanitize a string for use in a filename (exported for use elsewhere)\n */\nexport function sanitizeForFilename(str: string): string {\n return str\n .toLowerCase()\n .replace(/[^a-z0-9\\s-]/g, '') // Remove special chars\n .replace(/\\s+/g, '-') // Spaces to hyphens\n .replace(/-+/g, '-') // Collapse multiple hyphens\n .replace(/^-|-$/g, '') // Trim hyphens\n .substring(0, 50); // Limit length\n}\n\n/**\n * Extract a meaningful name from session note content\n * Looks at Work Done section and summary to generate a descriptive name\n */\nexport function extractMeaningfulName(noteContent: string, summary: string): string {\n // Try to extract from Work Done section headers (### headings)\n const workDoneMatch = noteContent.match(/## Work Done\\n\\n([\\s\\S]*?)(?=\\n---|\\n## Next)/);\n\n if (workDoneMatch) {\n const workDoneSection = workDoneMatch[1];\n\n // Look for ### subheadings which typically describe what was done\n const subheadings = workDoneSection.match(/### ([^\\n]+)/g);\n if (subheadings && subheadings.length > 0) {\n // Use the first subheading, clean it up\n const firstHeading = subheadings[0].replace('### ', '').trim();\n if (firstHeading.length > 5 && firstHeading.length < 60) {\n return sanitizeForFilename(firstHeading);\n }\n }\n\n // Look for bold text which often indicates key topics\n const boldMatches = workDoneSection.match(/\\*\\*([^*]+)\\*\\*/g);\n if (boldMatches && boldMatches.length > 0) {\n const firstBold = boldMatches[0].replace(/\\*\\*/g, '').trim();\n if (firstBold.length > 3 && firstBold.length < 50) {\n return sanitizeForFilename(firstBold);\n }\n }\n\n // Look for numbered list items (1. Something)\n const numberedItems = workDoneSection.match(/^\\d+\\.\\s+\\*\\*([^*]+)\\*\\*/m);\n if (numberedItems) {\n return sanitizeForFilename(numberedItems[1]);\n }\n }\n\n // Fall back to summary if provided\n if (summary && summary.length > 5 && summary !== 'Session completed.') {\n // Take first meaningful phrase from summary\n const cleanSummary = summary\n .replace(/[^\\w\\s-]/g, ' ')\n .trim()\n .split(/\\s+/)\n .slice(0, 5)\n .join(' ');\n\n if (cleanSummary.length > 3) {\n return sanitizeForFilename(cleanSummary);\n }\n }\n\n return '';\n}\n\n/**\n * Rename session note with a meaningful name\n * ALWAYS uses correct format: \"NNNN - YYYY-MM-DD - Description.md\"\n * Returns the new path, or original path if rename fails\n */\nexport function renameSessionNote(notePath: string, meaningfulName: string): string {\n if (!meaningfulName || !existsSync(notePath)) {\n return notePath;\n }\n\n const dir = join(notePath, '..');\n const oldFilename = basename(notePath);\n\n // Parse existing filename - support multiple formats:\n // CORRECT: \"0001 - 2026-01-02 - Description.md\"\n // Legacy: \"001_2026-01-02_description.md\"\n const correctMatch = oldFilename.match(/^(\\d{3,4}) - (\\d{4}-\\d{2}-\\d{2}) - .*\\.md$/);\n const legacyMatch = oldFilename.match(/^(\\d{3,4})_(\\d{4}-\\d{2}-\\d{2})_.*\\.md$/);\n\n const match = correctMatch || legacyMatch;\n if (!match) {\n return notePath; // Can't parse, don't rename\n }\n\n const [, noteNumber, date] = match;\n\n // Convert to Title Case\n const titleCaseName = meaningfulName\n .split(/[\\s_-]+/)\n .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())\n .join(' ')\n .trim();\n\n // ALWAYS use correct format with 4-digit number\n const paddedNumber = noteNumber.padStart(4, '0');\n const newFilename = `${paddedNumber} - ${date} - ${titleCaseName}.md`;\n const newPath = join(dir, newFilename);\n\n // Don't rename if name is the same\n if (newFilename === oldFilename) {\n return notePath;\n }\n\n try {\n renameSync(notePath, newPath);\n console.error(`Renamed note: ${oldFilename} \u2192 ${newFilename}`);\n return newPath;\n } catch (error) {\n console.error(`Could not rename note: ${error}`);\n return notePath;\n }\n}\n\n/**\n * Finalize session note (mark as complete, add summary, rename with meaningful name)\n * IDEMPOTENT: Will only finalize once, subsequent calls are no-ops\n * Returns the final path (may be renamed)\n */\nexport function finalizeSessionNote(notePath: string, summary: string): string {\n if (!existsSync(notePath)) {\n console.error(`Note file not found: ${notePath}`);\n return notePath;\n }\n\n let content = readFileSync(notePath, 'utf-8');\n\n // IDEMPOTENT CHECK: If already completed, don't modify again\n if (content.includes('**Status:** Completed')) {\n console.error(`Note already finalized: ${basename(notePath)}`);\n return notePath;\n }\n\n // Update status\n content = content.replace('**Status:** In Progress', '**Status:** Completed');\n\n // Add completion timestamp (only if not already present)\n if (!content.includes('**Completed:**')) {\n const completionTime = new Date().toISOString();\n content = content.replace(\n '---\\n\\n## Work Done',\n `**Completed:** ${completionTime}\\n\\n---\\n\\n## Work Done`\n );\n }\n\n // Add summary to Next Steps section (only if placeholder exists)\n const nextStepsMatch = content.match(/## Next Steps\\n\\n(<!-- .*? -->)/);\n if (nextStepsMatch) {\n content = content.replace(\n nextStepsMatch[0],\n `## Next Steps\\n\\n${summary || 'Session completed.'}`\n );\n }\n\n writeFileSync(notePath, content);\n console.error(`Session note finalized: ${basename(notePath)}`);\n\n // Extract meaningful name and rename the file\n const meaningfulName = extractMeaningfulName(content, summary);\n if (meaningfulName) {\n const newPath = renameSessionNote(notePath, meaningfulName);\n return newPath;\n }\n\n return notePath;\n}\n\n/**\n * Calculate total tokens from a session .jsonl file\n */\nexport function calculateSessionTokens(jsonlPath: string): number {\n if (!existsSync(jsonlPath)) {\n return 0;\n }\n\n try {\n const content = readFileSync(jsonlPath, 'utf-8');\n const lines = content.trim().split('\\n');\n let totalTokens = 0;\n\n for (const line of lines) {\n try {\n const entry = JSON.parse(line);\n if (entry.message?.usage) {\n const usage = entry.message.usage;\n totalTokens += (usage.input_tokens || 0);\n totalTokens += (usage.output_tokens || 0);\n totalTokens += (usage.cache_creation_input_tokens || 0);\n totalTokens += (usage.cache_read_input_tokens || 0);\n }\n } catch {\n // Skip invalid JSON lines\n }\n }\n\n return totalTokens;\n } catch (error) {\n console.error(`Error calculating tokens: ${error}`);\n return 0;\n }\n}\n\n/**\n * Find TODO.md - check local first, fallback to central\n */\nexport function findTodoPath(cwd: string): string {\n // Check local locations first\n const localPaths = [\n join(cwd, 'TODO.md'),\n join(cwd, 'notes', 'TODO.md'),\n join(cwd, 'Notes', 'TODO.md'),\n join(cwd, '.claude', 'TODO.md')\n ];\n\n for (const path of localPaths) {\n if (existsSync(path)) {\n return path;\n }\n }\n\n // Fallback to central location (inside Notes/)\n return join(getNotesDir(cwd), 'TODO.md');\n}\n\n/**\n * Find CLAUDE.md - check local locations\n * Returns the FIRST found path (for backwards compatibility)\n */\nexport function findClaudeMdPath(cwd: string): string | null {\n const paths = findAllClaudeMdPaths(cwd);\n return paths.length > 0 ? paths[0] : null;\n}\n\n/**\n * Find ALL CLAUDE.md files in local locations\n * Returns paths in priority order (most specific first):\n * 1. .claude/CLAUDE.md (project-specific config dir)\n * 2. CLAUDE.md (project root)\n * 3. Notes/CLAUDE.md (notes directory)\n * 4. Prompts/CLAUDE.md (prompts directory)\n *\n * All found files will be loaded and injected into context.\n */\nexport function findAllClaudeMdPaths(cwd: string): string[] {\n const foundPaths: string[] = [];\n\n // Priority order: most specific first\n const localPaths = [\n join(cwd, '.claude', 'CLAUDE.md'),\n join(cwd, 'CLAUDE.md'),\n join(cwd, 'Notes', 'CLAUDE.md'),\n join(cwd, 'notes', 'CLAUDE.md'),\n join(cwd, 'Prompts', 'CLAUDE.md'),\n join(cwd, 'prompts', 'CLAUDE.md')\n ];\n\n for (const path of localPaths) {\n if (existsSync(path)) {\n foundPaths.push(path);\n }\n }\n\n return foundPaths;\n}\n\n/**\n * Ensure TODO.md exists\n */\nexport function ensureTodoMd(cwd: string): string {\n const todoPath = findTodoPath(cwd);\n\n if (!existsSync(todoPath)) {\n // Ensure parent directory exists\n const parentDir = join(todoPath, '..');\n if (!existsSync(parentDir)) {\n mkdirSync(parentDir, { recursive: true });\n }\n\n const content = `# TODO\n\n## Current Session\n\n- [ ] (Tasks will be tracked here)\n\n## Backlog\n\n- [ ] (Future tasks)\n\n---\n\n*Last updated: ${new Date().toISOString()}*\n`;\n\n writeFileSync(todoPath, content);\n console.error(`Created TODO.md: ${todoPath}`);\n }\n\n return todoPath;\n}\n\n/**\n * Task item for TODO.md\n */\nexport interface TodoItem {\n content: string;\n completed: boolean;\n}\n\n/**\n * Update TODO.md with current session tasks\n * Preserves the Backlog section\n * Ensures only ONE timestamp line at the end\n */\nexport function updateTodoMd(cwd: string, tasks: TodoItem[], sessionSummary?: string): void {\n const todoPath = ensureTodoMd(cwd);\n const content = readFileSync(todoPath, 'utf-8');\n\n // Find Backlog section to preserve it (but strip any trailing timestamps/separators)\n const backlogMatch = content.match(/## Backlog[\\s\\S]*?(?=\\n---|\\n\\*Last updated|$)/);\n let backlogSection = backlogMatch ? backlogMatch[0].trim() : '## Backlog\\n\\n- [ ] (Future tasks)';\n\n // Format tasks\n const taskLines = tasks.length > 0\n ? tasks.map(t => `- [${t.completed ? 'x' : ' '}] ${t.content}`).join('\\n')\n : '- [ ] (No active tasks)';\n\n // Build new content with exactly ONE timestamp at the end\n const newContent = `# TODO\n\n## Current Session\n\n${taskLines}\n\n${sessionSummary ? `**Session Summary:** ${sessionSummary}\\n\\n` : ''}${backlogSection}\n\n---\n\n*Last updated: ${new Date().toISOString()}*\n`;\n\n writeFileSync(todoPath, newContent);\n console.error(`Updated TODO.md: ${todoPath}`);\n}\n\n/**\n * Add a checkpoint entry to TODO.md (without replacing tasks)\n * Ensures only ONE timestamp line at the end\n * Works regardless of TODO.md structure \u2014 appends if no known section found\n */\nexport function addTodoCheckpoint(cwd: string, checkpoint: string): void {\n const todoPath = ensureTodoMd(cwd);\n let content = readFileSync(todoPath, 'utf-8');\n\n // Remove ALL existing timestamp lines and trailing separators\n content = content.replace(/(\\n---\\s*)*(\\n\\*Last updated:.*\\*\\s*)+$/g, '');\n\n const checkpointText = `\\n**Checkpoint (${new Date().toISOString()}):** ${checkpoint}\\n\\n`;\n\n // Try to insert before Backlog section\n const backlogIndex = content.indexOf('## Backlog');\n if (backlogIndex !== -1) {\n content = content.substring(0, backlogIndex) + checkpointText + content.substring(backlogIndex);\n } else {\n // No Backlog section \u2014 try before Continue section, or just append\n const continueIndex = content.indexOf('## Continue');\n if (continueIndex !== -1) {\n // Insert after the Continue section (find the next ## or ---)\n const afterContinue = content.indexOf('\\n---', continueIndex);\n if (afterContinue !== -1) {\n const insertAt = afterContinue + 4; // after \\n---\n content = content.substring(0, insertAt) + '\\n' + checkpointText + content.substring(insertAt);\n } else {\n content = content.trimEnd() + '\\n' + checkpointText;\n }\n } else {\n // No known section \u2014 just append before the end\n content = content.trimEnd() + '\\n' + checkpointText;\n }\n }\n\n // Add exactly ONE timestamp at the end\n content = content.trimEnd() + `\\n\\n---\\n\\n*Last updated: ${new Date().toISOString()}*\\n`;\n\n writeFileSync(todoPath, content);\n console.error(`Checkpoint added to TODO.md`);\n}\n\n/**\n * Update the ## Continue section at the top of TODO.md.\n * This mirrors \"pause session\" behavior \u2014 gives the next session a starting point.\n * Replaces any existing ## Continue section.\n */\nexport function updateTodoContinue(\n cwd: string,\n noteFilename: string,\n state: string | null,\n tokenDisplay: string\n): void {\n const todoPath = ensureTodoMd(cwd);\n let content = readFileSync(todoPath, 'utf-8');\n\n // Remove existing ## Continue section (from ## Continue to the first standalone --- line)\n content = content.replace(/## Continue\\n[\\s\\S]*?\\n---\\n+/, '');\n\n const now = new Date().toISOString();\n const stateLines = state\n ? state.split('\\n').filter(l => l.trim()).slice(0, 10).map(l => `> ${l}`).join('\\n')\n : `> Check the latest session note for details.`;\n\n const continueSection = `## Continue\n\n> **Last session:** ${noteFilename.replace('.md', '')}\n> **Paused at:** ${now}\n>\n${stateLines}\n\n---\n\n`;\n\n // Remove leading whitespace from content\n content = content.replace(/^\\s+/, '');\n\n // If content starts with # title, insert after it\n const titleMatch = content.match(/^(# [^\\n]+\\n+)/);\n if (titleMatch) {\n content = titleMatch[1] + continueSection + content.substring(titleMatch[0].length);\n } else {\n content = continueSection + content;\n }\n\n // Clean up trailing timestamps and add fresh one\n content = content.replace(/(\\n---\\s*)*(\\n\\*Last updated:.*\\*\\s*)+$/g, '');\n content = content.trimEnd() + `\\n\\n---\\n\\n*Last updated: ${now}*\\n`;\n\n writeFileSync(todoPath, content);\n console.error('TODO.md ## Continue section updated');\n}\n", "/**\n * PAI Path Resolution - Single Source of Truth\n *\n * This module provides consistent path resolution across all PAI hooks.\n * It handles PAI_DIR detection whether set explicitly or defaulting to ~/.claude\n *\n * ALSO loads .env file from PAI_DIR so all hooks get environment variables\n * without relying on Claude Code's settings.json injection.\n *\n * Usage in hooks:\n * import { PAI_DIR, HOOKS_DIR, SKILLS_DIR } from './lib/pai-paths';\n */\n\nimport { homedir } from 'os';\nimport { resolve, join } from 'path';\nimport { existsSync, readFileSync } from 'fs';\n\n/**\n * Load .env file and inject into process.env\n * Must run BEFORE PAI_DIR resolution so .env can set PAI_DIR if needed\n */\nfunction loadEnvFile(): void {\n // Check common locations for .env\n const possiblePaths = [\n resolve(process.env.PAI_DIR || '', '.env'),\n resolve(homedir(), '.claude', '.env'),\n ];\n\n for (const envPath of possiblePaths) {\n if (existsSync(envPath)) {\n try {\n const content = readFileSync(envPath, 'utf-8');\n for (const line of content.split('\\n')) {\n const trimmed = line.trim();\n // Skip comments and empty lines\n if (!trimmed || trimmed.startsWith('#')) continue;\n\n const eqIndex = trimmed.indexOf('=');\n if (eqIndex > 0) {\n const key = trimmed.substring(0, eqIndex).trim();\n let value = trimmed.substring(eqIndex + 1).trim();\n\n // Remove surrounding quotes if present\n if ((value.startsWith('\"') && value.endsWith('\"')) ||\n (value.startsWith(\"'\") && value.endsWith(\"'\"))) {\n value = value.slice(1, -1);\n }\n\n // Expand $HOME and ~ in values\n value = value.replace(/\\$HOME/g, homedir());\n value = value.replace(/^~(?=\\/|$)/, homedir());\n\n // Only set if not already defined (env vars take precedence)\n if (process.env[key] === undefined) {\n process.env[key] = value;\n }\n }\n }\n // Found and loaded, don't check other paths\n break;\n } catch {\n // Silently continue if .env can't be read\n }\n }\n }\n}\n\n// Load .env FIRST, before any other initialization\nloadEnvFile();\n\n/**\n * Smart PAI_DIR detection with fallback\n * Priority:\n * 1. PAI_DIR environment variable (if set)\n * 2. ~/.claude (standard location)\n */\nexport const PAI_DIR = process.env.PAI_DIR\n ? resolve(process.env.PAI_DIR)\n : resolve(homedir(), '.claude');\n\n/**\n * Common PAI directories\n */\nexport const HOOKS_DIR = join(PAI_DIR, 'Hooks');\nexport const SKILLS_DIR = join(PAI_DIR, 'Skills');\nexport const AGENTS_DIR = join(PAI_DIR, 'Agents');\nexport const HISTORY_DIR = join(PAI_DIR, 'History');\nexport const COMMANDS_DIR = join(PAI_DIR, 'Commands');\n\n/**\n * Validate PAI directory structure on first import\n * This fails fast with a clear error if PAI is misconfigured\n */\nfunction validatePAIStructure(): void {\n if (!existsSync(PAI_DIR)) {\n console.error(`PAI_DIR does not exist: ${PAI_DIR}`);\n console.error(` Expected ~/.claude or set PAI_DIR environment variable`);\n process.exit(1);\n }\n\n if (!existsSync(HOOKS_DIR)) {\n console.error(`PAI hooks directory not found: ${HOOKS_DIR}`);\n console.error(` Your PAI_DIR may be misconfigured`);\n console.error(` Current PAI_DIR: ${PAI_DIR}`);\n process.exit(1);\n }\n}\n\n// Run validation on module import\n// This ensures any hook that imports this module will fail fast if paths are wrong\nvalidatePAIStructure();\n\n/**\n * Helper to get history file path with date-based organization\n */\nexport function getHistoryFilePath(subdir: string, filename: string): string {\n const now = new Date();\n const tz = process.env.TIME_ZONE || Intl.DateTimeFormat().resolvedOptions().timeZone;\n const localDate = new Date(now.toLocaleString('en-US', { timeZone: tz }));\n const year = localDate.getFullYear();\n const month = String(localDate.getMonth() + 1).padStart(2, '0');\n\n return join(HISTORY_DIR, subdir, `${year}-${month}`, filename);\n}\n"],
5
5
  "mappings": "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",
6
6
  "names": ["readFileSync", "basename", "existsSync", "readFileSync", "join", "join", "existsSync", "join", "homedir", "existsSync", "readFileSync", "resolve", "existsSync", "join", "existsSync", "join", "existsSync", "readFileSync", "existsSync", "join", "readFileSync", "readFileSync", "basename"]
7
7
  }