#!/usr/bin/env node /** * load-project-context.ts * * SessionStart hook that sets up project context: * - Checks for CLAUDE.md in various locations (Claude Code handles loading) * - Sets up Notes/ directory in ~/.claude/projects/{encoded-path}/ * - Ensures TODO.md exists * - Sends ntfy.sh notification (mandatory) * - Displays session continuity info (like session-init.sh) * * This hook complements Claude Code's native CLAUDE.md loading by: * - Setting up the Notes infrastructure * - Showing the latest session note for continuity * - Sending ntfy.sh notifications */ import { isWorkerSession } from "../lib/worker-session.js"; import { existsSync, readdirSync, readFileSync, statSync } from 'fs'; import { join, basename, dirname, resolve } from 'path'; import { homedir } from 'os'; import { execSync } from 'child_process'; import { buildWakeupContext } from '../../../memory/wakeup.js'; import { readContinueCheckpoint } from '../../../session/checkpoint-block.js'; import { applyHandoverBudget } from '../lib/handover-budget.js'; import { sessionRoutingPath } from '../lib/pai-paths.js'; import { findNotesDir, getProjectDir, getCurrentNotePath, createSessionNote, findTodoPath, findAllClaudeMdPaths, sendNtfyNotification, isProbeSession, archiveSessionFilesToSessionsDir } from '../lib/project-utils'; /** * Find the pai CLI binary path dynamically. * Tries `which pai` first, then common fallback locations. */ function findPaiBinary(): string { try { return execSync('which pai', { encoding: 'utf-8' }).trim(); } catch { // Fallback locations in order of preference const fallbacks = [ '/usr/local/bin/pai', '/opt/homebrew/bin/pai', `${process.env.HOME}/.local/bin/pai`, ]; for (const p of fallbacks) { if (existsSync(p)) return p; } } return 'pai'; // Last resort: rely on PATH at runtime } /** * Check session-routing.json for an active route. * Returns the routed Notes path if set, or null to use default behavior. */ function getRoutedNotesPath(): string | null { const routingFile = sessionRoutingPath(); if (!existsSync(routingFile)) return null; try { const routing = JSON.parse(readFileSync(routingFile, 'utf-8')); const active = routing?.active_session; if (active?.notes_path) { return active.notes_path; } } catch { // Ignore parse errors } return null; } /** * Project signals that indicate a directory is a real project root. */ const PROJECT_SIGNALS = [ '.git', 'package.json', 'pubspec.yaml', 'Cargo.toml', 'go.mod', 'pyproject.toml', 'setup.py', 'build.gradle', 'pom.xml', 'composer.json', 'Gemfile', 'Makefile', 'CMakeLists.txt', 'tsconfig.json', 'CLAUDE.md', join('Notes', 'PAI.md'), ]; /** * Returns true if the given directory looks like a project root. * Checks for the presence of well-known project signal files/dirs. */ function hasProjectSignals(dir: string): boolean { for (const signal of PROJECT_SIGNALS) { if (existsSync(join(dir, signal))) return true; } return false; } /** * Returns true if the directory should NOT be auto-registered. * Guards: home directory, shallow paths, temp directories. */ function isGuardedPath(dir: string): boolean { const home = homedir(); const resolved = resolve(dir); // Never register the home directory itself if (resolved === home) return true; // Depth guard: require at least 3 path segments beyond root // e.g. /Users/owner/foo is depth 3 on macOS — reject it const parts = resolved.split('/').filter(Boolean); if (parts.length < 3) return true; // Temp/system directories const forbidden = ['/tmp', '/var', '/private/tmp', '/private/var/folders']; for (const prefix of forbidden) { if (resolved === prefix || resolved.startsWith(prefix + '/')) return true; } return false; } interface HookInput { session_id: string; cwd: string; hook_event_name: string; } async function main() { if (isWorkerSession()) return; // disposable worker: no per-session bookkeeping console.error('\nload-project-context.ts starting...'); // Skip probe/health-check sessions (e.g. CodexBar ClaudeProbe) if (isProbeSession()) { console.error('Probe session detected - skipping project context loading'); process.exit(0); } // Read hook input from stdin let hookInput: HookInput | null = null; try { const chunks: Buffer[] = []; for await (const chunk of process.stdin) { chunks.push(chunk); } const input = Buffer.concat(chunks).toString('utf-8'); if (input.trim()) { hookInput = JSON.parse(input); } } catch (error) { console.error('Could not parse hook input, using process.cwd()'); } // Get current working directory const cwd = hookInput?.cwd || process.cwd(); // Determine meaningful project name // If cwd is a Notes directory, use parent directory name instead let projectName = basename(cwd); if (projectName.toLowerCase() === 'notes') { projectName = basename(dirname(cwd)); } console.error(`Working directory: ${cwd}`); console.error(`Project: ${projectName}`); // Check if this is a subagent session - skip for subagents const isSubagent = process.env.CLAUDE_AGENT_TYPE !== undefined || (process.env.CLAUDE_PROJECT_DIR || '').includes('/.claude/agents/'); if (isSubagent) { console.error('Subagent session - skipping project context setup'); process.exit(0); } // 1. Find and READ all CLAUDE.md files - inject them into context // This ensures Claude actually processes the instructions, not just sees them in headers const claudeMdPaths = findAllClaudeMdPaths(cwd); const claudeMdContents: { path: string; content: string }[] = []; if (claudeMdPaths.length > 0) { console.error(`Found ${claudeMdPaths.length} CLAUDE.md file(s):`); for (const path of claudeMdPaths) { console.error(` - ${path}`); try { const content = readFileSync(path, 'utf-8'); claudeMdContents.push({ path, content }); console.error(` Read ${content.length} chars`); } catch (error) { console.error(` Could not read: ${error}`); } } } else { console.error('No CLAUDE.md found in project'); console.error(' Consider creating one at ./CLAUDE.md or ./.claude/CLAUDE.md'); } // 2. Find or create Notes directory // Priority: // 1. Active session routing (pai route ) → routed Obsidian path // 2. Local Notes/ in cwd → use it (git-trackable, e.g. symlink to Obsidian) // 3. Central ~/.claude/projects/.../Notes/ → fallback const routedPath = getRoutedNotesPath(); let notesDir: string; if (routedPath) { // Routing is active - use the configured Obsidian Notes path const { mkdirSync } = await import('fs'); if (!existsSync(routedPath)) { mkdirSync(routedPath, { recursive: true }); console.error(`Created routed Notes: ${routedPath}`); } else { console.error(`Notes directory: ${routedPath} (routed via pai route)`); } notesDir = routedPath; } else { const notesInfo = findNotesDir(cwd); if (notesInfo.isLocal) { notesDir = notesInfo.path; console.error(`Notes directory: ${notesDir} (local)`); } else { // Create central Notes directory if (!existsSync(notesInfo.path)) { const { mkdirSync } = await import('fs'); mkdirSync(notesInfo.path, { recursive: true }); console.error(`Created central Notes: ${notesInfo.path}`); } else { console.error(`Notes directory: ${notesInfo.path} (central)`); } notesDir = notesInfo.path; } } // 3. Archive the project's transcripts into sessions/ — by LINKING, never moving. // // This used to renameSync every .jsonl except the newest out of the project // root. `claude --resume ` reads ~/.claude/projects//.jsonl // and only that path, so moving the file is what makes a session unresumable — // and because this is a SessionStart hook, the act of STARTING a session in a // project destroyed the resumability of every earlier session in it. The old // comment ("keep the newest one for potential resume") shows the dependency was // known; keeping one file was not enough. // // Measured 2026-08-04: this repo had 1 transcript at the top level and 52 under // sessions/, every one of the 52 unresumable. Among them was the id PAI prints // in its own archived handovers as `claude --resume `, so the instruction // we ship in every checkpoint could not work. // // It also completes the incident of that morning: `pai Paperfull` failed to // resume b3462801 because of a probe bug, started a fresh session instead, and // THIS hook then moved b3462801 — 867 KB of real work — out of reach. The // failed resume destroyed what it had failed to open. // // A hard link keeps both truths: the archive under sessions/ is populated for // everything that reads it, and the file Claude Code owns stays where Claude // Code put it. Same inode, so it costs nothing. // This was the fourth mover, with its own inline loop. It now calls the one // shared archiver instead: a second implementation is exactly how the earlier // probeResume fix came to land in one of three copies and leave `pai ` // broken for a day. const projectDir = getProjectDir(cwd); if (existsSync(projectDir)) { try { // Exclude this session's own transcript: it is being written right now, // and the archive is meant to hold finished sessions. const own = hookInput?.session_id ? `${hookInput.session_id}.jsonl` : undefined; archiveSessionFilesToSessionsDir(projectDir, own, true); } catch (error) { console.error(`Could not archive session transcripts: ${error}`); } } // 4. Find or create TODO.md const todoPath = findTodoPath(cwd); const hasTodo = existsSync(todoPath); if (hasTodo) { console.error(`TODO.md: ${todoPath}`); } else { // Create TODO.md in the Notes directory const newTodoPath = join(notesDir, 'TODO.md'); const { writeFileSync } = await import('fs'); writeFileSync(newTodoPath, `# TODO\n\n## Offen\n\n- [ ] \n\n---\n\n*Created: ${new Date().toISOString()}*\n`); console.error(`Created TODO.md: ${newTodoPath}`); } // 5. Check for existing note or create new one let activeNotePath: string | null = null; if (notesDir) { // notesDir is always set now (local or central) const currentNotePath = getCurrentNotePath(notesDir); // Only create a new note if there is truly no note at all. // A completed note is still used — it will be updated or continued. // This prevents duplicate notes at month boundaries and on every compaction. if (!currentNotePath) { // Defensive: ensure projectName is a usable string const safeProjectName = (typeof projectName === 'string' && projectName.trim().length > 0) ? projectName.trim() : 'Untitled Session'; console.error('\nNo previous session notes found - creating new one'); activeNotePath = createSessionNote(notesDir, String(safeProjectName)); console.error(`Created: ${basename(activeNotePath)}`); } else { activeNotePath = currentNotePath!; console.error(`\nUsing existing session note: ${basename(activeNotePath)}`); // Show preview of current note try { const content = readFileSync(activeNotePath, 'utf-8'); const lines = content.split('\n').slice(0, 12); console.error('--- Current Note Preview ---'); for (const line of lines) { console.error(line); } console.error('--- End Preview ---\n'); } catch { // Ignore read errors } } } // 6. Show TODO.md preview if (existsSync(todoPath)) { try { const todoContent = readFileSync(todoPath, 'utf-8'); const todoLines = todoContent.split('\n').filter(l => l.includes('[ ]')).slice(0, 5); if (todoLines.length > 0) { console.error('\nOpen TODOs:'); for (const line of todoLines) { console.error(` ${line.trim()}`); } } } catch { // Ignore read errors } } // 7. Send ntfy.sh notification (MANDATORY) await sendNtfyNotification(`Session started in ${projectName}`); // 7.5. Run pai project detect to identify the registered PAI project const paiBin = findPaiBinary(); let paiProjectBlock = ''; try { const { execFileSync } = await import('child_process'); const raw = execFileSync(paiBin, ['project', 'detect', '--json', cwd], { encoding: 'utf-8', env: process.env, }).trim(); if (raw) { const detected = JSON.parse(raw) as { slug?: string; display_name?: string; root_path?: string; match_type?: string; relative_path?: string | null; session_count?: number; status?: string; error?: string; cwd?: string; }; /** * Attempt to auto-register the CWD as a new PAI project. * Calls `pai project add `, then re-detects to confirm registration. * Returns true if registration succeeded (or was attempted and project add ran), * and sets paiProjectBlock as a side effect on success. */ const tryAutoRegister = async (): Promise => { if (isGuardedPath(cwd) || !hasProjectSignals(cwd)) return false; try { execFileSync(paiBin, ['project', 'add', cwd], { encoding: 'utf-8', env: process.env, }); console.error(`PAI auto-registered project at: ${cwd}`); // Re-run detect to confirm registration try { const raw2 = execFileSync(paiBin, ['project', 'detect', '--json', cwd], { encoding: 'utf-8', env: process.env, }).trim(); if (raw2) { const detected2 = JSON.parse(raw2) as typeof detected; if (detected2.slug) { const name2 = detected2.display_name || detected2.slug; console.error(`PAI auto-registered: "${detected2.slug}" (${detected2.match_type})`); paiProjectBlock = `PAI Project Registry: ${name2} (slug: ${detected2.slug}) [AUTO-REGISTERED] Match: ${detected2.match_type ?? 'exact'} | Sessions: 0`; return true; } } } catch (detectErr) { console.error('PAI auto-registration: project added but re-detect failed:', detectErr); return true; // project IS registered, just can't load context } } catch (addErr) { console.error('PAI auto-registration failed (project add):', addErr); } return false; }; if (detected.error === 'no_match') { // Attempt auto-registration if the directory looks like a real project const autoRegistered = await tryAutoRegister(); if (!autoRegistered) { paiProjectBlock = `PAI Project Registry: No registered project matches this directory. Run "pai project add ." to register this project, or use /route to tag the session.`; console.error('PAI detect: no match for', cwd); } } else if ( detected.match_type === 'parent' && detected.relative_path && !isGuardedPath(cwd) && hasProjectSignals(cwd) ) { // The CWD is inside a broader registered parent (e.g. "owner" or "apps"), // but it has its own project signals — register it as a distinct project. console.error( `PAI detect: parent match to "${detected.slug}" via relative path "${detected.relative_path}" — CWD looks like its own project, attempting auto-registration` ); const autoRegistered = await tryAutoRegister(); if (!autoRegistered) { // Fall through: show the parent match as normal const name = detected.display_name || detected.slug; const nameSlug = ` (slug: ${detected.slug})`; const matchDesc = `parent (+${detected.relative_path ?? ''})`; const statusFlag = detected.status && detected.status !== 'active' ? ` [${detected.status.toUpperCase()}]` : ''; paiProjectBlock = `PAI Project Registry: ${name}${statusFlag}${nameSlug} Match: ${matchDesc} | Sessions: ${detected.session_count ?? 0}${detected.status && detected.status !== 'active' ? `\nWARNING: Project status is "${detected.status}". Run: pai project health --fix` : ''}`; console.error(`PAI detect: kept parent match "${detected.slug}" (auto-register not applicable)`); } } else if (detected.slug) { const name = detected.display_name || detected.slug; const nameSlug = ` (slug: ${detected.slug})`; const matchDesc = detected.match_type === 'exact' ? 'exact' : `parent (+${detected.relative_path ?? ''})`; const statusFlag = detected.status && detected.status !== 'active' ? ` [${detected.status.toUpperCase()}]` : ''; paiProjectBlock = `PAI Project Registry: ${name}${statusFlag}${nameSlug} Match: ${matchDesc} | Sessions: ${detected.session_count ?? 0}${detected.status && detected.status !== 'active' ? `\nWARNING: Project status is "${detected.status}". Run: pai project health --fix` : ''}`; console.error(`PAI detect: matched "${detected.slug}" (${detected.match_type})`); } } } catch (e) { // Non-fatal — don't break session start if pai is unavailable console.error('pai project detect failed:', e); } // 8. Output system reminder with session info const reminder = ` PROJECT CONTEXT LOADED Project: ${projectName} Working Directory: ${cwd} ${notesDir ? `Notes Directory: ${notesDir}${routedPath ? ' (routed via pai route)' : ''}` : 'Notes: disabled (no local Notes/ directory)'} ${hasTodo ? `TODO: ${todoPath}` : 'TODO: not found'} ${claudeMdPaths.length > 0 ? `CLAUDE.md: ${claudeMdPaths.join(', ')}` : 'No CLAUDE.md found'} ${activeNotePath ? `Active Note: ${basename(activeNotePath)}` : ''} ${routedPath ? `\nNote Routing: ACTIVE (pai route is set - notes go to Obsidian vault)` : ''} ${paiProjectBlock ? `\n${paiProjectBlock}` : ''} `; // Output to stdout for Claude to receive console.log(reminder); // 8.5. INJECT THE PAUSE CHECKPOINT // // `pai pause` writes the handover to TODO.md under `## Continue`. Until now // nothing read it back: CORE SKILL.md only told the model to look there *if // the user typed "go"*, so a resumed session started blind unless the user // knew the magic word. Writing a handover nobody delivers is not a // handover. Inject it, and say plainly that it is the previous session's // state rather than an instruction to act on. let handoverInjected = false; if (existsSync(todoPath)) { try { const checkpoint = readContinueCheckpoint(readFileSync(todoPath, 'utf-8')); if (checkpoint) { handoverInjected = true; const from = checkpoint.meta?.session ? `\nFrom session: ${checkpoint.meta.session}` : ''; const when = checkpoint.meta?.ts ? `\nPaused at: ${checkpoint.meta.ts}` : ''; const resume = checkpoint.meta?.sessionId ? `\nResume that session with: claude --resume ${checkpoint.meta.sessionId}` : ''; const budgeted = applyHandoverBudget(checkpoint.body, todoPath); console.log(` HANDOVER FROM THE PREVIOUS SESSION Source: ${todoPath} (## Continue)${from}${when}${resume} ${budgeted.body} --- This is recorded state, not a new instruction. Do not start acting on it unprompted. If the user says "go", "continue", or "weiter", resume from here. `); const budgetNote = budgeted.truncated ? ', truncated' : budgeted.elided.length > 0 ? `, elided: ${budgeted.elided.join(',')}` : ''; console.error(`Injected pause checkpoint (${checkpoint.body.length} chars${budgetNote})`); } else { console.error('No pause checkpoint body in TODO.md — nothing to hand over'); } } catch (error) { // Non-fatal — a malformed TODO.md must never block session start. console.error('Checkpoint injection failed:', error); } } // 9. INJECT CLAUDE.md contents as system-reminders // This ensures Claude actually reads and processes the instructions for (const { path, content } of claudeMdContents) { const claudeMdReminder = ` LOCAL CLAUDE.md LOADED (MANDATORY - READ AND FOLLOW) Source: ${path} ${content} --- THE ABOVE INSTRUCTIONS ARE MANDATORY. Follow them exactly. `; console.log(claudeMdReminder); console.error(`Injected CLAUDE.md content from: ${path}`); } // 10. Inject wake-up context (L0 identity + L1 essential story) if available // The detected PAI project root_path is used for L1 note lookup. // We derive it from the `paiProjectBlock` detection result by re-running a // lightweight registry lookup, or by falling back to cwd for local-notes projects. try { // Attempt to find the project root path from the registry via `pai project detect --json` let wakeupRootPath: string | undefined; try { const { execFileSync: efs } = await import('child_process'); const raw2 = efs(paiBin, ['project', 'detect', '--json', cwd], { encoding: 'utf-8', env: process.env, }).trim(); if (raw2) { const det = JSON.parse(raw2) as { root_path?: string; slug?: string }; if (det.root_path) wakeupRootPath = det.root_path; } } catch { // Non-fatal — fall back to cwd wakeupRootPath = cwd; } // Handover supersedes the L1 story: both summarise the same recent note, // and the handover is a curated message while the story is auto-extracted. const wakeupBlock = buildWakeupContext(wakeupRootPath, undefined, { skipStory: handoverInjected }); if (wakeupBlock) { const wakeupReminder = `\n\nWAKEUP CONTEXT\n\n${wakeupBlock}\n\n`; console.log(wakeupReminder); console.error('Injected wake-up context (L0+L1)'); } else { console.error('No wake-up context to inject (no identity file or session notes)'); } } catch (wakeupError) { // Non-fatal — don't block session start console.error('Wake-up context injection failed:', wakeupError); } console.error('\nProject context setup complete\n'); process.exit(0); } main().catch(error => { console.error('load-project-context.ts error:', error); process.exit(0); // Don't block session start });