@tekmidian/pai 0.32.2 → 0.33.0
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.
- package/dist/{checkpoint-block-DYq9RmMU.mjs → checkpoint-block-D3rm4dAJ.mjs} +4 -4
- package/dist/checkpoint-block-D3rm4dAJ.mjs.map +1 -0
- package/dist/cli/index.mjs +4 -4
- package/dist/cli/program.mjs +4 -4
- package/dist/config-CcdkNSWa.mjs.map +1 -1
- package/dist/daemon/index.mjs +4 -4
- package/dist/{daemon-BhbX7XHt.mjs → daemon-BlaOnldQ.mjs} +5 -5
- package/dist/{daemon-BhbX7XHt.mjs.map → daemon-BlaOnldQ.mjs.map} +1 -1
- package/dist/{factory-hmDHxTaJ.mjs → factory-Cd94gzNz.mjs} +2 -2
- package/dist/{factory-hmDHxTaJ.mjs.map → factory-Cd94gzNz.mjs.map} +1 -1
- package/dist/hooks/load-project-context.mjs.map +1 -1
- package/dist/{main-resolver-DjyUDJrv.mjs → main-resolver-qIjvdJsb.mjs} +27 -12
- package/dist/main-resolver-qIjvdJsb.mjs.map +1 -0
- package/dist/{pick-BBb6PiKa.mjs → pick-CX1A7ffC.mjs} +116 -17
- package/dist/pick-CX1A7ffC.mjs.map +1 -0
- package/dist/{postgres-5IpjMwo_.mjs → postgres-L6TIAIGV.mjs} +2 -2
- package/dist/postgres-L6TIAIGV.mjs.map +1 -0
- package/dist/search-C32zQ0V0.mjs.map +1 -1
- package/dist/{work-queue-worker-CGMXpO_4.mjs → work-queue-worker-DgoTjWfd.mjs} +2 -2
- package/dist/{work-queue-worker-CGMXpO_4.mjs.map → work-queue-worker-DgoTjWfd.mjs.map} +1 -1
- package/docs/commands/README.md +2 -0
- package/docs/commands/project.md +22 -0
- package/docs/commands/projects.md +22 -0
- package/package.json +1 -1
- package/src/hooks/session-stop.sh +47 -9
- package/src/hooks/ts/session-start/load-project-context.ts +2 -2
- package/dist/checkpoint-block-DYq9RmMU.mjs.map +0 -1
- package/dist/main-resolver-DjyUDJrv.mjs.map +0 -1
- package/dist/pick-BBb6PiKa.mjs.map +0 -1
- package/dist/postgres-5IpjMwo_.mjs.map +0 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/hooks/ts/session-start/load-project-context.ts", "../../../src/memory/wakeup.ts", "../../../src/session/checkpoint-block.ts", "../../../src/hooks/ts/lib/project-utils/paths.ts", "../../../src/hooks/ts/lib/pai-paths.ts", "../../../src/hooks/ts/lib/project-utils/notify.ts", "../../../src/hooks/ts/lib/project-utils/session-notes.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, resolve } from 'path';\nimport { homedir } from 'os';\nimport { execSync } from 'child_process';\nimport { buildWakeupContext } from '../../../memory/wakeup.js';\nimport { readContinueCheckpoint } from '../../../session/checkpoint-block.js';\nimport {\n PAI_DIR,\n findNotesDir,\n getProjectDir,\n getCurrentNotePath,\n createSessionNote,\n findTodoPath,\n findAllClaudeMdPaths,\n sendNtfyNotification,\n isProbeSession,\n archiveSessionFilesToSessionsDir\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\n/**\n * Project signals that indicate a directory is a real project root.\n */\nconst PROJECT_SIGNALS = [\n '.git',\n 'package.json',\n 'pubspec.yaml',\n 'Cargo.toml',\n 'go.mod',\n 'pyproject.toml',\n 'setup.py',\n 'build.gradle',\n 'pom.xml',\n 'composer.json',\n 'Gemfile',\n 'Makefile',\n 'CMakeLists.txt',\n 'tsconfig.json',\n 'CLAUDE.md',\n join('Notes', 'PAI.md'),\n];\n\n/**\n * Returns true if the given directory looks like a project root.\n * Checks for the presence of well-known project signal files/dirs.\n */\nfunction hasProjectSignals(dir: string): boolean {\n for (const signal of PROJECT_SIGNALS) {\n if (existsSync(join(dir, signal))) return true;\n }\n return false;\n}\n\n/**\n * Returns true if the directory should NOT be auto-registered.\n * Guards: home directory, shallow paths, temp directories.\n */\nfunction isGuardedPath(dir: string): boolean {\n const home = homedir();\n const resolved = resolve(dir);\n\n // Never register the home directory itself\n if (resolved === home) return true;\n\n // Depth guard: require at least 3 path segments beyond root\n // e.g. /Users/i052341/foo is depth 3 on macOS \u2014 reject it\n const parts = resolved.split('/').filter(Boolean);\n if (parts.length < 3) return true;\n\n // Temp/system directories\n const forbidden = ['/tmp', '/var', '/private/tmp', '/private/var/folders'];\n for (const prefix of forbidden) {\n if (resolved === prefix || resolved.startsWith(prefix + '/')) return true;\n }\n\n return false;\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 // Skip probe/health-check sessions (e.g. CodexBar ClaudeProbe)\n if (isProbeSession()) {\n console.error('Probe session detected - skipping project context loading');\n process.exit(0);\n }\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. Archive the project's transcripts into sessions/ \u2014 by LINKING, never moving.\n //\n // This used to renameSync every .jsonl except the newest out of the project\n // root. `claude --resume <uuid>` reads ~/.claude/projects/<encoded>/<uuid>.jsonl\n // and only that path, so moving the file is what makes a session unresumable \u2014\n // and because this is a SessionStart hook, the act of STARTING a session in a\n // project destroyed the resumability of every earlier session in it. The old\n // comment (\"keep the newest one for potential resume\") shows the dependency was\n // known; keeping one file was not enough.\n //\n // Measured 2026-08-04: this repo had 1 transcript at the top level and 52 under\n // sessions/, every one of the 52 unresumable. Among them was the id PAI prints\n // in its own archived handovers as `claude --resume <uuid>`, so the instruction\n // we ship in every checkpoint could not work.\n //\n // It also completes the incident of that morning: `pai Paperfull` failed to\n // resume b3462801 because of a probe bug, started a fresh session instead, and\n // THIS hook then moved b3462801 \u2014 867 KB of real work \u2014 out of reach. The\n // failed resume destroyed what it had failed to open.\n //\n // A hard link keeps both truths: the archive under sessions/ is populated for\n // everything that reads it, and the file Claude Code owns stays where Claude\n // Code put it. Same inode, so it costs nothing.\n // This was the fourth mover, with its own inline loop. It now calls the one\n // shared archiver instead: a second implementation is exactly how the earlier\n // probeResume fix came to land in one of three copies and leave `pai <Name>`\n // broken for a day.\n const projectDir = getProjectDir(cwd);\n if (existsSync(projectDir)) {\n try {\n // Exclude this session's own transcript: it is being written right now,\n // and the archive is meant to hold finished sessions.\n const own = hookInput?.session_id ? `${hookInput.session_id}.jsonl` : undefined;\n archiveSessionFilesToSessionsDir(projectDir, own, true);\n } catch (error) {\n console.error(`Could not archive session transcripts: ${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 // Only create a new note if there is truly no note at all.\n // A completed note is still used \u2014 it will be updated or continued.\n // This prevents duplicate notes at month boundaries and on every compaction.\n if (!currentNotePath) {\n // Defensive: ensure projectName is a usable string\n const safeProjectName = (typeof projectName === 'string' && projectName.trim().length > 0)\n ? projectName.trim()\n : 'Untitled Session';\n console.error('\\nNo previous session notes found - creating new one');\n activeNotePath = createSessionNote(notesDir, String(safeProjectName));\n console.error(`Created: ${basename(activeNotePath)}`);\n } else {\n activeNotePath = currentNotePath!;\n console.error(`\\nUsing existing session note: ${basename(activeNotePath)}`);\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 /**\n * Attempt to auto-register the CWD as a new PAI project.\n * Calls `pai project add <cwd>`, then re-detects to confirm registration.\n * Returns true if registration succeeded (or was attempted and project add ran),\n * and sets paiProjectBlock as a side effect on success.\n */\n const tryAutoRegister = async (): Promise<boolean> => {\n if (isGuardedPath(cwd) || !hasProjectSignals(cwd)) return false;\n\n try {\n execFileSync(paiBin, ['project', 'add', cwd], {\n encoding: 'utf-8',\n env: process.env,\n });\n console.error(`PAI auto-registered project at: ${cwd}`);\n\n // Re-run detect to confirm registration\n try {\n const raw2 = execFileSync(paiBin, ['project', 'detect', '--json', cwd], {\n encoding: 'utf-8',\n env: process.env,\n }).trim();\n\n if (raw2) {\n const detected2 = JSON.parse(raw2) as typeof detected;\n if (detected2.slug) {\n const name2 = detected2.display_name || detected2.slug;\n console.error(`PAI auto-registered: \"${detected2.slug}\" (${detected2.match_type})`);\n paiProjectBlock = `PAI Project Registry: ${name2} (slug: ${detected2.slug}) [AUTO-REGISTERED]\nMatch: ${detected2.match_type ?? 'exact'} | Sessions: 0`;\n return true;\n }\n }\n } catch (detectErr) {\n console.error('PAI auto-registration: project added but re-detect failed:', detectErr);\n return true; // project IS registered, just can't load context\n }\n } catch (addErr) {\n console.error('PAI auto-registration failed (project add):', addErr);\n }\n return false;\n };\n\n if (detected.error === 'no_match') {\n // Attempt auto-registration if the directory looks like a real project\n const autoRegistered = await tryAutoRegister();\n\n if (!autoRegistered) {\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 }\n } else if (\n detected.match_type === 'parent' &&\n detected.relative_path &&\n !isGuardedPath(cwd) &&\n hasProjectSignals(cwd)\n ) {\n // The CWD is inside a broader registered parent (e.g. \"i052341\" or \"apps\"),\n // but it has its own project signals \u2014 register it as a distinct project.\n console.error(\n `PAI detect: parent match to \"${detected.slug}\" via relative path \"${detected.relative_path}\" \u2014 CWD looks like its own project, attempting auto-registration`\n );\n const autoRegistered = await tryAutoRegister();\n\n if (!autoRegistered) {\n // Fall through: show the parent match as normal\n const name = detected.display_name || detected.slug;\n const nameSlug = ` (slug: ${detected.slug})`;\n const matchDesc = `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: kept parent match \"${detected.slug}\" (auto-register not applicable)`);\n }\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 // 8.5. INJECT THE PAUSE CHECKPOINT\n //\n // `pai pause` writes the handover to TODO.md under `## Continue`. Until now\n // nothing read it back: the session-commands hook only told the model to look\n // there *if the user typed \"go\"*, so a resumed session started blind unless\n // the user knew the magic word. Writing a handover nobody delivers is not a\n // handover. Inject it, and say plainly that it is the previous session's\n // state rather than an instruction to act on.\n if (existsSync(todoPath)) {\n try {\n const checkpoint = readContinueCheckpoint(readFileSync(todoPath, 'utf-8'));\n if (checkpoint) {\n const from = checkpoint.meta?.session\n ? `\\nFrom session: ${checkpoint.meta.session}`\n : '';\n const when = checkpoint.meta?.ts ? `\\nPaused at: ${checkpoint.meta.ts}` : '';\n const resume = checkpoint.meta?.sessionId\n ? `\\nResume that session with: claude --resume ${checkpoint.meta.sessionId}`\n : '';\n\n console.log(`\n<system-reminder>\nHANDOVER FROM THE PREVIOUS SESSION\n\nSource: ${todoPath} (## Continue)${from}${when}${resume}\n\n${checkpoint.body}\n\n---\nThis is recorded state, not a new instruction. Do not start acting on it\nunprompted. If the user says \"go\", \"continue\", or \"weiter\", resume from here.\n</system-reminder>\n`);\n console.error(`Injected pause checkpoint (${checkpoint.body.length} chars)`);\n } else {\n console.error('No pause checkpoint body in TODO.md \u2014 nothing to hand over');\n }\n } catch (error) {\n // Non-fatal \u2014 a malformed TODO.md must never block session start.\n console.error('Checkpoint injection failed:', error);\n }\n }\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 // 10. Inject wake-up context (L0 identity + L1 essential story) if available\n // The detected PAI project root_path is used for L1 note lookup.\n // We derive it from the `paiProjectBlock` detection result by re-running a\n // lightweight registry lookup, or by falling back to cwd for local-notes projects.\n try {\n // Attempt to find the project root path from the registry via `pai project detect --json`\n let wakeupRootPath: string | undefined;\n try {\n const { execFileSync: efs } = await import('child_process');\n const raw2 = efs(paiBin, ['project', 'detect', '--json', cwd], {\n encoding: 'utf-8',\n env: process.env,\n }).trim();\n if (raw2) {\n const det = JSON.parse(raw2) as { root_path?: string; slug?: string };\n if (det.root_path) wakeupRootPath = det.root_path;\n }\n } catch {\n // Non-fatal \u2014 fall back to cwd\n wakeupRootPath = cwd;\n }\n\n const wakeupBlock = buildWakeupContext(wakeupRootPath);\n if (wakeupBlock) {\n const wakeupReminder = `\\n<system-reminder>\\nWAKEUP CONTEXT\\n\\n${wakeupBlock}\\n</system-reminder>\\n`;\n console.log(wakeupReminder);\n console.error('Injected wake-up context (L0+L1)');\n } else {\n console.error('No wake-up context to inject (no identity file or session notes)');\n }\n } catch (wakeupError) {\n // Non-fatal \u2014 don't block session start\n console.error('Wake-up context injection failed:', wakeupError);\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 * Wake-up context system \u2014 progressive context loading inspired by mempalace.\n *\n * Layers:\n * L0 Identity (~100 tokens) \u2014 user identity from ~/.pai/identity.txt. Always loaded.\n * L1 Essential Story (~500-800t) \u2014 top session notes for the project, key lines extracted.\n * L2 On-Demand \u2014 triggered by topic queries (handled by memory_search).\n * L3 Deep Search \u2014 unlimited federated memory search (memory_search tool).\n */\n\nimport { existsSync, readdirSync, readFileSync, statSync } from \"node:fs\";\nimport { join, basename } from \"node:path\";\nimport { homedir } from \"node:os\";\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/** Maximum tokens for the L1 essential story block. Approx 4 chars/token. */\nconst L1_TOKEN_BUDGET = 800;\nconst L1_CHAR_BUDGET = L1_TOKEN_BUDGET * 4; // ~3200 chars\n\n/** Maximum session notes to scan when building L1. */\nconst L1_MAX_NOTES = 10;\n\n/** Sections to extract from session notes (in priority order). */\nconst EXTRACT_SECTIONS = [\n \"Work Done\",\n \"Key Decisions\",\n \"Next Steps\",\n \"Checkpoint\",\n];\n\n/** Identity file location. */\nconst IDENTITY_FILE = join(homedir(), \".pai\", \"identity.txt\");\n\n// ---------------------------------------------------------------------------\n// L0: Identity\n// ---------------------------------------------------------------------------\n\n/**\n * Load L0 identity from ~/.pai/identity.txt.\n * Returns the file content, or an empty string if the file does not exist.\n * Never throws.\n */\nexport function loadL0Identity(): string {\n if (!existsSync(IDENTITY_FILE)) return \"\";\n try {\n return readFileSync(IDENTITY_FILE, \"utf-8\").trim();\n } catch {\n return \"\";\n }\n}\n\n// ---------------------------------------------------------------------------\n// L1: Essential Story\n// ---------------------------------------------------------------------------\n\n/**\n * Find the Notes directory for a project given its root_path from the registry.\n * Checks local Notes/ first, then central ~/.claude/projects/... path.\n */\nfunction findNotesDirForProject(rootPath: string): string | null {\n // Check local Notes directories first\n const localCandidates = [\n join(rootPath, \"Notes\"),\n join(rootPath, \"notes\"),\n join(rootPath, \".claude\", \"Notes\"),\n ];\n for (const p of localCandidates) {\n if (existsSync(p)) return p;\n }\n\n // Fall back to central ~/.claude/projects/{encoded}/Notes\n const encoded = rootPath\n .replace(/\\//g, \"-\")\n .replace(/\\./g, \"-\")\n .replace(/ /g, \"-\");\n const centralNotes = join(\n homedir(),\n \".claude\",\n \"projects\",\n encoded,\n \"Notes\"\n );\n if (existsSync(centralNotes)) return centralNotes;\n\n return null;\n}\n\n/**\n * Recursively find all .md session note files in a Notes directory.\n * Handles both flat layout (Notes/*.md) and month-subdirectory layout\n * (Notes/YYYY/MM/*.md). Returns files sorted newest-first by filename\n * (note numbers are monotonically increasing, so lexicographic = newest-last,\n * so we reverse).\n */\nfunction findSessionNotes(notesDir: string): string[] {\n const result: string[] = [];\n\n const scanDir = (dir: string) => {\n if (!existsSync(dir)) return;\n let entries: string[];\n try {\n entries = readdirSync(dir, { withFileTypes: true } as Parameters<typeof readdirSync>[1] as any)\n .map((e: any) => ({ name: e.name, isDir: e.isDirectory() }));\n } catch {\n return;\n }\n\n for (const entry of entries as Array<{ name: string; isDir: boolean }>) {\n const fullPath = join(dir, entry.name);\n if (entry.isDir) {\n // Recurse into YYYY/MM subdirectories\n scanDir(fullPath);\n } else if (entry.name.match(/^\\d{3,4}[\\s_-].*\\.md$/)) {\n result.push(fullPath);\n }\n }\n };\n\n scanDir(notesDir);\n\n // Sort newest-first by the DATE in the filename, not the note number.\n //\n // The note number is only monotonic within one uninterrupted numbering run.\n // It restarts \u2014 per month directory, and after a registry merge renumbers a\n // project \u2014 so \"highest number\" and \"most recent\" diverge. Observed live:\n // `0184 - 2026-02-22` outranked `0008 - 2026-08-01`, and the wake-up context\n // handed a resumed session five-month-old material as its recent history.\n //\n // `NNNN - YYYY-MM-DD - Title.md` is the enforced layout, so the date is the\n // reliable key. Number breaks ties within a day; mtime is the last resort\n // for notes that predate the naming convention.\n const dateOf = (p: string): string =>\n basename(p).match(/(\\d{4}-\\d{2}-\\d{2})/)?.[1] ?? \"\";\n const numberOf = (p: string): number =>\n parseInt(basename(p).match(/^(\\d+)/)?.[1] ?? \"0\", 10);\n const mtimeOf = (p: string): number => {\n try {\n return statSync(p).mtimeMs;\n } catch {\n return 0;\n }\n };\n\n result.sort((a, b) => {\n const dateA = dateOf(a);\n const dateB = dateOf(b);\n // Undated notes sort last rather than winning on an empty string.\n if (dateA !== dateB) {\n if (!dateA) return 1;\n if (!dateB) return -1;\n return dateB.localeCompare(dateA);\n }\n const byNumber = numberOf(b) - numberOf(a);\n if (byNumber !== 0) return byNumber;\n return mtimeOf(b) - mtimeOf(a);\n });\n\n return result;\n}\n\n/**\n * Extract the most important lines from a session note.\n * Prioritises: Work Done items, Key Decisions, Next Steps, Checkpoint headings.\n * Returns a condensed string under maxChars.\n */\nfunction extractKeyLines(content: string, maxChars: number): string {\n const lines = content.split(\"\\n\");\n const selected: string[] = [];\n let inTargetSection = false;\n let currentSection = \"\";\n let charCount = 0;\n\n // First pass: collect lines from priority sections\n for (const line of lines) {\n // Detect section headers\n const h2Match = line.match(/^## (.+)$/);\n const h3Match = line.match(/^### (.+)$/);\n if (h2Match) {\n currentSection = h2Match[1];\n inTargetSection = EXTRACT_SECTIONS.some((s) =>\n currentSection.toLowerCase().includes(s.toLowerCase())\n );\n continue;\n }\n if (h3Match) {\n // Checkpoints / sub-sections \u2014 include heading as label\n if (inTargetSection) {\n const label = `[${h3Match[1]}]`;\n if (charCount + label.length < maxChars) {\n selected.push(label);\n charCount += label.length + 1;\n }\n }\n continue;\n }\n\n if (!inTargetSection) continue;\n\n // Skip blank lines and HTML comments\n const trimmed = line.trim();\n if (!trimmed || trimmed.startsWith(\"<!--\") || trimmed === \"---\") continue;\n\n // Include checkbox items, bold text, and plain text lines\n if (\n trimmed.startsWith(\"- \") ||\n trimmed.startsWith(\"* \") ||\n trimmed.match(/^\\d+\\./) ||\n trimmed.startsWith(\"**\")\n ) {\n if (charCount + trimmed.length + 1 > maxChars) break;\n selected.push(trimmed);\n charCount += trimmed.length + 1;\n }\n }\n\n return selected.join(\"\\n\");\n}\n\n/**\n * Build the L1 essential story block.\n *\n * Reads the most recent session notes for the project and extracts the key\n * lines (Work Done, Key Decisions, Next Steps) within the token budget.\n *\n * @param rootPath The project root path (from the registry).\n * @param tokenBudget Max tokens to consume. Default 800 (~3200 chars).\n * @returns Formatted L1 block, or empty string if no notes found.\n */\nexport function buildL1EssentialStory(\n rootPath: string,\n tokenBudget = L1_TOKEN_BUDGET\n): string {\n const charBudget = tokenBudget * 4;\n const notesDir = findNotesDirForProject(rootPath);\n if (!notesDir) return \"\";\n\n const noteFiles = findSessionNotes(notesDir).slice(0, L1_MAX_NOTES);\n if (noteFiles.length === 0) return \"\";\n\n const sections: string[] = [];\n let remaining = charBudget;\n\n for (const noteFile of noteFiles) {\n if (remaining <= 50) break;\n\n let content: string;\n try {\n content = readFileSync(noteFile, \"utf-8\");\n } catch {\n continue;\n }\n\n // Extract the note date and title from the filename\n const name = basename(noteFile);\n const titleMatch = name.match(/^\\d+ - (\\d{4}-\\d{2}-\\d{2}) - (.+)\\.md$/);\n const dateLabel = titleMatch ? titleMatch[1] : \"\";\n const titleLabel = titleMatch\n ? titleMatch[2]\n : name.replace(/^\\d+ - /, \"\").replace(/\\.md$/, \"\");\n\n // Skip if nothing useful extracted from this note\n const perNoteChars = Math.min(remaining, Math.floor(charBudget / noteFiles.length) + 200);\n const extracted = extractKeyLines(content, perNoteChars);\n if (!extracted) continue;\n\n const noteBlock = `[${dateLabel} - ${titleLabel}]\\n${extracted}`;\n sections.push(noteBlock);\n remaining -= noteBlock.length + 1;\n }\n\n if (sections.length === 0) return \"\";\n\n return sections.join(\"\\n\\n\");\n}\n\n// ---------------------------------------------------------------------------\n// Combined: buildWakeupContext\n// ---------------------------------------------------------------------------\n\n/**\n * Build the combined wake-up context block (L0 + L1).\n *\n * Returns a formatted string suitable for injection as a system-reminder,\n * or an empty string if both layers are empty.\n *\n * @param rootPath Project root path for L1 note lookup. Optional.\n * @param tokenBudget L1 token budget. Default 800.\n */\nexport function buildWakeupContext(\n rootPath?: string,\n tokenBudget = L1_TOKEN_BUDGET\n): string {\n const identity = loadL0Identity();\n const essentialStory = rootPath\n ? buildL1EssentialStory(rootPath, tokenBudget)\n : \"\";\n\n if (!identity && !essentialStory) return \"\";\n\n const parts: string[] = [];\n\n if (identity) {\n parts.push(`## L0 Identity\\n\\n${identity}`);\n }\n\n if (essentialStory) {\n parts.push(`## L1 Essential Story\\n\\n${essentialStory}`);\n }\n\n return parts.join(\"\\n\\n\");\n}\n", "/**\n * Shared \"## Continue\" checkpoint logic for a project's TODO.md.\n *\n * WHY THIS MODULE EXISTS\n * ----------------------\n * Before this, `pause.ts` and `handover.ts` each carried their own copy of\n * findProjectTodo / stripContinueSection / block-builder. Both wrote the same\n * fixed four-line block, and both stripped any existing ## Continue section\n * unconditionally. The consequence was that a rich, model-authored checkpoint\n * could never survive:\n *\n * 1. `pai pause` had no way to accept a body, so the model printed its\n * checkpoint to the terminal and it was lost.\n * 2. Even if a body had been written by hand, the session-stop hook runs\n * `pai session handover` on every clean exit, which regenerated the\n * generic block and erased it.\n *\n * So there are two jobs here:\n *\n * - AUTHORED writes (`pai pause --body-file`) carry the model's markdown\n * verbatim, wrapped in explicit start/end markers.\n * - AUTO writes (hooks) must never destroy an authored checkpoint belonging\n * to the *same* session. They may replace a stale one left by an earlier\n * session, otherwise TODO.md would show a checkpoint that no longer\n * describes where the work stands.\n *\n * PARSING\n * -------\n * A rich body can legitimately contain `---` rules and `##` headings, which the\n * old heuristic scanner treated as section terminators. Authored blocks are\n * therefore delimited by an explicit HTML-comment pair; the legacy heuristic is\n * kept only as a fallback for blocks written before this change.\n */\n\nimport {\n existsSync,\n readFileSync,\n writeFileSync,\n readdirSync,\n renameSync,\n mkdirSync,\n realpathSync,\n} from \"node:fs\";\nimport { join } from \"node:path\";\nimport { homedir } from \"node:os\";\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/** Locations searched for a project TODO.md, in priority order. */\nexport const TODO_LOCATIONS = [\n \"Notes/TODO.md\",\n \".claude/Notes/TODO.md\",\n \"tasks/todo.md\",\n \"TODO.md\",\n];\n\nexport const MARKER_OPEN = \"<!-- pai:checkpoint\";\nexport const MARKER_CLOSE = \"<!-- /pai:checkpoint -->\";\n\n/**\n * Markers for a handover that has been superseded but not thrown away.\n *\n * Deliberately NOT `pai:checkpoint`: `locateContinue` scans for that marker and\n * for the `## Continue` heading, so an archived block written verbatim would be\n * found as the live section and rewritten in place of it. Archived entries are\n * inert by construction \u2014 different marker, no heading.\n */\nexport const ARCHIVE_OPEN = \"<!-- pai:archived-handover\";\nexport const ARCHIVE_CLOSE = \"<!-- /pai:archived-handover -->\";\nexport const ARCHIVE_HEADING = \"## Previous handovers\";\n\n/**\n * How many superseded handovers TODO.md keeps.\n *\n * Enough that a handover survives a run of sessions that each end without\n * pausing, which is the sequence that destroyed one on 2026-08-04. Not\n * unbounded: TODO.md is read by a human at the top of every session, and a file\n * that grows without limit stops being read, which is its own kind of loss.\n */\nexport const ARCHIVE_LIMIT = 5;\n\nexport const CONTINUE_HEADING = \"## Continue\";\n\n/** Who wrote the checkpoint currently in TODO.md. */\nexport type Authorship = \"model\" | \"auto\";\n\n/** Result of applying a checkpoint. */\nexport type ApplyAction = \"written\" | \"preserved\" | \"failed\";\n\n// ---------------------------------------------------------------------------\n// TODO.md discovery\n// ---------------------------------------------------------------------------\n\nexport function findProjectTodo(\n rootPath: string\n): { path: string; content: string } | null {\n for (const rel of TODO_LOCATIONS) {\n const full = join(rootPath, rel);\n if (existsSync(full)) {\n try {\n return { path: full, content: readFileSync(full, \"utf8\") };\n } catch {\n // unreadable \u2014 try next\n }\n }\n }\n return null;\n}\n\n/**\n * Resolve the TODO.md to write to, creating Notes/ if nothing exists yet.\n * Returns null only when the directory could not be created.\n */\nexport function resolveTodoTarget(\n rootPath: string,\n opts: { create?: boolean } = {}\n): { path: string; content: string } | null {\n const found = findProjectTodo(rootPath);\n if (found) return found;\n\n const notesDir = join(rootPath, \"Notes\");\n if (opts.create !== false) {\n try {\n if (!existsSync(notesDir)) mkdirSync(notesDir, { recursive: true });\n } catch {\n return null;\n }\n }\n return { path: join(notesDir, \"TODO.md\"), content: \"\" };\n}\n\n// ---------------------------------------------------------------------------\n// Marker parsing\n// ---------------------------------------------------------------------------\n\nexport interface CheckpointMeta {\n authored: Authorship;\n /** Session line the checkpoint describes, e.g. \"0003 - 2026-08-01 - Title\". */\n session?: string;\n /** Claude Code session UUID, when known. */\n sessionId?: string;\n ts?: string;\n}\n\n/**\n * Parse a `<!-- pai:checkpoint key=\"value\" ... -->` marker line.\n * Returns null when the line is not a marker.\n */\nexport function parseMarker(line: string): CheckpointMeta | null {\n const trimmed = line.trim();\n if (!trimmed.startsWith(MARKER_OPEN)) return null;\n\n const attrs: Record<string, string> = {};\n for (const m of trimmed.matchAll(/([a-zA-Z][\\w-]*)=\"([^\"]*)\"/g)) {\n attrs[m[1]] = m[2];\n }\n\n return {\n authored: attrs.authored === \"model\" ? \"model\" : \"auto\",\n session: attrs.session || undefined,\n sessionId: attrs[\"session-id\"] || undefined,\n ts: attrs.ts || undefined,\n };\n}\n\nexport interface LocatedContinue {\n /** Index of the \"## Continue\" heading line. */\n startIdx: number;\n /** Exclusive end index, past any trailing `---` separator. */\n endIdx: number;\n meta: CheckpointMeta | null;\n /** Raw lines of the section, heading included. */\n lines: string[];\n}\n\n/**\n * Lines an auto-generated block is made of. Anything else in a section is\n * content somebody put there deliberately.\n *\n * The blockquote marker is optional on every pattern. These lines appear\n * quoted when the block builder emits them as its header, and unquoted when a\n * caller passes the same text as a BODY \u2014 which the session-stop hook does: it\n * builds `Working directory: ${cwd}` and hands it over as state. Matching only\n * the quoted form made such a body read as content, which is how a session\n * that did no work at all overwrote a real handover on 2026-08-04. A line that\n * merely restates the generated header is boilerplate wherever it sits.\n */\nconst BOILERPLATE_PATTERNS = [\n /^##\\s+Continue$/,\n /^<!--\\s*\\/?pai:checkpoint/,\n /^>?\\s*\\*\\*Last session:\\*\\*/,\n /^>?\\s*\\*\\*Paused at:\\*\\*/,\n /^>?\\s*Working directory:/,\n /^>?\\s*Resume with:/,\n /^>?\\s*_No checkpoint body was recorded/,\n /^-{3,}$/,\n];\n\n/** A blank line, with or without a blockquote marker. */\nconst BLANK_LINE = /^>?\\s*$/;\n\n/**\n * True when a section contains nothing but generated header lines.\n *\n * This is the guard that stops an auto write from destroying content it did\n * not author. A session that hit the old clobbering bug may have worked around\n * it by hand \u2014 writing its state into a subsection underneath the generated\n * header lines, in an unmarked block. Those blocks predate the marker, so\n * authorship cannot be read off them; the only safe signal is whether anything\n * beyond boilerplate is present.\n */\nexport function isBoilerplateOnly(lines: string[]): boolean {\n return lines.every((line) => {\n const t = line.trim();\n return BLANK_LINE.test(t) || BOILERPLATE_PATTERNS.some((re) => re.test(t));\n });\n}\n\n/**\n * True when a checkpoint body says something the generated header does not.\n *\n * \"Has a body\" and \"has something to say\" are not the same question, and the\n * preservation rules care only about the second. An unattended writer that\n * emits a single line restating the working directory has produced a body by\n * any string test and a handover by none.\n */\nexport function hasSubstance(body: string | null | undefined): boolean {\n const text = (body ?? \"\").trim();\n if (!text) return false;\n return !isBoilerplateOnly(text.split(\"\\n\"));\n}\n\n/**\n * Extract the non-boilerplate content of a section, or \"\" if there is none.\n *\n * Interior blank lines are kept. They are not decoration \u2014 in Markdown they\n * are what separates a paragraph from the table or list that follows, so\n * dropping them (as this did while it treated every blank line as boilerplate)\n * silently welds a checkpoint body into one unreadable run.\n */\nexport function extractSectionContent(lines: string[]): string {\n const kept: string[] = [];\n for (const line of lines) {\n const t = line.trim();\n if (BOILERPLATE_PATTERNS.some((re) => re.test(t))) continue;\n kept.push(line);\n }\n return trimBlankEdges(collapseBlankRuns(kept)).join(\"\\n\");\n}\n\n/** Drop leading and trailing blank lines, leaving interior spacing alone. */\nfunction trimBlankEdges(lines: string[]): string[] {\n let start = 0;\n let end = lines.length;\n while (start < end && BLANK_LINE.test(lines[start].trim())) start += 1;\n while (end > start && BLANK_LINE.test(lines[end - 1].trim())) end -= 1;\n return lines.slice(start, end);\n}\n\n/**\n * Collapse runs of blank lines to a single blank.\n *\n * Removing a boilerplate line leaves the blank that surrounded it behind, so\n * stripping the header can open a three-line gap in the middle of the body.\n * One blank line is all Markdown needs.\n */\nfunction collapseBlankRuns(lines: string[]): string[] {\n const out: string[] = [];\n let lastWasBlank = false;\n for (const line of lines) {\n const isBlank = BLANK_LINE.test(line.trim());\n if (isBlank && lastWasBlank) continue;\n out.push(line);\n lastWasBlank = isBlank;\n }\n return out;\n}\n\n/**\n * Locate the existing ## Continue section.\n *\n * When the section carries an explicit marker pair, the close marker defines\n * the end \u2014 this is what lets a rich body contain `---` and `##` safely.\n * Otherwise the legacy heuristic applies: stop at the first `---` or the next\n * `##` heading.\n */\nexport function locateContinue(content: string): LocatedContinue | null {\n const lines = content.split(\"\\n\");\n const startIdx = lines.findIndex((l) => l.trim() === CONTINUE_HEADING);\n if (startIdx === -1) return null;\n\n // Look for an open marker in the first few lines of the section.\n let meta: CheckpointMeta | null = null;\n let markerIdx = -1;\n for (let i = startIdx + 1; i < Math.min(startIdx + 6, lines.length); i++) {\n const parsed = parseMarker(lines[i]);\n if (parsed) {\n meta = parsed;\n markerIdx = i;\n break;\n }\n // A non-blank, non-marker line means there is no marker for this section.\n if (lines[i].trim() !== \"\") break;\n }\n\n let endIdx = lines.length;\n\n if (markerIdx !== -1) {\n const closeIdx = lines.findIndex(\n (l, i) => i > markerIdx && l.trim() === MARKER_CLOSE\n );\n if (closeIdx !== -1) {\n endIdx = closeIdx + 1;\n } else {\n // Malformed (open without close) \u2014 fall back to the heuristic so we do\n // not swallow the rest of the file.\n endIdx = heuristicEnd(lines, startIdx);\n }\n } else {\n endIdx = heuristicEnd(lines, startIdx);\n }\n\n // Consume one trailing `---` separator and the blank lines around it.\n let trailingEnd = endIdx;\n while (trailingEnd < lines.length && lines[trailingEnd].trim() === \"\") {\n trailingEnd += 1;\n }\n if (trailingEnd < lines.length && lines[trailingEnd].trim() === \"---\") {\n trailingEnd += 1;\n } else {\n trailingEnd = endIdx;\n }\n\n return {\n startIdx,\n endIdx: trailingEnd,\n meta,\n lines: lines.slice(startIdx, trailingEnd),\n };\n}\n\n/**\n * Legacy scanner for blocks written before checkpoint markers existed.\n *\n * Terminates on a horizontal rule or the next level-1 or level-2 heading. Note\n * the `(?!#)` \u2014 `###` is a *subsection* of `## Continue`, not a terminator. The\n * original scanner stopped at any run of `#`, which meant a `### Restored\n * state` subsection fell outside the section entirely and could not be seen,\n * let alone carried forward.\n *\n * `#{1,2}` rather than `##`, because matching only `##` did not stop at an H1\n * and therefore ATE IT. A TODO.md whose `## Continue` block precedes its\n * `# TODO` title had the title absorbed into the section and destroyed on the\n * next regenerate \u2014 reported independently by three sessions on 2026-08-03,\n * one of which lost it three times and recovered from git each time.\n *\n * An H1 is a document-level heading. It can never be part of a `## Continue`\n * section, so treating it as a terminator is not a heuristic improvement but a\n * structural fact.\n */\nfunction heuristicEnd(lines: string[], startIdx: number): number {\n for (let i = startIdx + 1; i < lines.length; i++) {\n const trimmed = lines[i].trim();\n if (\n trimmed === \"---\" ||\n (/^#{1,2}(?!#)/.test(trimmed) && trimmed !== CONTINUE_HEADING)\n ) {\n return i;\n }\n }\n return lines.length;\n}\n\n/** Remove the ## Continue section, returning the remainder of the document. */\nexport function stripContinue(content: string): string {\n const found = locateContinue(content);\n if (!found) return content;\n\n const lines = content.split(\"\\n\");\n const before = lines.slice(0, found.startIdx);\n const after = lines.slice(found.endIdx);\n while (after.length > 0 && after[0].trim() === \"\") after.shift();\n\n return [...before, ...after].join(\"\\n\");\n}\n\n// ---------------------------------------------------------------------------\n// Reading a checkpoint back\n// ---------------------------------------------------------------------------\n\nexport interface ContinueCheckpoint {\n meta: CheckpointMeta | null;\n /** The authored body \u2014 the section with generated header lines removed. */\n body: string;\n /** The full section, heading included. */\n raw: string;\n}\n\n/**\n * Read the `## Continue` checkpoint out of a TODO.md.\n *\n * Writing a checkpoint is only half of a handover; something has to deliver it\n * to the next session. This is the read side, used by the SessionStart hook.\n *\n * Returns null when there is no section, or when the section holds nothing but\n * generated header lines \u2014 a bodyless block carries no information a new\n * session does not already have, and injecting it would only add noise.\n */\nexport function readContinueCheckpoint(\n content: string\n): ContinueCheckpoint | null {\n const found = locateContinue(content);\n if (!found) return null;\n\n const body = found.meta\n ? extractMarkedBody(found.lines)\n : extractSectionContent(found.lines);\n if (!body) return null;\n\n return { meta: found.meta, body, raw: found.lines.join(\"\\n\") };\n}\n\n/**\n * Extract the body of a marker-delimited block by position rather than by\n * pattern.\n *\n * The layout is fixed: open marker, a contiguous run of `>` header lines, the\n * body, then the close marker. Because the boundaries are known exactly, the\n * body comes back byte-for-byte \u2014 including any `---` rules or `##` headings\n * of its own, which a pattern-based filter would mistake for boilerplate and\n * delete. Falls back to the pattern filter if the shape is not as expected.\n */\nfunction extractMarkedBody(lines: string[]): string {\n const markerIdx = lines.findIndex((l) => parseMarker(l) !== null);\n if (markerIdx === -1) return extractSectionContent(lines);\n\n const closeIdx = lines.findIndex(\n (l, i) => i > markerIdx && l.trim() === MARKER_CLOSE\n );\n if (closeIdx === -1) return extractSectionContent(lines);\n\n // Skip the blank lines and the `>` header run that follow the open marker.\n let bodyStart = markerIdx + 1;\n while (bodyStart < closeIdx) {\n const t = lines[bodyStart].trim();\n if (BLANK_LINE.test(t) || t.startsWith(\">\")) {\n bodyStart += 1;\n continue;\n }\n break;\n }\n\n return trimBlankEdges(lines.slice(bodyStart, closeIdx)).join(\"\\n\");\n}\n\n// ---------------------------------------------------------------------------\n// Block construction\n// ---------------------------------------------------------------------------\n\nexport interface BuildOptions {\n authored: Authorship;\n /** Human-readable session line, or \"Unknown session\". */\n sessionLine: string;\n /** Claude Code session UUID \u2014 the `claude --resume` handle. */\n sessionId?: string;\n cwd: string;\n /** Model-authored markdown. Omitted for auto blocks. */\n body?: string;\n /** Overridable for deterministic tests. */\n timestamp?: string;\n}\n\nfunction escapeAttr(value: string): string {\n return value.replace(/\"/g, \"'\");\n}\n\nexport function buildContinueBlock(opts: BuildOptions): string {\n const ts = opts.timestamp ?? new Date().toISOString();\n\n const attrs = [\n `authored=\"${opts.authored}\"`,\n `session=\"${escapeAttr(opts.sessionLine)}\"`,\n opts.sessionId ? `session-id=\"${escapeAttr(opts.sessionId)}\"` : null,\n `ts=\"${ts}\"`,\n ]\n .filter(Boolean)\n .join(\" \");\n\n const header = [\n `> **Last session:** ${opts.sessionLine}`,\n `> **Paused at:** ${ts}`,\n \">\",\n `> Working directory: ${opts.cwd}`,\n ];\n\n if (opts.sessionId) {\n header.push(\">\", `> Resume with: \\`claude --resume ${opts.sessionId}\\``);\n }\n\n const body = (opts.body ?? \"\").trim();\n\n const parts = [\n CONTINUE_HEADING,\n \"\",\n `${MARKER_OPEN} ${attrs} -->`,\n \"\",\n ...header,\n ];\n\n if (body) {\n parts.push(\"\", body);\n } else {\n parts.push(\n \">\",\n \"> _No checkpoint body was recorded \u2014 see the latest session note._\"\n );\n }\n\n parts.push(\"\", MARKER_CLOSE, \"\", \"---\", \"\");\n\n return parts.join(\"\\n\");\n}\n\n// ---------------------------------------------------------------------------\n// Apply\n// ---------------------------------------------------------------------------\n\nexport interface ApplyOptions extends BuildOptions {\n /** Project root; TODO.md is resolved beneath it. */\n rootPath: string;\n /** Preview only \u2014 nothing is written. */\n dryRun?: boolean;\n}\n\nexport interface ApplyResult {\n action: ApplyAction;\n path: string | null;\n block: string;\n /** Set when action === \"preserved\". */\n preservedMeta?: CheckpointMeta;\n /** Set when unattributed content was carried forward into the new block. */\n carriedForward?: boolean;\n /** Set when a superseded model-authored handover was moved to the archive. */\n archived?: boolean;\n error?: string;\n}\n\n/**\n * Write the ## Continue block, honouring the preservation rules.\n *\n * An AUTO write is unattended \u2014 it fires from the session-stop and pre-compact\n * hooks \u2014 so it operates under one governing rule: **never destroy content it\n * did not author.** Three cases follow from that:\n *\n * 1. An authored checkpoint for the SAME session is left untouched. The hooks\n * fire after the model has already recorded the real state; overwriting it\n * with metadata is the bug this module exists to fix.\n *\n * \"Same session\" is decided by the Claude session UUID whenever both sides\n * know it, and only falls back to the human-readable session line when one\n * of them does not. The line is derived from the session note filename,\n * and `session-stop.sh` *renames and renumbers that file* \u2014 via `session\n * slug --apply` and `session cleanup --execute` \u2014 before it reaches the\n * handover step. So the key the hook computes at exit is not the key the\n * model wrote seconds earlier, and a filename-keyed comparison mismatches\n * by construction. Observed live on 2026-08-01: notes renumbered twice\n * within a single session. The UUID is the only identifier that holds\n * still.\n * 2. An authored checkpoint from an EARLIER session is stale \u2014 TODO.md would\n * otherwise keep pointing at the wrong session \u2014 so it is replaced. Its\n * content is not lost: `pai pause` mirrors every authored body into the\n * session note.\n * 3. An UNMARKED block predates the marker, so authorship cannot be read off\n * it. If it is nothing but generated header lines it is replaced. If it\n * carries anything else, that content was put there deliberately \u2014 quite\n * possibly as a hand-rolled workaround for the very clobbering this fixes \u2014\n * and is carried forward into the new block rather than dropped.\n *\n * A MODEL write always replaces: the model is authoring the checkpoint, and a\n * newer one supersedes an older one.\n */\n/**\n * Do an existing checkpoint and an incoming write describe the same session?\n *\n * The UUID is authoritative when both sides carry one: it is assigned by Claude\n * Code and never changes for the life of the session. The session line is a\n * derived, mutable label and is only consulted when there is no UUID to compare\n * \u2014 a checkpoint written before `--session-id` was threaded through, or an auto\n * write from a caller that was not given one.\n */\nfunction isSameSession(\n meta: CheckpointMeta,\n opts: Pick<ApplyOptions, \"sessionId\" | \"sessionLine\">\n): boolean {\n if (meta.sessionId && opts.sessionId) {\n return meta.sessionId === opts.sessionId;\n }\n return meta.session === opts.sessionLine;\n}\n\n/**\n * How long a model-authored checkpoint is protected from automated overwriting.\n *\n * Sized for the race, not for the session: the gap between a model writing its\n * checkpoint and a hook firing is seconds to minutes, and an hour covers even a\n * slow checkpoint on a loaded machine. Anything genuinely from an earlier\n * session is hours or days old and still falls through to case 2, which is what\n * keeps TODO.md from freezing on a checkpoint nobody will ever replace.\n */\nconst AUTHORED_GRACE_MS = 60 * 60 * 1000;\n\n/** Was this checkpoint written recently enough to still belong to the run in progress? */\nfunction isRecent(meta: CheckpointMeta, now?: string): boolean {\n if (!meta.ts) return false; // no stamp, no claim \u2014 case 2 decides as before\n const then = Date.parse(meta.ts);\n if (Number.isNaN(then)) return false;\n const nowMs = now ? Date.parse(now) : Date.now();\n if (Number.isNaN(nowMs)) return false;\n // Guard the future too: a clock skew that puts the stamp ahead of now must\n // not read as \"very old\" and license an overwrite.\n return nowMs - then < AUTHORED_GRACE_MS;\n}\n\n/**\n * Turn a live checkpoint block into an inert archive entry.\n *\n * Two things have to go: the `## Continue` heading and the `pai:checkpoint`\n * marker. Both are what `locateContinue` looks for, so leaving either in place\n * would let a later write treat the archive as the live section \u2014 and the next\n * one after that would then archive the archive. Everything else is kept\n * verbatim, because the body is the whole reason this exists.\n */\nfunction toArchiveEntry(lines: string[], meta: CheckpointMeta | null): string[] {\n const out: string[] = [];\n const label = meta?.session ?? \"Unknown session\";\n const stamp = meta?.ts ? ` \u2014 checkpointed ${meta.ts}` : \"\";\n out.push(`${ARCHIVE_OPEN} session=\"${label}\"${meta?.ts ? ` ts=\"${meta.ts}\"` : \"\"} -->`);\n out.push(\"\");\n out.push(`### ${label}${stamp}`);\n out.push(\"\");\n for (const line of lines) {\n if (line.trim() === CONTINUE_HEADING) continue;\n if (line.trim().startsWith(MARKER_OPEN)) continue;\n if (line.trim() === MARKER_CLOSE) continue;\n if (line.trim() === \"---\") continue;\n out.push(line);\n }\n while (out.length > 0 && out[out.length - 1].trim() === \"\") out.pop();\n out.push(\"\");\n out.push(ARCHIVE_CLOSE);\n return out;\n}\n\n/**\n * Put a superseded handover into the archive section, newest first.\n *\n * `rest` is the document with the Continue section already stripped. The\n * archive lives immediately below it so a reader meets the current handover\n * first and the previous ones directly after, rather than hunting for them at\n * the bottom of a file that also holds the project's open work.\n */\nfunction archiveInto(rest: string, entry: string[]): string {\n const lines = rest.split(\"\\n\");\n const headingIdx = lines.findIndex((l) => l.trim() === ARCHIVE_HEADING);\n\n if (headingIdx === -1) {\n return [ARCHIVE_HEADING, \"\", ...entry, \"\", \"---\", \"\", rest.trimStart()].join(\"\\n\");\n }\n\n // Insert directly under the heading, then drop whatever falls past the cap.\n const before = lines.slice(0, headingIdx + 1);\n const after = lines.slice(headingIdx + 1);\n while (after.length > 0 && after[0].trim() === \"\") after.shift();\n\n const merged = [...entry, \"\", ...after];\n const kept: string[] = [];\n let seen = 0;\n for (let i = 0; i < merged.length; i++) {\n if (merged[i].trim().startsWith(ARCHIVE_OPEN)) {\n seen += 1;\n if (seen > ARCHIVE_LIMIT) {\n // Everything from here to the end of THIS entry goes; keep scanning so\n // anything after the archive section (other headings, open work) stays.\n while (i < merged.length && merged[i].trim() !== ARCHIVE_CLOSE) i++;\n continue; // skips the close marker too\n }\n }\n kept.push(merged[i]);\n }\n return [...before, \"\", ...kept].join(\"\\n\");\n}\n\nexport function applyContinue(opts: ApplyOptions): ApplyResult {\n const target = resolveTodoTarget(opts.rootPath, { create: !opts.dryRun });\n if (!target) {\n return {\n action: \"failed\",\n path: null,\n block: buildContinueBlock(opts),\n error: \"Could not resolve or create a TODO.md target\",\n };\n }\n\n const existing = locateContinue(target.content);\n let carriedForward = false;\n let effectiveBody = opts.body;\n let archiveEntry: string[] | null = null;\n\n if (opts.authored === \"auto\" && existing) {\n // Case 1 \u2014 authored, same session: hands off.\n if (existing.meta?.authored === \"model\" && isSameSession(existing.meta, opts)) {\n return {\n action: \"preserved\",\n path: target.path,\n block: buildContinueBlock(opts),\n preservedMeta: existing.meta,\n };\n }\n\n // Case 1b \u2014 the incoming write has nothing to say, and what is already\n // there does. A metadata-only block says \"see the latest session note\", so\n // replacing a real handover with one trades content for a pointer \u2014 and the\n // pointer is not always good: observed live on 2026-08-01 in the AIBroker\n // project, where the stop hook's bodyless handover overwrote the autosave's\n // body and named a session note that had never been created, leaving the\n // next session nothing to resume from.\n //\n // Deferring to the session note is only safe when the note demonstrably has\n // the content. This code cannot see that, so it does the one thing that is\n // never wrong: a write with nothing to say does not get to destroy\n // something that does. A stale-but-real handover beats a fresh dead link.\n // Scoped to blocks we marked. An unmarked block falls through to case 3,\n // which salvages its content into the new block rather than freezing the\n // old one \u2014 better, because an unmarked block has no session metadata worth\n // preserving and may predate this scheme entirely.\n //\n // The test is SUBSTANCE, not emptiness. It was emptiness until 2026-08-04,\n // when a session that was opened and immediately exited destroyed the\n // previous day's handover for every project it touched. Its stop hook found\n // no work items and no completion message, so it sent the one line it\n // always builds unconditionally \u2014 `Working directory: \u2026`, a verbatim copy\n // of a line the header already generates. Non-empty by a string test, so\n // this guard stood down; worthless by any other reading. `hasSubstance`\n // asks the question the comment above always claimed to be asking.\n if (\n existing.meta &&\n !hasSubstance(opts.body) &&\n !isBoilerplateOnly(existing.lines)\n ) {\n return {\n action: \"preserved\",\n path: target.path,\n block: buildContinueBlock(opts),\n preservedMeta: existing.meta ?? undefined,\n };\n }\n\n // Case 2b \u2014 a model checkpoint written moments ago is THIS session's,\n // whatever the identity comparison says.\n //\n // Case 2 deliberately lets an auto write replace an authored checkpoint\n // from an EARLIER session, and that policy is right: TODO.md would freeze\n // otherwise, and `pai pause` mirrors authored bodies into the session note.\n // The bug is not the policy, it is that identity DEGRADES and healthy\n // sessions fall into it.\n //\n // `sessionId` is optional on `pai pause`. Without it isSameSession compares\n // a display line containing the note TITLE \u2014 and pausing renames the note.\n // So a session writes a checkpoint, its note is renamed, the hook fires\n // seconds later, no longer recognises its own work, and classifies it as a\n // stale earlier session. Three sessions reported exactly this on\n // 2026-08-03 from one `pai pause all`; one lost its checkpoint three times\n // and recovered from git each time. Sessions without a clean repo would\n // never have known it happened.\n //\n // Recency is the tiebreaker that identity cannot supply. A model checkpoint\n // minutes old is not a stale predecessor by any reading, so an automated\n // digest does not get to overwrite it. Older ones still fall through to\n // case 2 and are replaced as before, which is what stops TODO.md freezing\n // and keeps this from nesting carried-forward blocks without end.\n if (\n existing.meta?.authored === \"model\" &&\n isRecent(existing.meta, opts.timestamp) &&\n !isBoilerplateOnly(existing.lines)\n ) {\n return {\n action: \"preserved\",\n path: target.path,\n block: buildContinueBlock(opts),\n preservedMeta: existing.meta,\n };\n }\n\n // Case 2c \u2014 an authored handover from an EARLIER session, past the grace\n // window. Case 2 deleted it, on the reasoning that TODO.md must not freeze\n // and that `pai pause` mirrors authored bodies into the session note.\n //\n // The first half is right. The second is an assumption, and on 2026-08-04\n // it was false in the plainest way available: this project keeps no session\n // notes at all (`Notes/*` is gitignored and none exist on disk), so the\n // checkpoint WAS the only copy. It went at 08:36:29Z, to the autosave of a\n // session that had been open for sixteen minutes and had typed one line \u2014\n // destroying, as its first act, the handover it had been started to read.\n //\n // This is also the escape the v0.27.2 guard could not close: that one asks\n // whether the block is RECENT, and a handover written the night before is\n // not. Age was never the question. Nothing here needs the old block gone;\n // it only needs the slot. So the block moves down the file instead, and the\n // freeze argument and the loss argument stop being in tension.\n //\n // Only for `model` blocks with something in them. An auto block is a\n // transcript scrape that regenerates on the next tick, and archiving those\n // would bury the real handovers under mechanical noise.\n if (\n existing.meta?.authored === \"model\" &&\n !isBoilerplateOnly(existing.lines) &&\n !isSameSession(existing.meta, opts)\n ) {\n archiveEntry = toArchiveEntry(existing.lines, existing.meta);\n }\n\n // Case 3 \u2014 unmarked block holding content nobody can attribute to us.\n if (!existing.meta && !isBoilerplateOnly(existing.lines)) {\n const salvaged = extractSectionContent(existing.lines);\n if (salvaged) {\n effectiveBody = [\n \"_Carried forward from the previous checkpoint (author unknown \u2014 this\",\n \"block predates checkpoint authorship markers):_\",\n \"\",\n salvaged,\n ].join(\"\\n\");\n carriedForward = true;\n }\n }\n }\n\n const block = buildContinueBlock({ ...opts, body: effectiveBody });\n\n if (opts.dryRun) {\n return { action: \"written\", path: target.path, block, carriedForward, archived: !!archiveEntry };\n }\n\n const rest = stripContinue(target.content).trimStart();\n const newContent = block + (archiveEntry ? archiveInto(rest, archiveEntry) : rest);\n const tmpPath = `${target.path}.continue.tmp`;\n\n try {\n writeFileSync(tmpPath, newContent, \"utf8\");\n renameSync(tmpPath, target.path);\n } catch (err) {\n try {\n if (existsSync(tmpPath)) renameSync(tmpPath, `${tmpPath}.dead`);\n } catch {\n /* ignore */\n }\n return {\n action: \"failed\",\n path: target.path,\n block,\n error: String(err),\n };\n }\n\n return { action: \"written\", path: target.path, block, carriedForward, archived: !!archiveEntry };\n}\n\n// ---------------------------------------------------------------------------\n// Session-note discovery\n//\n// Lifted verbatim from end.ts so pause, end and any future caller share one\n// implementation. end.ts now imports these rather than carrying its own copy.\n// ---------------------------------------------------------------------------\n\n/** PAI_DIR \u2014 mirrors pai-paths.ts resolution. */\nfunction getPaiDir(): string {\n const envDir = process.env.PAI_DIR;\n if (envDir) {\n try {\n return realpathSync(envDir);\n } catch {\n return envDir;\n }\n }\n return join(homedir(), \".claude\");\n}\n\n/**\n * Find the notes directory for a project \u2014 local first, then the central\n * ~/.claude/projects/<encoded>/Notes fallback. Never creates.\n */\nexport function findNotesDir(\n rootPath: string,\n encodedDir: string\n): string | null {\n for (const rel of [\"Notes\", \"notes\", \".claude/Notes\"]) {\n const p = join(rootPath, rel);\n if (existsSync(p)) return p;\n }\n const central = join(getPaiDir(), \"projects\", encodedDir, \"Notes\");\n if (existsSync(central)) return central;\n return null;\n}\n\n/**\n * Find the current (highest-numbered) session note: current month, then the\n * previous month, then a flat notesDir as legacy fallback.\n */\nexport function findLatestNote(notesDir: string): string | null {\n const findIn = (dir: string): string | null => {\n if (!existsSync(dir)) return null;\n let files: string[];\n try {\n files = readdirSync(dir);\n } catch {\n return null;\n }\n const notes = files\n .filter((f) => /^\\d{3,4}[\\s_-].*\\.md$/.test(f))\n .sort((a, b) => {\n const na = parseInt(a.match(/^(\\d+)/)?.[1] ?? \"0\", 10);\n const nb = parseInt(b.match(/^(\\d+)/)?.[1] ?? \"0\", 10);\n return na - nb;\n });\n return notes.length > 0 ? join(dir, notes[notes.length - 1]) : null;\n };\n\n const now = new Date();\n const year = String(now.getFullYear());\n const month = String(now.getMonth() + 1).padStart(2, \"0\");\n\n const current = findIn(join(notesDir, year, month));\n if (current) return current;\n\n const prev = new Date(now.getFullYear(), now.getMonth() - 1, 1);\n const py = String(prev.getFullYear());\n const pm = String(prev.getMonth() + 1).padStart(2, \"0\");\n const prevFound = findIn(join(notesDir, py, pm));\n if (prevFound) return prevFound;\n\n return findIn(notesDir);\n}\n\n// ---------------------------------------------------------------------------\n// Session-note append\n// ---------------------------------------------------------------------------\n\n/**\n * Append the checkpoint body to a session note.\n *\n * TODO.md's ## Continue is a single slot that every later checkpoint\n * overwrites. The session note is the durable record, so the body goes to both.\n * Idempotent per timestamp: re-running with the same stamp will not duplicate.\n */\nexport function appendCheckpointToNote(\n notePath: string,\n body: string,\n timestamp?: string\n): { appended: boolean; error?: string } {\n const ts = timestamp ?? new Date().toISOString();\n const heading = `## Pause Checkpoint \u2014 ${ts}`;\n\n let existing: string;\n try {\n existing = readFileSync(notePath, \"utf8\");\n } catch (err) {\n return { appended: false, error: String(err) };\n }\n\n if (existing.includes(heading)) return { appended: false };\n\n const block = `\\n\\n---\\n\\n${heading}\\n\\n${body.trim()}\\n`;\n const tmpPath = `${notePath}.checkpoint.tmp`;\n\n try {\n writeFileSync(tmpPath, existing.trimEnd() + block, \"utf8\");\n renameSync(tmpPath, notePath);\n } catch (err) {\n try {\n if (existsSync(tmpPath)) renameSync(tmpPath, `${tmpPath}.dead`);\n } catch {\n /* ignore */\n }\n return { appended: false, error: String(err) };\n }\n\n return { appended: true };\n}\n\n// ---------------------------------------------------------------------------\n// Body loading\n// ---------------------------------------------------------------------------\n\n/** Read a checkpoint body from a file, or from stdin when path is \"-\". */\nexport function readBodyFile(path: string): string {\n if (path === \"-\") {\n return readFileSync(0, \"utf8\");\n }\n return readFileSync(path, \"utf8\");\n}\n", "/**\n * Path utilities \u2014 encoding, Notes/Sessions directory discovery and creation.\n */\n\nimport { existsSync, mkdirSync, readdirSync, linkSync, copyFileSync } from 'fs';\nimport { join, basename } from 'path';\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 * Directories known to be automated health-check / probe sessions.\n * Hooks should exit early for these to avoid registry clutter and wasted work.\n */\nconst PROBE_CWD_PATTERNS = [\n '/CodexBar/ClaudeProbe',\n '/ClaudeProbe',\n];\n\n/**\n * Check if the current working directory belongs to a probe/health-check session.\n * Returns true if hooks should skip this session entirely.\n */\nexport function isProbeSession(cwd?: string): boolean {\n const dir = cwd || process.cwd();\n return PROBE_CWD_PATTERNS.some(pattern => dir.includes(pattern));\n}\n\n/**\n * Encode a path the same way Claude Code does:\n * - Replace / with -\n * - Replace . with -\n * - Replace space with -\n */\nexport function encodePath(path: string): string {\n return path\n .replace(/\\//g, '-')\n .replace(/\\./g, '-')\n .replace(/ /g, '-');\n}\n\n/** Get the project directory for a given working directory. */\nexport function getProjectDir(cwd: string): string {\n const encoded = encodePath(cwd);\n return join(PROJECTS_DIR, encoded);\n}\n\n/** Get the Notes directory for a project (central location). */\nexport function getNotesDir(cwd: string): string {\n return join(getProjectDir(cwd), 'Notes');\n}\n\n/**\n * Find Notes directory \u2014 checks local first, falls back to central.\n * Does NOT create the directory.\n */\nexport function findNotesDir(cwd: string): { path: string; isLocal: boolean } {\n const cwdBasename = basename(cwd).toLowerCase();\n if (cwdBasename === 'notes' && existsSync(cwd)) {\n return { path: cwd, isLocal: true };\n }\n\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 return { path: getNotesDir(cwd), isLocal: false };\n}\n\n/** Get the sessions/ directory for a project (stores .jsonl transcripts). */\nexport function getSessionsDir(cwd: string): string {\n return join(getProjectDir(cwd), 'sessions');\n}\n\n/** Get the sessions/ directory from a project directory path. */\nexport function getSessionsDirFromProjectDir(projectDir: string): string {\n return join(projectDir, 'sessions');\n}\n\n// ---------------------------------------------------------------------------\n// Directory creation helpers\n// ---------------------------------------------------------------------------\n\n/** Ensure the Notes directory exists for a project. @deprecated Use ensureNotesDirSmart() */\nexport function ensureNotesDir(cwd: string): string {\n const notesDir = getNotesDir(cwd);\n if (!existsSync(notesDir)) {\n mkdirSync(notesDir, { recursive: true });\n console.error(`Created Notes directory: ${notesDir}`);\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 */\nexport function ensureNotesDirSmart(cwd: string): { path: string; isLocal: boolean } {\n const found = findNotesDir(cwd);\n if (found.isLocal) return found;\n if (!existsSync(found.path)) {\n mkdirSync(found.path, { recursive: true });\n console.error(`Created central Notes directory: ${found.path}`);\n }\n return found;\n}\n\n/** Ensure the sessions/ directory exists for a project. */\nexport function ensureSessionsDir(cwd: string): string {\n const sessionsDir = getSessionsDir(cwd);\n if (!existsSync(sessionsDir)) {\n mkdirSync(sessionsDir, { recursive: true });\n console.error(`Created sessions directory: ${sessionsDir}`);\n }\n return sessionsDir;\n}\n\n/** Ensure the sessions/ directory exists (from project dir path). */\nexport function ensureSessionsDirFromProjectDir(projectDir: string): string {\n const sessionsDir = getSessionsDirFromProjectDir(projectDir);\n if (!existsSync(sessionsDir)) {\n mkdirSync(sessionsDir, { recursive: true });\n console.error(`Created sessions directory: ${sessionsDir}`);\n }\n return sessionsDir;\n}\n\n/**\n * Publish every project-root .jsonl into sessions/ as well, WITHOUT removing it.\n *\n * This used to renameSync, and that is how PAI destroyed its users' sessions.\n *\n * `claude --resume <uuid>` finds a transcript only at the project root. Move it\n * into sessions/ and the session becomes permanently unresumable \u2014 measured\n * 2026-08-04: `claude --resume b3462801` (867 KB of real work, sessions/ only)\n * answers \"No conversation found with session ID\", while a top-level id is found\n * fine. Nothing warned; the id still looked valid everywhere PAI displayed it.\n *\n * The damage was not occasional. This ran from a UserPromptSubmit hook excluding\n * only the CURRENT session, so every prompt anyone typed unresumed every other\n * session in the project. One PAI project measured 1 transcript at top level\n * against 52 underneath. Among the casualties was 046bb712 \u2014 the exact id PAI's\n * own handover tells the user to resume.\n *\n * A hardlink satisfies both sides, which is why this is a two-line fix rather\n * than a redesign: the archive genuinely has consumers that read sessions/\n * (session-summary-worker, registry/moved, session/autosave), and `--resume`\n * needs the root path. One inode, two names, no copy, no window where the file\n * is missing from either place.\n *\n * Never unlink the source. Tidying up another tool's store was the whole\n * mistake; a stale duplicate is free, a lost session is not. Every caller of\n * `transcriptFiles()` was checked before choosing this \u2014 they all test emptiness\n * (`.length > 0`), never count, so the duplicate cannot skew a project's stats.\n *\n * `excludeFile` keeps a hook from archiving the transcript it is itself watching\n * being written. It is not a \"finished sessions only\" guard and must not be read\n * as one: it excludes exactly one file, the caller's own, so with two sessions\n * live in one project each still archives the other mid-turn. A consumer that\n * needs \"finished only\" has to enforce that itself \u2014 this function cannot know\n * what is live.\n *\n * Returns the number of files newly archived.\n */\nexport function archiveSessionFilesToSessionsDir(\n projectDir: string,\n excludeFile?: string,\n silent = false\n): number {\n const sessionsDir = ensureSessionsDirFromProjectDir(projectDir);\n\n if (!existsSync(projectDir)) return 0;\n\n const files = readdirSync(projectDir);\n let archivedCount = 0;\n\n for (const file of files) {\n if (!file.endsWith('.jsonl') || file === excludeFile) continue;\n\n const sourcePath = join(projectDir, file);\n const destPath = join(sessionsDir, file);\n\n // Already archived \u2014 including by an earlier rename, before this was a\n // hardlink. Those are the sessions that need restoring to the root, which is\n // a separate repair and not this function's job.\n if (existsSync(destPath)) continue;\n\n try {\n linkSync(sourcePath, destPath);\n if (!silent) console.error(`Archived ${file} \u2192 sessions/ (still resumable)`);\n archivedCount++;\n } catch (error) {\n // Cross-device (EXDEV) is the realistic failure: sessions/ on another\n // volume. Copy instead, and still leave the original alone.\n try {\n copyFileSync(sourcePath, destPath);\n if (!silent) console.error(`Copied ${file} \u2192 sessions/ (hardlink unavailable)`);\n archivedCount++;\n } catch {\n if (!silent) console.error(`Could not archive ${file}: ${error}`);\n }\n }\n }\n\n return archivedCount;\n}\n\n/**\n * @deprecated Renamed to `archiveSessionFilesToSessionsDir`, which is what it\n * now does. Kept so an out-of-tree caller fails loudly at the type level rather\n * than silently keeping the old destructive name for a non-destructive action.\n */\nexport const moveSessionFilesToSessionsDir = archiveSessionFilesToSessionsDir;\n\n// ---------------------------------------------------------------------------\n// CLAUDE.md / TODO.md discovery\n// ---------------------------------------------------------------------------\n\n/** Find TODO.md \u2014 check local first, fallback to central. */\nexport function findTodoPath(cwd: string): string {\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)) return path;\n }\n\n return join(getNotesDir(cwd), 'TODO.md');\n}\n\n/** Find CLAUDE.md \u2014 returns the FIRST found path. */\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 in priority order.\n */\nexport function findAllClaudeMdPaths(cwd: string): string[] {\n const foundPaths: string[] = [];\n\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)) foundPaths.push(path);\n }\n\n return foundPaths;\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", "/**\n * Push notification helpers \u2014 WhatsApp-aware with ntfy.sh fallback.\n */\n\nimport { existsSync, readFileSync } from 'fs';\nimport { join } from 'path';\nimport { homedir } from 'os';\n\n/**\n * Check if a messaging MCP server (AIBroker, Whazaa, or Telex) is configured.\n * When any messaging server is active, the AI handles notifications via MCP\n * and ntfy is skipped to avoid duplicates.\n */\nexport function isWhatsAppEnabled(): boolean {\n try {\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('aibroker') || enabled.includes('whazaa') || enabled.includes('telex');\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.\n * When WhatsApp is NOT configured, ntfy fires as the fallback channel.\n */\nexport async function sendNtfyNotification(message: string, retries = 2): Promise<boolean> {\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 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 * Session note creation, editing, checkpointing, renaming, and finalization.\n */\n\nimport { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync } from 'fs';\nimport { join, basename } from 'path';\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\n/** Get or create the YYYY/MM subdirectory for the current month inside notesDir. */\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// Public API\n// ---------------------------------------------------------------------------\n\n/**\n * Get the next note number (4-digit format: 0001, 0002, etc.).\n * Numbers are scoped per YYYY/MM directory.\n */\nexport function getNextNoteNumber(notesDir: string): string {\n const monthDir = getMonthDir(notesDir);\n\n const files = readdirSync(monthDir)\n .filter(f => f.match(/^\\d{3,4}[\\s_-]/))\n .sort();\n\n if (files.length === 0) return '0001';\n\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 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 current month \u2192 previous month \u2192 flat notesDir (legacy).\n */\nexport function getCurrentNotePath(notesDir: string): string | null {\n if (!existsSync(notesDir)) return null;\n\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 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 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 return findLatestIn(notesDir);\n}\n\n/**\n * Create a new session note.\n * Format: \"NNNN - YYYY-MM-DD - New Session.md\" filed into YYYY/MM subdirectory.\n * Claude MUST rename at session end with a meaningful description.\n */\nexport function createSessionNote(notesDir: string, description: string): string {\n const noteNumber = getNextNoteNumber(notesDir);\n const date = new Date().toISOString().split('T')[0];\n const monthDir = getMonthDir(notesDir);\n const filename = `${noteNumber} - ${date} - New Session.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/** Append a checkpoint to the current session note. */\nexport function appendCheckpoint(notePath: string, checkpoint: string): void {\n if (!existsSync(notePath)) {\n console.error(`Note file not found, recreating: ${notePath}`);\n try {\n const parentDir = join(notePath, '..');\n if (!existsSync(parentDir)) mkdirSync(parentDir, { recursive: true });\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 const nextStepsIndex = content.indexOf('## Next Steps');\n const newContent = nextStepsIndex !== -1\n ? content.substring(0, nextStepsIndex) + checkpointText + content.substring(nextStepsIndex)\n : content + checkpointText;\n\n writeFileSync(notePath, newContent);\n console.error(`Checkpoint added to: ${basename(notePath)}`);\n}\n\n/** Work item for session notes. */\nexport interface WorkItem {\n title: string;\n details?: string[];\n completed?: boolean;\n}\n\n/** Add work items to the \"Work Done\" section of a session note. */\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 let workText = '';\n if (sectionTitle) workText += `\\n### ${sectionTitle}\\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 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 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 * Check if a candidate title is meaningless / garbage.\n * Public wrapper around the internal filter for use by other hooks.\n */\nexport function isMeaningfulTitle(text: string): boolean {\n return !isMeaninglessCandidate(text);\n}\n\n/** Sanitize a string for use in a filename. */\nexport function sanitizeForFilename(str: string): string {\n return str\n .toLowerCase()\n .replace(/[^a-z0-9\\s-]/g, '')\n .replace(/\\s+/g, '-')\n .replace(/-+/g, '-')\n .replace(/^-|-$/g, '')\n .substring(0, 50);\n}\n\n/**\n * Return true if the candidate string should be rejected as a meaningful name.\n * Rejects file paths, shebangs, timestamps, system noise, XML tags, hashes, etc.\n */\nfunction isMeaninglessCandidate(text: string): boolean {\n const t = text.trim();\n if (!t) return true;\n if (t.length < 5) return true; // too short to be meaningful\n if (t.startsWith('/') || t.startsWith('~')) return true; // file path\n if (t.startsWith('#!')) return true; // shebang\n if (t.includes('[object Object]')) return true; // serialization artifact\n if (/^\\d{4}-\\d{2}-\\d{2}(T[\\d:.Z+-]+)?$/.test(t)) return true; // ISO timestamp\n if (/^\\d{1,2}:\\d{2}(:\\d{2})?(\\s*(AM|PM))?$/i.test(t)) return true; // time-only\n if (/^<[a-z-]+[\\s/>]/i.test(t)) return true; // XML/HTML tags (<task-notification>, etc.)\n if (/^[0-9a-f]{10,}$/i.test(t)) return true; // hex hash strings\n if (/^Exit code \\d+/i.test(t)) return true; // exit code messages\n if (/^Error:/i.test(t)) return true; // error messages\n if (/^This session is being continued/i.test(t)) return true; // continuation boilerplate\n if (/^\\(Bash completed/i.test(t)) return true; // bash output noise\n if (/^Task Notification$/i.test(t)) return true; // literal \"Task Notification\"\n if (/^New Session$/i.test(t)) return true; // placeholder title\n if (/^Recovered Session$/i.test(t)) return true; // placeholder title\n if (/^Continued Session$/i.test(t)) return true; // placeholder title\n if (/^Untitled Session$/i.test(t)) return true; // placeholder title\n if (/^Context Compression$/i.test(t)) return true; // compression artifact\n if (/^[A-Fa-f0-9]{8,}\\s+Output$/i.test(t)) return true; // hash + \"Output\" pattern\n return false;\n}\n\n/**\n * Extract a meaningful name from session note content and summary.\n * Looks at Work Done section headers, bold text, and summary.\n */\nexport function extractMeaningfulName(noteContent: string, summary: string): string {\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 const subheadings = workDoneSection.match(/### ([^\\n]+)/g);\n if (subheadings && subheadings.length > 0) {\n const firstHeading = subheadings[0].replace('### ', '').trim();\n if (!isMeaninglessCandidate(firstHeading) && firstHeading.length > 5 && firstHeading.length < 60) {\n return sanitizeForFilename(firstHeading);\n }\n }\n\n const boldMatches = workDoneSection.match(/\\*\\*([^*]+)\\*\\*/g);\n if (boldMatches && boldMatches.length > 0) {\n const firstBold = boldMatches[0].replace(/\\*\\*/g, '').trim();\n if (!isMeaninglessCandidate(firstBold) && firstBold.length > 3 && firstBold.length < 50) {\n return sanitizeForFilename(firstBold);\n }\n }\n\n const numberedItems = workDoneSection.match(/^\\d+\\.\\s+\\*\\*([^*]+)\\*\\*/m);\n if (numberedItems && !isMeaninglessCandidate(numberedItems[1])) {\n return sanitizeForFilename(numberedItems[1]);\n }\n }\n\n if (summary && summary.length > 5 && summary !== 'Session completed.' && !isMeaninglessCandidate(summary)) {\n const cleanSummary = summary\n .replace(/[^\\w\\s-]/g, ' ')\n .trim()\n .split(/\\s+/)\n .slice(0, 5)\n .join(' ');\n if (cleanSummary.length > 3 && !isMeaninglessCandidate(cleanSummary)) {\n return sanitizeForFilename(cleanSummary);\n }\n }\n\n return '';\n}\n\n/**\n * Rename a session note with a meaningful name.\n * Always uses \"NNNN - YYYY-MM-DD - Description.md\" format.\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)) return notePath;\n\n const dir = join(notePath, '..');\n const oldFilename = basename(notePath);\n\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 const match = correctMatch || legacyMatch;\n if (!match) return notePath;\n\n const [, noteNumber, date] = match;\n\n const titleCaseName = meaningfulName\n .split(/[\\s_-]+/)\n .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())\n .join(' ')\n .trim();\n\n const paddedNumber = noteNumber.padStart(4, '0');\n const newFilename = `${paddedNumber} - ${date} - ${titleCaseName}.md`;\n const newPath = join(dir, newFilename);\n\n if (newFilename === oldFilename) return notePath;\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/** Update the session note's H1 title and rename the file. */\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 content = content.replace(/^# Session \\d+:.*$/m, (match) => {\n const sessionNum = match.match(/Session (\\d+)/)?.[1] || '';\n return `# Session ${sessionNum}: ${newTitle}`;\n });\n writeFileSync(notePath, content);\n renameSessionNote(notePath, sanitizeForFilename(newTitle));\n}\n\n/**\n * Finalize session note \u2014 mark as complete, add summary, rename with meaningful name.\n * IDEMPOTENT: subsequent calls are no-ops if already finalized.\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 if (content.includes('**Status:** Completed')) {\n console.error(`Note already finalized: ${basename(notePath)}`);\n return notePath;\n }\n\n content = content.replace('**Status:** In Progress', '**Status:** Completed');\n\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 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 const meaningfulName = extractMeaningfulName(content, summary);\n if (meaningfulName) {\n return renameSessionNote(notePath, meaningfulName);\n }\n\n return notePath;\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, resolve } from 'path';\nimport { homedir } from 'os';\nimport { execSync } from 'child_process';\nimport { buildWakeupContext } from '../../../memory/wakeup.js';\nimport { readContinueCheckpoint } from '../../../session/checkpoint-block.js';\nimport {\n PAI_DIR,\n findNotesDir,\n getProjectDir,\n getCurrentNotePath,\n createSessionNote,\n findTodoPath,\n findAllClaudeMdPaths,\n sendNtfyNotification,\n isProbeSession,\n archiveSessionFilesToSessionsDir\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\n/**\n * Project signals that indicate a directory is a real project root.\n */\nconst PROJECT_SIGNALS = [\n '.git',\n 'package.json',\n 'pubspec.yaml',\n 'Cargo.toml',\n 'go.mod',\n 'pyproject.toml',\n 'setup.py',\n 'build.gradle',\n 'pom.xml',\n 'composer.json',\n 'Gemfile',\n 'Makefile',\n 'CMakeLists.txt',\n 'tsconfig.json',\n 'CLAUDE.md',\n join('Notes', 'PAI.md'),\n];\n\n/**\n * Returns true if the given directory looks like a project root.\n * Checks for the presence of well-known project signal files/dirs.\n */\nfunction hasProjectSignals(dir: string): boolean {\n for (const signal of PROJECT_SIGNALS) {\n if (existsSync(join(dir, signal))) return true;\n }\n return false;\n}\n\n/**\n * Returns true if the directory should NOT be auto-registered.\n * Guards: home directory, shallow paths, temp directories.\n */\nfunction isGuardedPath(dir: string): boolean {\n const home = homedir();\n const resolved = resolve(dir);\n\n // Never register the home directory itself\n if (resolved === home) return true;\n\n // Depth guard: require at least 3 path segments beyond root\n // e.g. /Users/owner/foo is depth 3 on macOS \u2014 reject it\n const parts = resolved.split('/').filter(Boolean);\n if (parts.length < 3) return true;\n\n // Temp/system directories\n const forbidden = ['/tmp', '/var', '/private/tmp', '/private/var/folders'];\n for (const prefix of forbidden) {\n if (resolved === prefix || resolved.startsWith(prefix + '/')) return true;\n }\n\n return false;\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 // Skip probe/health-check sessions (e.g. CodexBar ClaudeProbe)\n if (isProbeSession()) {\n console.error('Probe session detected - skipping project context loading');\n process.exit(0);\n }\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. Archive the project's transcripts into sessions/ \u2014 by LINKING, never moving.\n //\n // This used to renameSync every .jsonl except the newest out of the project\n // root. `claude --resume <uuid>` reads ~/.claude/projects/<encoded>/<uuid>.jsonl\n // and only that path, so moving the file is what makes a session unresumable \u2014\n // and because this is a SessionStart hook, the act of STARTING a session in a\n // project destroyed the resumability of every earlier session in it. The old\n // comment (\"keep the newest one for potential resume\") shows the dependency was\n // known; keeping one file was not enough.\n //\n // Measured 2026-08-04: this repo had 1 transcript at the top level and 52 under\n // sessions/, every one of the 52 unresumable. Among them was the id PAI prints\n // in its own archived handovers as `claude --resume <uuid>`, so the instruction\n // we ship in every checkpoint could not work.\n //\n // It also completes the incident of that morning: `pai Paperfull` failed to\n // resume b3462801 because of a probe bug, started a fresh session instead, and\n // THIS hook then moved b3462801 \u2014 867 KB of real work \u2014 out of reach. The\n // failed resume destroyed what it had failed to open.\n //\n // A hard link keeps both truths: the archive under sessions/ is populated for\n // everything that reads it, and the file Claude Code owns stays where Claude\n // Code put it. Same inode, so it costs nothing.\n // This was the fourth mover, with its own inline loop. It now calls the one\n // shared archiver instead: a second implementation is exactly how the earlier\n // probeResume fix came to land in one of three copies and leave `pai <Name>`\n // broken for a day.\n const projectDir = getProjectDir(cwd);\n if (existsSync(projectDir)) {\n try {\n // Exclude this session's own transcript: it is being written right now,\n // and the archive is meant to hold finished sessions.\n const own = hookInput?.session_id ? `${hookInput.session_id}.jsonl` : undefined;\n archiveSessionFilesToSessionsDir(projectDir, own, true);\n } catch (error) {\n console.error(`Could not archive session transcripts: ${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 // Only create a new note if there is truly no note at all.\n // A completed note is still used \u2014 it will be updated or continued.\n // This prevents duplicate notes at month boundaries and on every compaction.\n if (!currentNotePath) {\n // Defensive: ensure projectName is a usable string\n const safeProjectName = (typeof projectName === 'string' && projectName.trim().length > 0)\n ? projectName.trim()\n : 'Untitled Session';\n console.error('\\nNo previous session notes found - creating new one');\n activeNotePath = createSessionNote(notesDir, String(safeProjectName));\n console.error(`Created: ${basename(activeNotePath)}`);\n } else {\n activeNotePath = currentNotePath!;\n console.error(`\\nUsing existing session note: ${basename(activeNotePath)}`);\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 /**\n * Attempt to auto-register the CWD as a new PAI project.\n * Calls `pai project add <cwd>`, then re-detects to confirm registration.\n * Returns true if registration succeeded (or was attempted and project add ran),\n * and sets paiProjectBlock as a side effect on success.\n */\n const tryAutoRegister = async (): Promise<boolean> => {\n if (isGuardedPath(cwd) || !hasProjectSignals(cwd)) return false;\n\n try {\n execFileSync(paiBin, ['project', 'add', cwd], {\n encoding: 'utf-8',\n env: process.env,\n });\n console.error(`PAI auto-registered project at: ${cwd}`);\n\n // Re-run detect to confirm registration\n try {\n const raw2 = execFileSync(paiBin, ['project', 'detect', '--json', cwd], {\n encoding: 'utf-8',\n env: process.env,\n }).trim();\n\n if (raw2) {\n const detected2 = JSON.parse(raw2) as typeof detected;\n if (detected2.slug) {\n const name2 = detected2.display_name || detected2.slug;\n console.error(`PAI auto-registered: \"${detected2.slug}\" (${detected2.match_type})`);\n paiProjectBlock = `PAI Project Registry: ${name2} (slug: ${detected2.slug}) [AUTO-REGISTERED]\nMatch: ${detected2.match_type ?? 'exact'} | Sessions: 0`;\n return true;\n }\n }\n } catch (detectErr) {\n console.error('PAI auto-registration: project added but re-detect failed:', detectErr);\n return true; // project IS registered, just can't load context\n }\n } catch (addErr) {\n console.error('PAI auto-registration failed (project add):', addErr);\n }\n return false;\n };\n\n if (detected.error === 'no_match') {\n // Attempt auto-registration if the directory looks like a real project\n const autoRegistered = await tryAutoRegister();\n\n if (!autoRegistered) {\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 }\n } else if (\n detected.match_type === 'parent' &&\n detected.relative_path &&\n !isGuardedPath(cwd) &&\n hasProjectSignals(cwd)\n ) {\n // The CWD is inside a broader registered parent (e.g. \"owner\" or \"apps\"),\n // but it has its own project signals \u2014 register it as a distinct project.\n console.error(\n `PAI detect: parent match to \"${detected.slug}\" via relative path \"${detected.relative_path}\" \u2014 CWD looks like its own project, attempting auto-registration`\n );\n const autoRegistered = await tryAutoRegister();\n\n if (!autoRegistered) {\n // Fall through: show the parent match as normal\n const name = detected.display_name || detected.slug;\n const nameSlug = ` (slug: ${detected.slug})`;\n const matchDesc = `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: kept parent match \"${detected.slug}\" (auto-register not applicable)`);\n }\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 // 8.5. INJECT THE PAUSE CHECKPOINT\n //\n // `pai pause` writes the handover to TODO.md under `## Continue`. Until now\n // nothing read it back: the session-commands hook only told the model to look\n // there *if the user typed \"go\"*, so a resumed session started blind unless\n // the user knew the magic word. Writing a handover nobody delivers is not a\n // handover. Inject it, and say plainly that it is the previous session's\n // state rather than an instruction to act on.\n if (existsSync(todoPath)) {\n try {\n const checkpoint = readContinueCheckpoint(readFileSync(todoPath, 'utf-8'));\n if (checkpoint) {\n const from = checkpoint.meta?.session\n ? `\\nFrom session: ${checkpoint.meta.session}`\n : '';\n const when = checkpoint.meta?.ts ? `\\nPaused at: ${checkpoint.meta.ts}` : '';\n const resume = checkpoint.meta?.sessionId\n ? `\\nResume that session with: claude --resume ${checkpoint.meta.sessionId}`\n : '';\n\n console.log(`\n<system-reminder>\nHANDOVER FROM THE PREVIOUS SESSION\n\nSource: ${todoPath} (## Continue)${from}${when}${resume}\n\n${checkpoint.body}\n\n---\nThis is recorded state, not a new instruction. Do not start acting on it\nunprompted. If the user says \"go\", \"continue\", or \"weiter\", resume from here.\n</system-reminder>\n`);\n console.error(`Injected pause checkpoint (${checkpoint.body.length} chars)`);\n } else {\n console.error('No pause checkpoint body in TODO.md \u2014 nothing to hand over');\n }\n } catch (error) {\n // Non-fatal \u2014 a malformed TODO.md must never block session start.\n console.error('Checkpoint injection failed:', error);\n }\n }\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 // 10. Inject wake-up context (L0 identity + L1 essential story) if available\n // The detected PAI project root_path is used for L1 note lookup.\n // We derive it from the `paiProjectBlock` detection result by re-running a\n // lightweight registry lookup, or by falling back to cwd for local-notes projects.\n try {\n // Attempt to find the project root path from the registry via `pai project detect --json`\n let wakeupRootPath: string | undefined;\n try {\n const { execFileSync: efs } = await import('child_process');\n const raw2 = efs(paiBin, ['project', 'detect', '--json', cwd], {\n encoding: 'utf-8',\n env: process.env,\n }).trim();\n if (raw2) {\n const det = JSON.parse(raw2) as { root_path?: string; slug?: string };\n if (det.root_path) wakeupRootPath = det.root_path;\n }\n } catch {\n // Non-fatal \u2014 fall back to cwd\n wakeupRootPath = cwd;\n }\n\n const wakeupBlock = buildWakeupContext(wakeupRootPath);\n if (wakeupBlock) {\n const wakeupReminder = `\\n<system-reminder>\\nWAKEUP CONTEXT\\n\\n${wakeupBlock}\\n</system-reminder>\\n`;\n console.log(wakeupReminder);\n console.error('Injected wake-up context (L0+L1)');\n } else {\n console.error('No wake-up context to inject (no identity file or session notes)');\n }\n } catch (wakeupError) {\n // Non-fatal \u2014 don't block session start\n console.error('Wake-up context injection failed:', wakeupError);\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 * Wake-up context system \u2014 progressive context loading inspired by mempalace.\n *\n * Layers:\n * L0 Identity (~100 tokens) \u2014 user identity from ~/.pai/identity.txt. Always loaded.\n * L1 Essential Story (~500-800t) \u2014 top session notes for the project, key lines extracted.\n * L2 On-Demand \u2014 triggered by topic queries (handled by memory_search).\n * L3 Deep Search \u2014 unlimited federated memory search (memory_search tool).\n */\n\nimport { existsSync, readdirSync, readFileSync, statSync } from \"node:fs\";\nimport { join, basename } from \"node:path\";\nimport { homedir } from \"node:os\";\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/** Maximum tokens for the L1 essential story block. Approx 4 chars/token. */\nconst L1_TOKEN_BUDGET = 800;\nconst L1_CHAR_BUDGET = L1_TOKEN_BUDGET * 4; // ~3200 chars\n\n/** Maximum session notes to scan when building L1. */\nconst L1_MAX_NOTES = 10;\n\n/** Sections to extract from session notes (in priority order). */\nconst EXTRACT_SECTIONS = [\n \"Work Done\",\n \"Key Decisions\",\n \"Next Steps\",\n \"Checkpoint\",\n];\n\n/** Identity file location. */\nconst IDENTITY_FILE = join(homedir(), \".pai\", \"identity.txt\");\n\n// ---------------------------------------------------------------------------\n// L0: Identity\n// ---------------------------------------------------------------------------\n\n/**\n * Load L0 identity from ~/.pai/identity.txt.\n * Returns the file content, or an empty string if the file does not exist.\n * Never throws.\n */\nexport function loadL0Identity(): string {\n if (!existsSync(IDENTITY_FILE)) return \"\";\n try {\n return readFileSync(IDENTITY_FILE, \"utf-8\").trim();\n } catch {\n return \"\";\n }\n}\n\n// ---------------------------------------------------------------------------\n// L1: Essential Story\n// ---------------------------------------------------------------------------\n\n/**\n * Find the Notes directory for a project given its root_path from the registry.\n * Checks local Notes/ first, then central ~/.claude/projects/... path.\n */\nfunction findNotesDirForProject(rootPath: string): string | null {\n // Check local Notes directories first\n const localCandidates = [\n join(rootPath, \"Notes\"),\n join(rootPath, \"notes\"),\n join(rootPath, \".claude\", \"Notes\"),\n ];\n for (const p of localCandidates) {\n if (existsSync(p)) return p;\n }\n\n // Fall back to central ~/.claude/projects/{encoded}/Notes\n const encoded = rootPath\n .replace(/\\//g, \"-\")\n .replace(/\\./g, \"-\")\n .replace(/ /g, \"-\");\n const centralNotes = join(\n homedir(),\n \".claude\",\n \"projects\",\n encoded,\n \"Notes\"\n );\n if (existsSync(centralNotes)) return centralNotes;\n\n return null;\n}\n\n/**\n * Recursively find all .md session note files in a Notes directory.\n * Handles both flat layout (Notes/*.md) and month-subdirectory layout\n * (Notes/YYYY/MM/*.md). Returns files sorted newest-first by filename\n * (note numbers are monotonically increasing, so lexicographic = newest-last,\n * so we reverse).\n */\nfunction findSessionNotes(notesDir: string): string[] {\n const result: string[] = [];\n\n const scanDir = (dir: string) => {\n if (!existsSync(dir)) return;\n let entries: string[];\n try {\n entries = readdirSync(dir, { withFileTypes: true } as Parameters<typeof readdirSync>[1] as any)\n .map((e: any) => ({ name: e.name, isDir: e.isDirectory() }));\n } catch {\n return;\n }\n\n for (const entry of entries as Array<{ name: string; isDir: boolean }>) {\n const fullPath = join(dir, entry.name);\n if (entry.isDir) {\n // Recurse into YYYY/MM subdirectories\n scanDir(fullPath);\n } else if (entry.name.match(/^\\d{3,4}[\\s_-].*\\.md$/)) {\n result.push(fullPath);\n }\n }\n };\n\n scanDir(notesDir);\n\n // Sort newest-first by the DATE in the filename, not the note number.\n //\n // The note number is only monotonic within one uninterrupted numbering run.\n // It restarts \u2014 per month directory, and after a registry merge renumbers a\n // project \u2014 so \"highest number\" and \"most recent\" diverge. Observed live:\n // `0184 - 2026-02-22` outranked `0008 - 2026-08-01`, and the wake-up context\n // handed a resumed session five-month-old material as its recent history.\n //\n // `NNNN - YYYY-MM-DD - Title.md` is the enforced layout, so the date is the\n // reliable key. Number breaks ties within a day; mtime is the last resort\n // for notes that predate the naming convention.\n const dateOf = (p: string): string =>\n basename(p).match(/(\\d{4}-\\d{2}-\\d{2})/)?.[1] ?? \"\";\n const numberOf = (p: string): number =>\n parseInt(basename(p).match(/^(\\d+)/)?.[1] ?? \"0\", 10);\n const mtimeOf = (p: string): number => {\n try {\n return statSync(p).mtimeMs;\n } catch {\n return 0;\n }\n };\n\n result.sort((a, b) => {\n const dateA = dateOf(a);\n const dateB = dateOf(b);\n // Undated notes sort last rather than winning on an empty string.\n if (dateA !== dateB) {\n if (!dateA) return 1;\n if (!dateB) return -1;\n return dateB.localeCompare(dateA);\n }\n const byNumber = numberOf(b) - numberOf(a);\n if (byNumber !== 0) return byNumber;\n return mtimeOf(b) - mtimeOf(a);\n });\n\n return result;\n}\n\n/**\n * Extract the most important lines from a session note.\n * Prioritises: Work Done items, Key Decisions, Next Steps, Checkpoint headings.\n * Returns a condensed string under maxChars.\n */\nfunction extractKeyLines(content: string, maxChars: number): string {\n const lines = content.split(\"\\n\");\n const selected: string[] = [];\n let inTargetSection = false;\n let currentSection = \"\";\n let charCount = 0;\n\n // First pass: collect lines from priority sections\n for (const line of lines) {\n // Detect section headers\n const h2Match = line.match(/^## (.+)$/);\n const h3Match = line.match(/^### (.+)$/);\n if (h2Match) {\n currentSection = h2Match[1];\n inTargetSection = EXTRACT_SECTIONS.some((s) =>\n currentSection.toLowerCase().includes(s.toLowerCase())\n );\n continue;\n }\n if (h3Match) {\n // Checkpoints / sub-sections \u2014 include heading as label\n if (inTargetSection) {\n const label = `[${h3Match[1]}]`;\n if (charCount + label.length < maxChars) {\n selected.push(label);\n charCount += label.length + 1;\n }\n }\n continue;\n }\n\n if (!inTargetSection) continue;\n\n // Skip blank lines and HTML comments\n const trimmed = line.trim();\n if (!trimmed || trimmed.startsWith(\"<!--\") || trimmed === \"---\") continue;\n\n // Include checkbox items, bold text, and plain text lines\n if (\n trimmed.startsWith(\"- \") ||\n trimmed.startsWith(\"* \") ||\n trimmed.match(/^\\d+\\./) ||\n trimmed.startsWith(\"**\")\n ) {\n if (charCount + trimmed.length + 1 > maxChars) break;\n selected.push(trimmed);\n charCount += trimmed.length + 1;\n }\n }\n\n return selected.join(\"\\n\");\n}\n\n/**\n * Build the L1 essential story block.\n *\n * Reads the most recent session notes for the project and extracts the key\n * lines (Work Done, Key Decisions, Next Steps) within the token budget.\n *\n * @param rootPath The project root path (from the registry).\n * @param tokenBudget Max tokens to consume. Default 800 (~3200 chars).\n * @returns Formatted L1 block, or empty string if no notes found.\n */\nexport function buildL1EssentialStory(\n rootPath: string,\n tokenBudget = L1_TOKEN_BUDGET\n): string {\n const charBudget = tokenBudget * 4;\n const notesDir = findNotesDirForProject(rootPath);\n if (!notesDir) return \"\";\n\n const noteFiles = findSessionNotes(notesDir).slice(0, L1_MAX_NOTES);\n if (noteFiles.length === 0) return \"\";\n\n const sections: string[] = [];\n let remaining = charBudget;\n\n for (const noteFile of noteFiles) {\n if (remaining <= 50) break;\n\n let content: string;\n try {\n content = readFileSync(noteFile, \"utf-8\");\n } catch {\n continue;\n }\n\n // Extract the note date and title from the filename\n const name = basename(noteFile);\n const titleMatch = name.match(/^\\d+ - (\\d{4}-\\d{2}-\\d{2}) - (.+)\\.md$/);\n const dateLabel = titleMatch ? titleMatch[1] : \"\";\n const titleLabel = titleMatch\n ? titleMatch[2]\n : name.replace(/^\\d+ - /, \"\").replace(/\\.md$/, \"\");\n\n // Skip if nothing useful extracted from this note\n const perNoteChars = Math.min(remaining, Math.floor(charBudget / noteFiles.length) + 200);\n const extracted = extractKeyLines(content, perNoteChars);\n if (!extracted) continue;\n\n const noteBlock = `[${dateLabel} - ${titleLabel}]\\n${extracted}`;\n sections.push(noteBlock);\n remaining -= noteBlock.length + 1;\n }\n\n if (sections.length === 0) return \"\";\n\n return sections.join(\"\\n\\n\");\n}\n\n// ---------------------------------------------------------------------------\n// Combined: buildWakeupContext\n// ---------------------------------------------------------------------------\n\n/**\n * Build the combined wake-up context block (L0 + L1).\n *\n * Returns a formatted string suitable for injection as a system-reminder,\n * or an empty string if both layers are empty.\n *\n * @param rootPath Project root path for L1 note lookup. Optional.\n * @param tokenBudget L1 token budget. Default 800.\n */\nexport function buildWakeupContext(\n rootPath?: string,\n tokenBudget = L1_TOKEN_BUDGET\n): string {\n const identity = loadL0Identity();\n const essentialStory = rootPath\n ? buildL1EssentialStory(rootPath, tokenBudget)\n : \"\";\n\n if (!identity && !essentialStory) return \"\";\n\n const parts: string[] = [];\n\n if (identity) {\n parts.push(`## L0 Identity\\n\\n${identity}`);\n }\n\n if (essentialStory) {\n parts.push(`## L1 Essential Story\\n\\n${essentialStory}`);\n }\n\n return parts.join(\"\\n\\n\");\n}\n", "/**\n * Shared \"## Continue\" checkpoint logic for a project's TODO.md.\n *\n * WHY THIS MODULE EXISTS\n * ----------------------\n * Before this, `pause.ts` and `handover.ts` each carried their own copy of\n * findProjectTodo / stripContinueSection / block-builder. Both wrote the same\n * fixed four-line block, and both stripped any existing ## Continue section\n * unconditionally. The consequence was that a rich, model-authored checkpoint\n * could never survive:\n *\n * 1. `pai pause` had no way to accept a body, so the model printed its\n * checkpoint to the terminal and it was lost.\n * 2. Even if a body had been written by hand, the session-stop hook runs\n * `pai session handover` on every clean exit, which regenerated the\n * generic block and erased it.\n *\n * So there are two jobs here:\n *\n * - AUTHORED writes (`pai pause --body-file`) carry the model's markdown\n * verbatim, wrapped in explicit start/end markers.\n * - AUTO writes (hooks) must never destroy an authored checkpoint belonging\n * to the *same* session. They may replace a stale one left by an earlier\n * session, otherwise TODO.md would show a checkpoint that no longer\n * describes where the work stands.\n *\n * PARSING\n * -------\n * A rich body can legitimately contain `---` rules and `##` headings, which the\n * old heuristic scanner treated as section terminators. Authored blocks are\n * therefore delimited by an explicit HTML-comment pair; the legacy heuristic is\n * kept only as a fallback for blocks written before this change.\n */\n\nimport {\n existsSync,\n readFileSync,\n writeFileSync,\n readdirSync,\n renameSync,\n mkdirSync,\n realpathSync,\n} from \"node:fs\";\nimport { join } from \"node:path\";\nimport { homedir } from \"node:os\";\n\n// ---------------------------------------------------------------------------\n// Constants\n// ---------------------------------------------------------------------------\n\n/** Locations searched for a project TODO.md, in priority order. */\nexport const TODO_LOCATIONS = [\n \"Notes/TODO.md\",\n \".claude/Notes/TODO.md\",\n \"tasks/todo.md\",\n \"TODO.md\",\n];\n\nexport const MARKER_OPEN = \"<!-- pai:checkpoint\";\nexport const MARKER_CLOSE = \"<!-- /pai:checkpoint -->\";\n\n/**\n * Markers for a handover that has been superseded but not thrown away.\n *\n * Deliberately NOT `pai:checkpoint`: `locateContinue` scans for that marker and\n * for the `## Continue` heading, so an archived block written verbatim would be\n * found as the live section and rewritten in place of it. Archived entries are\n * inert by construction \u2014 different marker, no heading.\n */\nexport const ARCHIVE_OPEN = \"<!-- pai:archived-handover\";\nexport const ARCHIVE_CLOSE = \"<!-- /pai:archived-handover -->\";\nexport const ARCHIVE_HEADING = \"## Previous handovers\";\n\n/**\n * How many superseded handovers TODO.md keeps.\n *\n * Enough that a handover survives a run of sessions that each end without\n * pausing, which is the sequence that destroyed one on 2026-08-04. Not\n * unbounded: TODO.md is read by a human at the top of every session, and a file\n * that grows without limit stops being read, which is its own kind of loss.\n */\nexport const ARCHIVE_LIMIT = 5;\n\nexport const CONTINUE_HEADING = \"## Continue\";\n\n/** Who wrote the checkpoint currently in TODO.md. */\nexport type Authorship = \"model\" | \"auto\";\n\n/** Result of applying a checkpoint. */\nexport type ApplyAction = \"written\" | \"preserved\" | \"failed\";\n\n// ---------------------------------------------------------------------------\n// TODO.md discovery\n// ---------------------------------------------------------------------------\n\nexport function findProjectTodo(\n rootPath: string\n): { path: string; content: string } | null {\n for (const rel of TODO_LOCATIONS) {\n const full = join(rootPath, rel);\n if (existsSync(full)) {\n try {\n return { path: full, content: readFileSync(full, \"utf8\") };\n } catch {\n // unreadable \u2014 try next\n }\n }\n }\n return null;\n}\n\n/**\n * Resolve the TODO.md to write to, creating Notes/ if nothing exists yet.\n * Returns null only when the directory could not be created.\n */\nexport function resolveTodoTarget(\n rootPath: string,\n opts: { create?: boolean } = {}\n): { path: string; content: string } | null {\n const found = findProjectTodo(rootPath);\n if (found) return found;\n\n const notesDir = join(rootPath, \"Notes\");\n if (opts.create !== false) {\n try {\n if (!existsSync(notesDir)) mkdirSync(notesDir, { recursive: true });\n } catch {\n return null;\n }\n }\n return { path: join(notesDir, \"TODO.md\"), content: \"\" };\n}\n\n// ---------------------------------------------------------------------------\n// Marker parsing\n// ---------------------------------------------------------------------------\n\nexport interface CheckpointMeta {\n authored: Authorship;\n /** Session line the checkpoint describes, e.g. \"0003 - 2026-08-01 - Title\". */\n session?: string;\n /** Claude Code session UUID, when known. */\n sessionId?: string;\n ts?: string;\n}\n\n/**\n * Parse a `<!-- pai:checkpoint key=\"value\" ... -->` marker line.\n * Returns null when the line is not a marker.\n */\nexport function parseMarker(line: string): CheckpointMeta | null {\n const trimmed = line.trim();\n if (!trimmed.startsWith(MARKER_OPEN)) return null;\n\n const attrs: Record<string, string> = {};\n for (const m of trimmed.matchAll(/([a-zA-Z][\\w-]*)=\"([^\"]*)\"/g)) {\n attrs[m[1]] = m[2];\n }\n\n return {\n authored: attrs.authored === \"model\" ? \"model\" : \"auto\",\n session: attrs.session || undefined,\n sessionId: attrs[\"session-id\"] || undefined,\n ts: attrs.ts || undefined,\n };\n}\n\nexport interface LocatedContinue {\n /** Index of the \"## Continue\" heading line. */\n startIdx: number;\n /** Exclusive end index, past any trailing `---` separator. */\n endIdx: number;\n meta: CheckpointMeta | null;\n /** Raw lines of the section, heading included. */\n lines: string[];\n}\n\n/**\n * Lines an auto-generated block is made of. Anything else in a section is\n * content somebody put there deliberately.\n *\n * The blockquote marker is optional on every pattern. These lines appear\n * quoted when the block builder emits them as its header, and unquoted when a\n * caller passes the same text as a BODY \u2014 which the session-stop hook does: it\n * builds `Working directory: ${cwd}` and hands it over as state. Matching only\n * the quoted form made such a body read as content, which is how a session\n * that did no work at all overwrote a real handover on 2026-08-04. A line that\n * merely restates the generated header is boilerplate wherever it sits.\n */\nconst BOILERPLATE_PATTERNS = [\n /^##\\s+Continue$/,\n /^<!--\\s*\\/?pai:checkpoint/,\n /^>?\\s*\\*\\*Last session:\\*\\*/,\n /^>?\\s*\\*\\*Paused at:\\*\\*/,\n /^>?\\s*Working directory:/,\n /^>?\\s*Resume with:/,\n /^>?\\s*_No checkpoint body was recorded/,\n /^-{3,}$/,\n];\n\n/** A blank line, with or without a blockquote marker. */\nconst BLANK_LINE = /^>?\\s*$/;\n\n/**\n * True when a section contains nothing but generated header lines.\n *\n * This is the guard that stops an auto write from destroying content it did\n * not author. A session that hit the old clobbering bug may have worked around\n * it by hand \u2014 writing its state into a subsection underneath the generated\n * header lines, in an unmarked block. Those blocks predate the marker, so\n * authorship cannot be read off them; the only safe signal is whether anything\n * beyond boilerplate is present.\n */\nexport function isBoilerplateOnly(lines: string[]): boolean {\n return lines.every((line) => {\n const t = line.trim();\n return BLANK_LINE.test(t) || BOILERPLATE_PATTERNS.some((re) => re.test(t));\n });\n}\n\n/**\n * True when a checkpoint body says something the generated header does not.\n *\n * \"Has a body\" and \"has something to say\" are not the same question, and the\n * preservation rules care only about the second. An unattended writer that\n * emits a single line restating the working directory has produced a body by\n * any string test and a handover by none.\n */\nexport function hasSubstance(body: string | null | undefined): boolean {\n const text = (body ?? \"\").trim();\n if (!text) return false;\n return !isBoilerplateOnly(text.split(\"\\n\"));\n}\n\n/**\n * Extract the non-boilerplate content of a section, or \"\" if there is none.\n *\n * Interior blank lines are kept. They are not decoration \u2014 in Markdown they\n * are what separates a paragraph from the table or list that follows, so\n * dropping them (as this did while it treated every blank line as boilerplate)\n * silently welds a checkpoint body into one unreadable run.\n */\nexport function extractSectionContent(lines: string[]): string {\n const kept: string[] = [];\n for (const line of lines) {\n const t = line.trim();\n if (BOILERPLATE_PATTERNS.some((re) => re.test(t))) continue;\n kept.push(line);\n }\n return trimBlankEdges(collapseBlankRuns(kept)).join(\"\\n\");\n}\n\n/** Drop leading and trailing blank lines, leaving interior spacing alone. */\nfunction trimBlankEdges(lines: string[]): string[] {\n let start = 0;\n let end = lines.length;\n while (start < end && BLANK_LINE.test(lines[start].trim())) start += 1;\n while (end > start && BLANK_LINE.test(lines[end - 1].trim())) end -= 1;\n return lines.slice(start, end);\n}\n\n/**\n * Collapse runs of blank lines to a single blank.\n *\n * Removing a boilerplate line leaves the blank that surrounded it behind, so\n * stripping the header can open a three-line gap in the middle of the body.\n * One blank line is all Markdown needs.\n */\nfunction collapseBlankRuns(lines: string[]): string[] {\n const out: string[] = [];\n let lastWasBlank = false;\n for (const line of lines) {\n const isBlank = BLANK_LINE.test(line.trim());\n if (isBlank && lastWasBlank) continue;\n out.push(line);\n lastWasBlank = isBlank;\n }\n return out;\n}\n\n/**\n * Locate the existing ## Continue section.\n *\n * When the section carries an explicit marker pair, the close marker defines\n * the end \u2014 this is what lets a rich body contain `---` and `##` safely.\n * Otherwise the legacy heuristic applies: stop at the first `---` or the next\n * `##` heading.\n */\nexport function locateContinue(content: string): LocatedContinue | null {\n const lines = content.split(\"\\n\");\n const startIdx = lines.findIndex((l) => l.trim() === CONTINUE_HEADING);\n if (startIdx === -1) return null;\n\n // Look for an open marker in the first few lines of the section.\n let meta: CheckpointMeta | null = null;\n let markerIdx = -1;\n for (let i = startIdx + 1; i < Math.min(startIdx + 6, lines.length); i++) {\n const parsed = parseMarker(lines[i]);\n if (parsed) {\n meta = parsed;\n markerIdx = i;\n break;\n }\n // A non-blank, non-marker line means there is no marker for this section.\n if (lines[i].trim() !== \"\") break;\n }\n\n let endIdx = lines.length;\n\n if (markerIdx !== -1) {\n const closeIdx = lines.findIndex(\n (l, i) => i > markerIdx && l.trim() === MARKER_CLOSE\n );\n if (closeIdx !== -1) {\n endIdx = closeIdx + 1;\n } else {\n // Malformed (open without close) \u2014 fall back to the heuristic so we do\n // not swallow the rest of the file.\n endIdx = heuristicEnd(lines, startIdx);\n }\n } else {\n endIdx = heuristicEnd(lines, startIdx);\n }\n\n // Consume one trailing `---` separator and the blank lines around it.\n let trailingEnd = endIdx;\n while (trailingEnd < lines.length && lines[trailingEnd].trim() === \"\") {\n trailingEnd += 1;\n }\n if (trailingEnd < lines.length && lines[trailingEnd].trim() === \"---\") {\n trailingEnd += 1;\n } else {\n trailingEnd = endIdx;\n }\n\n return {\n startIdx,\n endIdx: trailingEnd,\n meta,\n lines: lines.slice(startIdx, trailingEnd),\n };\n}\n\n/**\n * Legacy scanner for blocks written before checkpoint markers existed.\n *\n * Terminates on a horizontal rule or the next level-1 or level-2 heading. Note\n * the `(?!#)` \u2014 `###` is a *subsection* of `## Continue`, not a terminator. The\n * original scanner stopped at any run of `#`, which meant a `### Restored\n * state` subsection fell outside the section entirely and could not be seen,\n * let alone carried forward.\n *\n * `#{1,2}` rather than `##`, because matching only `##` did not stop at an H1\n * and therefore ATE IT. A TODO.md whose `## Continue` block precedes its\n * `# TODO` title had the title absorbed into the section and destroyed on the\n * next regenerate \u2014 reported independently by three sessions on 2026-08-03,\n * one of which lost it three times and recovered from git each time.\n *\n * An H1 is a document-level heading. It can never be part of a `## Continue`\n * section, so treating it as a terminator is not a heuristic improvement but a\n * structural fact.\n */\nfunction heuristicEnd(lines: string[], startIdx: number): number {\n for (let i = startIdx + 1; i < lines.length; i++) {\n const trimmed = lines[i].trim();\n if (\n trimmed === \"---\" ||\n (/^#{1,2}(?!#)/.test(trimmed) && trimmed !== CONTINUE_HEADING)\n ) {\n return i;\n }\n }\n return lines.length;\n}\n\n/** Remove the ## Continue section, returning the remainder of the document. */\nexport function stripContinue(content: string): string {\n const found = locateContinue(content);\n if (!found) return content;\n\n const lines = content.split(\"\\n\");\n const before = lines.slice(0, found.startIdx);\n const after = lines.slice(found.endIdx);\n while (after.length > 0 && after[0].trim() === \"\") after.shift();\n\n return [...before, ...after].join(\"\\n\");\n}\n\n// ---------------------------------------------------------------------------\n// Reading a checkpoint back\n// ---------------------------------------------------------------------------\n\nexport interface ContinueCheckpoint {\n meta: CheckpointMeta | null;\n /** The authored body \u2014 the section with generated header lines removed. */\n body: string;\n /** The full section, heading included. */\n raw: string;\n}\n\n/**\n * Read the `## Continue` checkpoint out of a TODO.md.\n *\n * Writing a checkpoint is only half of a handover; something has to deliver it\n * to the next session. This is the read side, used by the SessionStart hook.\n *\n * Returns null when there is no section, or when the section holds nothing but\n * generated header lines \u2014 a bodyless block carries no information a new\n * session does not already have, and injecting it would only add noise.\n */\nexport function readContinueCheckpoint(\n content: string\n): ContinueCheckpoint | null {\n const found = locateContinue(content);\n if (!found) return null;\n\n const body = found.meta\n ? extractMarkedBody(found.lines)\n : extractSectionContent(found.lines);\n if (!body) return null;\n\n return { meta: found.meta, body, raw: found.lines.join(\"\\n\") };\n}\n\n/**\n * Extract the body of a marker-delimited block by position rather than by\n * pattern.\n *\n * The layout is fixed: open marker, a contiguous run of `>` header lines, the\n * body, then the close marker. Because the boundaries are known exactly, the\n * body comes back byte-for-byte \u2014 including any `---` rules or `##` headings\n * of its own, which a pattern-based filter would mistake for boilerplate and\n * delete. Falls back to the pattern filter if the shape is not as expected.\n */\nfunction extractMarkedBody(lines: string[]): string {\n const markerIdx = lines.findIndex((l) => parseMarker(l) !== null);\n if (markerIdx === -1) return extractSectionContent(lines);\n\n const closeIdx = lines.findIndex(\n (l, i) => i > markerIdx && l.trim() === MARKER_CLOSE\n );\n if (closeIdx === -1) return extractSectionContent(lines);\n\n // Skip the blank lines and the `>` header run that follow the open marker.\n let bodyStart = markerIdx + 1;\n while (bodyStart < closeIdx) {\n const t = lines[bodyStart].trim();\n if (BLANK_LINE.test(t) || t.startsWith(\">\")) {\n bodyStart += 1;\n continue;\n }\n break;\n }\n\n return trimBlankEdges(lines.slice(bodyStart, closeIdx)).join(\"\\n\");\n}\n\n// ---------------------------------------------------------------------------\n// Block construction\n// ---------------------------------------------------------------------------\n\nexport interface BuildOptions {\n authored: Authorship;\n /** Human-readable session line, or \"Unknown session\". */\n sessionLine: string;\n /** Claude Code session UUID \u2014 the `claude --resume` handle. */\n sessionId?: string;\n cwd: string;\n /** Model-authored markdown. Omitted for auto blocks. */\n body?: string;\n /** Overridable for deterministic tests. */\n timestamp?: string;\n}\n\nfunction escapeAttr(value: string): string {\n return value.replace(/\"/g, \"'\");\n}\n\nexport function buildContinueBlock(opts: BuildOptions): string {\n const ts = opts.timestamp ?? new Date().toISOString();\n\n const attrs = [\n `authored=\"${opts.authored}\"`,\n `session=\"${escapeAttr(opts.sessionLine)}\"`,\n opts.sessionId ? `session-id=\"${escapeAttr(opts.sessionId)}\"` : null,\n `ts=\"${ts}\"`,\n ]\n .filter(Boolean)\n .join(\" \");\n\n const header = [\n `> **Last session:** ${opts.sessionLine}`,\n `> **Paused at:** ${ts}`,\n \">\",\n `> Working directory: ${opts.cwd}`,\n ];\n\n if (opts.sessionId) {\n header.push(\">\", `> Resume with: \\`claude --resume ${opts.sessionId}\\``);\n }\n\n const body = (opts.body ?? \"\").trim();\n\n const parts = [\n CONTINUE_HEADING,\n \"\",\n `${MARKER_OPEN} ${attrs} -->`,\n \"\",\n ...header,\n ];\n\n if (body) {\n parts.push(\"\", body);\n } else {\n parts.push(\n \">\",\n \"> _No checkpoint body was recorded \u2014 see the latest session note._\"\n );\n }\n\n parts.push(\"\", MARKER_CLOSE, \"\", \"---\", \"\");\n\n return parts.join(\"\\n\");\n}\n\n// ---------------------------------------------------------------------------\n// Apply\n// ---------------------------------------------------------------------------\n\nexport interface ApplyOptions extends BuildOptions {\n /** Project root; TODO.md is resolved beneath it. */\n rootPath: string;\n /** Preview only \u2014 nothing is written. */\n dryRun?: boolean;\n}\n\nexport interface ApplyResult {\n action: ApplyAction;\n path: string | null;\n block: string;\n /** Set when action === \"preserved\". */\n preservedMeta?: CheckpointMeta;\n /** Set when unattributed content was carried forward into the new block. */\n carriedForward?: boolean;\n /** Set when a superseded model-authored handover was moved to the archive. */\n archived?: boolean;\n error?: string;\n}\n\n/**\n * Write the ## Continue block, honouring the preservation rules.\n *\n * An AUTO write is unattended \u2014 it fires from the session-stop and pre-compact\n * hooks \u2014 so it operates under one governing rule: **never destroy content it\n * did not author.** Three cases follow from that:\n *\n * 1. An authored checkpoint for the SAME session is left untouched. The hooks\n * fire after the model has already recorded the real state; overwriting it\n * with metadata is the bug this module exists to fix.\n *\n * \"Same session\" is decided by the Claude session UUID whenever both sides\n * know it, and only falls back to the human-readable session line when one\n * of them does not. The line is derived from the session note filename,\n * and `session-stop.sh` *renames and renumbers that file* \u2014 via `session\n * slug --apply` and `session cleanup --execute` \u2014 before it reaches the\n * handover step. So the key the hook computes at exit is not the key the\n * model wrote seconds earlier, and a filename-keyed comparison mismatches\n * by construction. Observed live on 2026-08-01: notes renumbered twice\n * within a single session. The UUID is the only identifier that holds\n * still.\n * 2. An authored checkpoint from an EARLIER session is stale \u2014 TODO.md would\n * otherwise keep pointing at the wrong session \u2014 so it is replaced. Its\n * content is not lost: `pai pause` mirrors every authored body into the\n * session note.\n * 3. An UNMARKED block predates the marker, so authorship cannot be read off\n * it. If it is nothing but generated header lines it is replaced. If it\n * carries anything else, that content was put there deliberately \u2014 quite\n * possibly as a hand-rolled workaround for the very clobbering this fixes \u2014\n * and is carried forward into the new block rather than dropped.\n *\n * A MODEL write always replaces: the model is authoring the checkpoint, and a\n * newer one supersedes an older one.\n */\n/**\n * Do an existing checkpoint and an incoming write describe the same session?\n *\n * The UUID is authoritative when both sides carry one: it is assigned by Claude\n * Code and never changes for the life of the session. The session line is a\n * derived, mutable label and is only consulted when there is no UUID to compare\n * \u2014 a checkpoint written before `--session-id` was threaded through, or an auto\n * write from a caller that was not given one.\n */\nfunction isSameSession(\n meta: CheckpointMeta,\n opts: Pick<ApplyOptions, \"sessionId\" | \"sessionLine\">\n): boolean {\n if (meta.sessionId && opts.sessionId) {\n return meta.sessionId === opts.sessionId;\n }\n return meta.session === opts.sessionLine;\n}\n\n/**\n * How long a model-authored checkpoint is protected from automated overwriting.\n *\n * Sized for the race, not for the session: the gap between a model writing its\n * checkpoint and a hook firing is seconds to minutes, and an hour covers even a\n * slow checkpoint on a loaded machine. Anything genuinely from an earlier\n * session is hours or days old and still falls through to case 2, which is what\n * keeps TODO.md from freezing on a checkpoint nobody will ever replace.\n */\nconst AUTHORED_GRACE_MS = 60 * 60 * 1000;\n\n/** Was this checkpoint written recently enough to still belong to the run in progress? */\nfunction isRecent(meta: CheckpointMeta, now?: string): boolean {\n if (!meta.ts) return false; // no stamp, no claim \u2014 case 2 decides as before\n const then = Date.parse(meta.ts);\n if (Number.isNaN(then)) return false;\n const nowMs = now ? Date.parse(now) : Date.now();\n if (Number.isNaN(nowMs)) return false;\n // Guard the future too: a clock skew that puts the stamp ahead of now must\n // not read as \"very old\" and license an overwrite.\n return nowMs - then < AUTHORED_GRACE_MS;\n}\n\n/**\n * Turn a live checkpoint block into an inert archive entry.\n *\n * Two things have to go: the `## Continue` heading and the `pai:checkpoint`\n * marker. Both are what `locateContinue` looks for, so leaving either in place\n * would let a later write treat the archive as the live section \u2014 and the next\n * one after that would then archive the archive. Everything else is kept\n * verbatim, because the body is the whole reason this exists.\n */\nfunction toArchiveEntry(lines: string[], meta: CheckpointMeta | null): string[] {\n const out: string[] = [];\n const label = meta?.session ?? \"Unknown session\";\n const stamp = meta?.ts ? ` \u2014 checkpointed ${meta.ts}` : \"\";\n out.push(`${ARCHIVE_OPEN} session=\"${label}\"${meta?.ts ? ` ts=\"${meta.ts}\"` : \"\"} -->`);\n out.push(\"\");\n out.push(`### ${label}${stamp}`);\n out.push(\"\");\n for (const line of lines) {\n if (line.trim() === CONTINUE_HEADING) continue;\n if (line.trim().startsWith(MARKER_OPEN)) continue;\n if (line.trim() === MARKER_CLOSE) continue;\n if (line.trim() === \"---\") continue;\n out.push(line);\n }\n while (out.length > 0 && out[out.length - 1].trim() === \"\") out.pop();\n out.push(\"\");\n out.push(ARCHIVE_CLOSE);\n return out;\n}\n\n/**\n * Put a superseded handover into the archive section, newest first.\n *\n * `rest` is the document with the Continue section already stripped. The\n * archive lives immediately below it so a reader meets the current handover\n * first and the previous ones directly after, rather than hunting for them at\n * the bottom of a file that also holds the project's open work.\n */\nfunction archiveInto(rest: string, entry: string[]): string {\n const lines = rest.split(\"\\n\");\n const headingIdx = lines.findIndex((l) => l.trim() === ARCHIVE_HEADING);\n\n if (headingIdx === -1) {\n return [ARCHIVE_HEADING, \"\", ...entry, \"\", \"---\", \"\", rest.trimStart()].join(\"\\n\");\n }\n\n // Insert directly under the heading, then drop whatever falls past the cap.\n const before = lines.slice(0, headingIdx + 1);\n const after = lines.slice(headingIdx + 1);\n while (after.length > 0 && after[0].trim() === \"\") after.shift();\n\n const merged = [...entry, \"\", ...after];\n const kept: string[] = [];\n let seen = 0;\n for (let i = 0; i < merged.length; i++) {\n if (merged[i].trim().startsWith(ARCHIVE_OPEN)) {\n seen += 1;\n if (seen > ARCHIVE_LIMIT) {\n // Everything from here to the end of THIS entry goes; keep scanning so\n // anything after the archive section (other headings, open work) stays.\n while (i < merged.length && merged[i].trim() !== ARCHIVE_CLOSE) i++;\n continue; // skips the close marker too\n }\n }\n kept.push(merged[i]);\n }\n return [...before, \"\", ...kept].join(\"\\n\");\n}\n\nexport function applyContinue(opts: ApplyOptions): ApplyResult {\n const target = resolveTodoTarget(opts.rootPath, { create: !opts.dryRun });\n if (!target) {\n return {\n action: \"failed\",\n path: null,\n block: buildContinueBlock(opts),\n error: \"Could not resolve or create a TODO.md target\",\n };\n }\n\n const existing = locateContinue(target.content);\n let carriedForward = false;\n let effectiveBody = opts.body;\n let archiveEntry: string[] | null = null;\n\n if (opts.authored === \"auto\" && existing) {\n // Case 1 \u2014 authored, same session: hands off.\n if (existing.meta?.authored === \"model\" && isSameSession(existing.meta, opts)) {\n return {\n action: \"preserved\",\n path: target.path,\n block: buildContinueBlock(opts),\n preservedMeta: existing.meta,\n };\n }\n\n // Case 1b \u2014 the incoming write has nothing to say, and what is already\n // there does. A metadata-only block says \"see the latest session note\", so\n // replacing a real handover with one trades content for a pointer \u2014 and the\n // pointer is not always good: observed live on 2026-08-01 in the AIBroker\n // project, where the stop hook's bodyless handover overwrote the autosave's\n // body and named a session note that had never been created, leaving the\n // next session nothing to resume from.\n //\n // Deferring to the session note is only safe when the note demonstrably has\n // the content. This code cannot see that, so it does the one thing that is\n // never wrong: a write with nothing to say does not get to destroy\n // something that does. A stale-but-real handover beats a fresh dead link.\n // Scoped to blocks we marked. An unmarked block falls through to case 3,\n // which salvages its content into the new block rather than freezing the\n // old one \u2014 better, because an unmarked block has no session metadata worth\n // preserving and may predate this scheme entirely.\n //\n // The test is SUBSTANCE, not emptiness. It was emptiness until 2026-08-04,\n // when a session that was opened and immediately exited destroyed the\n // previous day's handover for every project it touched. Its stop hook found\n // no work items and no completion message, so it sent the one line it\n // always builds unconditionally \u2014 `Working directory: \u2026`, a verbatim copy\n // of a line the header already generates. Non-empty by a string test, so\n // this guard stood down; worthless by any other reading. `hasSubstance`\n // asks the question the comment above always claimed to be asking.\n if (\n existing.meta &&\n !hasSubstance(opts.body) &&\n !isBoilerplateOnly(existing.lines)\n ) {\n return {\n action: \"preserved\",\n path: target.path,\n block: buildContinueBlock(opts),\n preservedMeta: existing.meta ?? undefined,\n };\n }\n\n // Case 2b \u2014 a model checkpoint written moments ago is THIS session's,\n // whatever the identity comparison says.\n //\n // Case 2 deliberately lets an auto write replace an authored checkpoint\n // from an EARLIER session, and that policy is right: TODO.md would freeze\n // otherwise, and `pai pause` mirrors authored bodies into the session note.\n // The bug is not the policy, it is that identity DEGRADES and healthy\n // sessions fall into it.\n //\n // `sessionId` is optional on `pai pause`. Without it isSameSession compares\n // a display line containing the note TITLE \u2014 and pausing renames the note.\n // So a session writes a checkpoint, its note is renamed, the hook fires\n // seconds later, no longer recognises its own work, and classifies it as a\n // stale earlier session. Three sessions reported exactly this on\n // 2026-08-03 from one `pai pause all`; one lost its checkpoint three times\n // and recovered from git each time. Sessions without a clean repo would\n // never have known it happened.\n //\n // Recency is the tiebreaker that identity cannot supply. A model checkpoint\n // minutes old is not a stale predecessor by any reading, so an automated\n // digest does not get to overwrite it. Older ones still fall through to\n // case 2 and are replaced as before, which is what stops TODO.md freezing\n // and keeps this from nesting carried-forward blocks without end.\n if (\n existing.meta?.authored === \"model\" &&\n isRecent(existing.meta, opts.timestamp) &&\n !isBoilerplateOnly(existing.lines)\n ) {\n return {\n action: \"preserved\",\n path: target.path,\n block: buildContinueBlock(opts),\n preservedMeta: existing.meta,\n };\n }\n\n // Case 2c \u2014 an authored handover from an EARLIER session, past the grace\n // window. Case 2 deleted it, on the reasoning that TODO.md must not freeze\n // and that `pai pause` mirrors authored bodies into the session note.\n //\n // The first half is right. The second is an assumption, and on 2026-08-04\n // it was false in the plainest way available: this project keeps no session\n // notes at all (`Notes/*` is gitignored and none exist on disk), so the\n // checkpoint WAS the only copy. It went at 08:36:29Z, to the autosave of a\n // session that had been open for sixteen minutes and had typed one line \u2014\n // destroying, as its first act, the handover it had been started to read.\n //\n // This is also the escape the v0.27.2 guard could not close: that one asks\n // whether the block is RECENT, and a handover written the night before is\n // not. Age was never the question. Nothing here needs the old block gone;\n // it only needs the slot. So the block moves down the file instead, and the\n // freeze argument and the loss argument stop being in tension.\n //\n // Only for `model` blocks with something in them. An auto block is a\n // transcript scrape that regenerates on the next tick, and archiving those\n // would bury the real handovers under mechanical noise.\n if (\n existing.meta?.authored === \"model\" &&\n !isBoilerplateOnly(existing.lines) &&\n !isSameSession(existing.meta, opts)\n ) {\n archiveEntry = toArchiveEntry(existing.lines, existing.meta);\n }\n\n // Case 3 \u2014 unmarked block holding content nobody can attribute to us.\n if (!existing.meta && !isBoilerplateOnly(existing.lines)) {\n const salvaged = extractSectionContent(existing.lines);\n if (salvaged) {\n effectiveBody = [\n \"_Carried forward from the previous checkpoint (author unknown \u2014 this\",\n \"block predates checkpoint authorship markers):_\",\n \"\",\n salvaged,\n ].join(\"\\n\");\n carriedForward = true;\n }\n }\n }\n\n const block = buildContinueBlock({ ...opts, body: effectiveBody });\n\n if (opts.dryRun) {\n return { action: \"written\", path: target.path, block, carriedForward, archived: !!archiveEntry };\n }\n\n const rest = stripContinue(target.content).trimStart();\n const newContent = block + (archiveEntry ? archiveInto(rest, archiveEntry) : rest);\n const tmpPath = `${target.path}.continue.tmp`;\n\n try {\n writeFileSync(tmpPath, newContent, \"utf8\");\n renameSync(tmpPath, target.path);\n } catch (err) {\n try {\n if (existsSync(tmpPath)) renameSync(tmpPath, `${tmpPath}.dead`);\n } catch {\n /* ignore */\n }\n return {\n action: \"failed\",\n path: target.path,\n block,\n error: String(err),\n };\n }\n\n return { action: \"written\", path: target.path, block, carriedForward, archived: !!archiveEntry };\n}\n\n// ---------------------------------------------------------------------------\n// Session-note discovery\n//\n// Lifted verbatim from end.ts so pause, end and any future caller share one\n// implementation. end.ts now imports these rather than carrying its own copy.\n// ---------------------------------------------------------------------------\n\n/** PAI_DIR \u2014 mirrors pai-paths.ts resolution. */\nfunction getPaiDir(): string {\n const envDir = process.env.PAI_DIR;\n if (envDir) {\n try {\n return realpathSync(envDir);\n } catch {\n return envDir;\n }\n }\n return join(homedir(), \".claude\");\n}\n\n/**\n * Find the notes directory for a project \u2014 local first, then the central\n * ~/.claude/projects/<encoded>/Notes fallback. Never creates.\n */\nexport function findNotesDir(\n rootPath: string,\n encodedDir: string\n): string | null {\n for (const rel of [\"Notes\", \"notes\", \".claude/Notes\"]) {\n const p = join(rootPath, rel);\n if (existsSync(p)) return p;\n }\n const central = join(getPaiDir(), \"projects\", encodedDir, \"Notes\");\n if (existsSync(central)) return central;\n return null;\n}\n\n/**\n * Find the current (highest-numbered) session note: current month, then the\n * previous month, then a flat notesDir as legacy fallback.\n */\nexport function findLatestNote(notesDir: string): string | null {\n const findIn = (dir: string): string | null => {\n if (!existsSync(dir)) return null;\n let files: string[];\n try {\n files = readdirSync(dir);\n } catch {\n return null;\n }\n const notes = files\n .filter((f) => /^\\d{3,4}[\\s_-].*\\.md$/.test(f))\n .sort((a, b) => {\n const na = parseInt(a.match(/^(\\d+)/)?.[1] ?? \"0\", 10);\n const nb = parseInt(b.match(/^(\\d+)/)?.[1] ?? \"0\", 10);\n return na - nb;\n });\n return notes.length > 0 ? join(dir, notes[notes.length - 1]) : null;\n };\n\n const now = new Date();\n const year = String(now.getFullYear());\n const month = String(now.getMonth() + 1).padStart(2, \"0\");\n\n const current = findIn(join(notesDir, year, month));\n if (current) return current;\n\n const prev = new Date(now.getFullYear(), now.getMonth() - 1, 1);\n const py = String(prev.getFullYear());\n const pm = String(prev.getMonth() + 1).padStart(2, \"0\");\n const prevFound = findIn(join(notesDir, py, pm));\n if (prevFound) return prevFound;\n\n return findIn(notesDir);\n}\n\n// ---------------------------------------------------------------------------\n// Session-note append\n// ---------------------------------------------------------------------------\n\n/**\n * Append the checkpoint body to a session note.\n *\n * TODO.md's ## Continue is a single slot that every later checkpoint\n * overwrites. The session note is the durable record, so the body goes to both.\n * Idempotent per timestamp: re-running with the same stamp will not duplicate.\n */\nexport function appendCheckpointToNote(\n notePath: string,\n body: string,\n timestamp?: string\n): { appended: boolean; error?: string } {\n const ts = timestamp ?? new Date().toISOString();\n const heading = `## Pause Checkpoint \u2014 ${ts}`;\n\n let existing: string;\n try {\n existing = readFileSync(notePath, \"utf8\");\n } catch (err) {\n return { appended: false, error: String(err) };\n }\n\n if (existing.includes(heading)) return { appended: false };\n\n const block = `\\n\\n---\\n\\n${heading}\\n\\n${body.trim()}\\n`;\n const tmpPath = `${notePath}.checkpoint.tmp`;\n\n try {\n writeFileSync(tmpPath, existing.trimEnd() + block, \"utf8\");\n renameSync(tmpPath, notePath);\n } catch (err) {\n try {\n if (existsSync(tmpPath)) renameSync(tmpPath, `${tmpPath}.dead`);\n } catch {\n /* ignore */\n }\n return { appended: false, error: String(err) };\n }\n\n return { appended: true };\n}\n\n// ---------------------------------------------------------------------------\n// Body loading\n// ---------------------------------------------------------------------------\n\n/** Read a checkpoint body from a file, or from stdin when path is \"-\". */\nexport function readBodyFile(path: string): string {\n if (path === \"-\") {\n return readFileSync(0, \"utf8\");\n }\n return readFileSync(path, \"utf8\");\n}\n", "/**\n * Path utilities \u2014 encoding, Notes/Sessions directory discovery and creation.\n */\n\nimport { existsSync, mkdirSync, readdirSync, linkSync, copyFileSync } from 'fs';\nimport { join, basename } from 'path';\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 * Directories known to be automated health-check / probe sessions.\n * Hooks should exit early for these to avoid registry clutter and wasted work.\n */\nconst PROBE_CWD_PATTERNS = [\n '/CodexBar/ClaudeProbe',\n '/ClaudeProbe',\n];\n\n/**\n * Check if the current working directory belongs to a probe/health-check session.\n * Returns true if hooks should skip this session entirely.\n */\nexport function isProbeSession(cwd?: string): boolean {\n const dir = cwd || process.cwd();\n return PROBE_CWD_PATTERNS.some(pattern => dir.includes(pattern));\n}\n\n/**\n * Encode a path the same way Claude Code does:\n * - Replace / with -\n * - Replace . with -\n * - Replace space with -\n */\nexport function encodePath(path: string): string {\n return path\n .replace(/\\//g, '-')\n .replace(/\\./g, '-')\n .replace(/ /g, '-');\n}\n\n/** Get the project directory for a given working directory. */\nexport function getProjectDir(cwd: string): string {\n const encoded = encodePath(cwd);\n return join(PROJECTS_DIR, encoded);\n}\n\n/** Get the Notes directory for a project (central location). */\nexport function getNotesDir(cwd: string): string {\n return join(getProjectDir(cwd), 'Notes');\n}\n\n/**\n * Find Notes directory \u2014 checks local first, falls back to central.\n * Does NOT create the directory.\n */\nexport function findNotesDir(cwd: string): { path: string; isLocal: boolean } {\n const cwdBasename = basename(cwd).toLowerCase();\n if (cwdBasename === 'notes' && existsSync(cwd)) {\n return { path: cwd, isLocal: true };\n }\n\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 return { path: getNotesDir(cwd), isLocal: false };\n}\n\n/** Get the sessions/ directory for a project (stores .jsonl transcripts). */\nexport function getSessionsDir(cwd: string): string {\n return join(getProjectDir(cwd), 'sessions');\n}\n\n/** Get the sessions/ directory from a project directory path. */\nexport function getSessionsDirFromProjectDir(projectDir: string): string {\n return join(projectDir, 'sessions');\n}\n\n// ---------------------------------------------------------------------------\n// Directory creation helpers\n// ---------------------------------------------------------------------------\n\n/** Ensure the Notes directory exists for a project. @deprecated Use ensureNotesDirSmart() */\nexport function ensureNotesDir(cwd: string): string {\n const notesDir = getNotesDir(cwd);\n if (!existsSync(notesDir)) {\n mkdirSync(notesDir, { recursive: true });\n console.error(`Created Notes directory: ${notesDir}`);\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 */\nexport function ensureNotesDirSmart(cwd: string): { path: string; isLocal: boolean } {\n const found = findNotesDir(cwd);\n if (found.isLocal) return found;\n if (!existsSync(found.path)) {\n mkdirSync(found.path, { recursive: true });\n console.error(`Created central Notes directory: ${found.path}`);\n }\n return found;\n}\n\n/** Ensure the sessions/ directory exists for a project. */\nexport function ensureSessionsDir(cwd: string): string {\n const sessionsDir = getSessionsDir(cwd);\n if (!existsSync(sessionsDir)) {\n mkdirSync(sessionsDir, { recursive: true });\n console.error(`Created sessions directory: ${sessionsDir}`);\n }\n return sessionsDir;\n}\n\n/** Ensure the sessions/ directory exists (from project dir path). */\nexport function ensureSessionsDirFromProjectDir(projectDir: string): string {\n const sessionsDir = getSessionsDirFromProjectDir(projectDir);\n if (!existsSync(sessionsDir)) {\n mkdirSync(sessionsDir, { recursive: true });\n console.error(`Created sessions directory: ${sessionsDir}`);\n }\n return sessionsDir;\n}\n\n/**\n * Publish every project-root .jsonl into sessions/ as well, WITHOUT removing it.\n *\n * This used to renameSync, and that is how PAI destroyed its users' sessions.\n *\n * `claude --resume <uuid>` finds a transcript only at the project root. Move it\n * into sessions/ and the session becomes permanently unresumable \u2014 measured\n * 2026-08-04: `claude --resume b3462801` (867 KB of real work, sessions/ only)\n * answers \"No conversation found with session ID\", while a top-level id is found\n * fine. Nothing warned; the id still looked valid everywhere PAI displayed it.\n *\n * The damage was not occasional. This ran from a UserPromptSubmit hook excluding\n * only the CURRENT session, so every prompt anyone typed unresumed every other\n * session in the project. One PAI project measured 1 transcript at top level\n * against 52 underneath. Among the casualties was 046bb712 \u2014 the exact id PAI's\n * own handover tells the user to resume.\n *\n * A hardlink satisfies both sides, which is why this is a two-line fix rather\n * than a redesign: the archive genuinely has consumers that read sessions/\n * (session-summary-worker, registry/moved, session/autosave), and `--resume`\n * needs the root path. One inode, two names, no copy, no window where the file\n * is missing from either place.\n *\n * Never unlink the source. Tidying up another tool's store was the whole\n * mistake; a stale duplicate is free, a lost session is not. Every caller of\n * `transcriptFiles()` was checked before choosing this \u2014 they all test emptiness\n * (`.length > 0`), never count, so the duplicate cannot skew a project's stats.\n *\n * `excludeFile` keeps a hook from archiving the transcript it is itself watching\n * being written. It is not a \"finished sessions only\" guard and must not be read\n * as one: it excludes exactly one file, the caller's own, so with two sessions\n * live in one project each still archives the other mid-turn. A consumer that\n * needs \"finished only\" has to enforce that itself \u2014 this function cannot know\n * what is live.\n *\n * Returns the number of files newly archived.\n */\nexport function archiveSessionFilesToSessionsDir(\n projectDir: string,\n excludeFile?: string,\n silent = false\n): number {\n const sessionsDir = ensureSessionsDirFromProjectDir(projectDir);\n\n if (!existsSync(projectDir)) return 0;\n\n const files = readdirSync(projectDir);\n let archivedCount = 0;\n\n for (const file of files) {\n if (!file.endsWith('.jsonl') || file === excludeFile) continue;\n\n const sourcePath = join(projectDir, file);\n const destPath = join(sessionsDir, file);\n\n // Already archived \u2014 including by an earlier rename, before this was a\n // hardlink. Those are the sessions that need restoring to the root, which is\n // a separate repair and not this function's job.\n if (existsSync(destPath)) continue;\n\n try {\n linkSync(sourcePath, destPath);\n if (!silent) console.error(`Archived ${file} \u2192 sessions/ (still resumable)`);\n archivedCount++;\n } catch (error) {\n // Cross-device (EXDEV) is the realistic failure: sessions/ on another\n // volume. Copy instead, and still leave the original alone.\n try {\n copyFileSync(sourcePath, destPath);\n if (!silent) console.error(`Copied ${file} \u2192 sessions/ (hardlink unavailable)`);\n archivedCount++;\n } catch {\n if (!silent) console.error(`Could not archive ${file}: ${error}`);\n }\n }\n }\n\n return archivedCount;\n}\n\n/**\n * @deprecated Renamed to `archiveSessionFilesToSessionsDir`, which is what it\n * now does. Kept so an out-of-tree caller fails loudly at the type level rather\n * than silently keeping the old destructive name for a non-destructive action.\n */\nexport const moveSessionFilesToSessionsDir = archiveSessionFilesToSessionsDir;\n\n// ---------------------------------------------------------------------------\n// CLAUDE.md / TODO.md discovery\n// ---------------------------------------------------------------------------\n\n/** Find TODO.md \u2014 check local first, fallback to central. */\nexport function findTodoPath(cwd: string): string {\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)) return path;\n }\n\n return join(getNotesDir(cwd), 'TODO.md');\n}\n\n/** Find CLAUDE.md \u2014 returns the FIRST found path. */\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 in priority order.\n */\nexport function findAllClaudeMdPaths(cwd: string): string[] {\n const foundPaths: string[] = [];\n\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)) foundPaths.push(path);\n }\n\n return foundPaths;\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", "/**\n * Push notification helpers \u2014 WhatsApp-aware with ntfy.sh fallback.\n */\n\nimport { existsSync, readFileSync } from 'fs';\nimport { join } from 'path';\nimport { homedir } from 'os';\n\n/**\n * Check if a messaging MCP server (AIBroker, Whazaa, or Telex) is configured.\n * When any messaging server is active, the AI handles notifications via MCP\n * and ntfy is skipped to avoid duplicates.\n */\nexport function isWhatsAppEnabled(): boolean {\n try {\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('aibroker') || enabled.includes('whazaa') || enabled.includes('telex');\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.\n * When WhatsApp is NOT configured, ntfy fires as the fallback channel.\n */\nexport async function sendNtfyNotification(message: string, retries = 2): Promise<boolean> {\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 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 * Session note creation, editing, checkpointing, renaming, and finalization.\n */\n\nimport { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync } from 'fs';\nimport { join, basename } from 'path';\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\n/** Get or create the YYYY/MM subdirectory for the current month inside notesDir. */\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// Public API\n// ---------------------------------------------------------------------------\n\n/**\n * Get the next note number (4-digit format: 0001, 0002, etc.).\n * Numbers are scoped per YYYY/MM directory.\n */\nexport function getNextNoteNumber(notesDir: string): string {\n const monthDir = getMonthDir(notesDir);\n\n const files = readdirSync(monthDir)\n .filter(f => f.match(/^\\d{3,4}[\\s_-]/))\n .sort();\n\n if (files.length === 0) return '0001';\n\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 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 current month \u2192 previous month \u2192 flat notesDir (legacy).\n */\nexport function getCurrentNotePath(notesDir: string): string | null {\n if (!existsSync(notesDir)) return null;\n\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 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 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 return findLatestIn(notesDir);\n}\n\n/**\n * Create a new session note.\n * Format: \"NNNN - YYYY-MM-DD - New Session.md\" filed into YYYY/MM subdirectory.\n * Claude MUST rename at session end with a meaningful description.\n */\nexport function createSessionNote(notesDir: string, description: string): string {\n const noteNumber = getNextNoteNumber(notesDir);\n const date = new Date().toISOString().split('T')[0];\n const monthDir = getMonthDir(notesDir);\n const filename = `${noteNumber} - ${date} - New Session.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/** Append a checkpoint to the current session note. */\nexport function appendCheckpoint(notePath: string, checkpoint: string): void {\n if (!existsSync(notePath)) {\n console.error(`Note file not found, recreating: ${notePath}`);\n try {\n const parentDir = join(notePath, '..');\n if (!existsSync(parentDir)) mkdirSync(parentDir, { recursive: true });\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 const nextStepsIndex = content.indexOf('## Next Steps');\n const newContent = nextStepsIndex !== -1\n ? content.substring(0, nextStepsIndex) + checkpointText + content.substring(nextStepsIndex)\n : content + checkpointText;\n\n writeFileSync(notePath, newContent);\n console.error(`Checkpoint added to: ${basename(notePath)}`);\n}\n\n/** Work item for session notes. */\nexport interface WorkItem {\n title: string;\n details?: string[];\n completed?: boolean;\n}\n\n/** Add work items to the \"Work Done\" section of a session note. */\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 let workText = '';\n if (sectionTitle) workText += `\\n### ${sectionTitle}\\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 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 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 * Check if a candidate title is meaningless / garbage.\n * Public wrapper around the internal filter for use by other hooks.\n */\nexport function isMeaningfulTitle(text: string): boolean {\n return !isMeaninglessCandidate(text);\n}\n\n/** Sanitize a string for use in a filename. */\nexport function sanitizeForFilename(str: string): string {\n return str\n .toLowerCase()\n .replace(/[^a-z0-9\\s-]/g, '')\n .replace(/\\s+/g, '-')\n .replace(/-+/g, '-')\n .replace(/^-|-$/g, '')\n .substring(0, 50);\n}\n\n/**\n * Return true if the candidate string should be rejected as a meaningful name.\n * Rejects file paths, shebangs, timestamps, system noise, XML tags, hashes, etc.\n */\nfunction isMeaninglessCandidate(text: string): boolean {\n const t = text.trim();\n if (!t) return true;\n if (t.length < 5) return true; // too short to be meaningful\n if (t.startsWith('/') || t.startsWith('~')) return true; // file path\n if (t.startsWith('#!')) return true; // shebang\n if (t.includes('[object Object]')) return true; // serialization artifact\n if (/^\\d{4}-\\d{2}-\\d{2}(T[\\d:.Z+-]+)?$/.test(t)) return true; // ISO timestamp\n if (/^\\d{1,2}:\\d{2}(:\\d{2})?(\\s*(AM|PM))?$/i.test(t)) return true; // time-only\n if (/^<[a-z-]+[\\s/>]/i.test(t)) return true; // XML/HTML tags (<task-notification>, etc.)\n if (/^[0-9a-f]{10,}$/i.test(t)) return true; // hex hash strings\n if (/^Exit code \\d+/i.test(t)) return true; // exit code messages\n if (/^Error:/i.test(t)) return true; // error messages\n if (/^This session is being continued/i.test(t)) return true; // continuation boilerplate\n if (/^\\(Bash completed/i.test(t)) return true; // bash output noise\n if (/^Task Notification$/i.test(t)) return true; // literal \"Task Notification\"\n if (/^New Session$/i.test(t)) return true; // placeholder title\n if (/^Recovered Session$/i.test(t)) return true; // placeholder title\n if (/^Continued Session$/i.test(t)) return true; // placeholder title\n if (/^Untitled Session$/i.test(t)) return true; // placeholder title\n if (/^Context Compression$/i.test(t)) return true; // compression artifact\n if (/^[A-Fa-f0-9]{8,}\\s+Output$/i.test(t)) return true; // hash + \"Output\" pattern\n return false;\n}\n\n/**\n * Extract a meaningful name from session note content and summary.\n * Looks at Work Done section headers, bold text, and summary.\n */\nexport function extractMeaningfulName(noteContent: string, summary: string): string {\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 const subheadings = workDoneSection.match(/### ([^\\n]+)/g);\n if (subheadings && subheadings.length > 0) {\n const firstHeading = subheadings[0].replace('### ', '').trim();\n if (!isMeaninglessCandidate(firstHeading) && firstHeading.length > 5 && firstHeading.length < 60) {\n return sanitizeForFilename(firstHeading);\n }\n }\n\n const boldMatches = workDoneSection.match(/\\*\\*([^*]+)\\*\\*/g);\n if (boldMatches && boldMatches.length > 0) {\n const firstBold = boldMatches[0].replace(/\\*\\*/g, '').trim();\n if (!isMeaninglessCandidate(firstBold) && firstBold.length > 3 && firstBold.length < 50) {\n return sanitizeForFilename(firstBold);\n }\n }\n\n const numberedItems = workDoneSection.match(/^\\d+\\.\\s+\\*\\*([^*]+)\\*\\*/m);\n if (numberedItems && !isMeaninglessCandidate(numberedItems[1])) {\n return sanitizeForFilename(numberedItems[1]);\n }\n }\n\n if (summary && summary.length > 5 && summary !== 'Session completed.' && !isMeaninglessCandidate(summary)) {\n const cleanSummary = summary\n .replace(/[^\\w\\s-]/g, ' ')\n .trim()\n .split(/\\s+/)\n .slice(0, 5)\n .join(' ');\n if (cleanSummary.length > 3 && !isMeaninglessCandidate(cleanSummary)) {\n return sanitizeForFilename(cleanSummary);\n }\n }\n\n return '';\n}\n\n/**\n * Rename a session note with a meaningful name.\n * Always uses \"NNNN - YYYY-MM-DD - Description.md\" format.\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)) return notePath;\n\n const dir = join(notePath, '..');\n const oldFilename = basename(notePath);\n\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 const match = correctMatch || legacyMatch;\n if (!match) return notePath;\n\n const [, noteNumber, date] = match;\n\n const titleCaseName = meaningfulName\n .split(/[\\s_-]+/)\n .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())\n .join(' ')\n .trim();\n\n const paddedNumber = noteNumber.padStart(4, '0');\n const newFilename = `${paddedNumber} - ${date} - ${titleCaseName}.md`;\n const newPath = join(dir, newFilename);\n\n if (newFilename === oldFilename) return notePath;\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/** Update the session note's H1 title and rename the file. */\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 content = content.replace(/^# Session \\d+:.*$/m, (match) => {\n const sessionNum = match.match(/Session (\\d+)/)?.[1] || '';\n return `# Session ${sessionNum}: ${newTitle}`;\n });\n writeFileSync(notePath, content);\n renameSessionNote(notePath, sanitizeForFilename(newTitle));\n}\n\n/**\n * Finalize session note \u2014 mark as complete, add summary, rename with meaningful name.\n * IDEMPOTENT: subsequent calls are no-ops if already finalized.\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 if (content.includes('**Status:** Completed')) {\n console.error(`Note already finalized: ${basename(notePath)}`);\n return notePath;\n }\n\n content = content.replace('**Status:** In Progress', '**Status:** Completed');\n\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 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 const meaningfulName = extractMeaningfulName(content, summary);\n if (meaningfulName) {\n return renameSessionNote(notePath, meaningfulName);\n }\n\n return notePath;\n}\n"],
|
|
5
5
|
"mappings": "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",
|
|
6
6
|
"names": ["existsSync", "readFileSync", "join", "basename", "resolve", "homedir", "existsSync", "readdirSync", "join", "basename", "homedir", "join", "existsSync", "readFileSync", "join", "basename", "existsSync", "join", "existsSync", "readdirSync", "join", "join", "existsSync", "join", "existsSync", "existsSync", "readFileSync", "join", "homedir", "resolve", "existsSync", "mkdirSync", "readdirSync", "readFileSync", "join", "basename", "existsSync", "join", "readFileSync", "homedir", "resolve", "basename", "mkdirSync", "writeFileSync"]
|
|
7
7
|
}
|