@tekmidian/pai 0.36.2 → 0.37.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.
Files changed (157) hide show
  1. package/dist/{auto-route-Byf8ENXj.mjs → auto-route-DM7GhJ8y.mjs} +2 -2
  2. package/dist/{auto-route-Byf8ENXj.mjs.map → auto-route-DM7GhJ8y.mjs.map} +1 -1
  3. package/dist/cli/index.mjs +5 -4
  4. package/dist/cli/index.mjs.map +1 -1
  5. package/dist/cli/program.d.mts.map +1 -1
  6. package/dist/cli/program.mjs +5 -4
  7. package/dist/{clusters-wZgTCYCB.mjs → clusters-Do4tEGyc.mjs} +1 -1
  8. package/dist/{clusters-wZgTCYCB.mjs.map → clusters-Do4tEGyc.mjs.map} +1 -1
  9. package/dist/{context-handover-cache-PtNvj_8D.mjs → context-handover-cache-BpUojjsi.mjs} +2 -2
  10. package/dist/{context-handover-cache-PtNvj_8D.mjs.map → context-handover-cache-BpUojjsi.mjs.map} +1 -1
  11. package/dist/daemon/index.mjs +8 -8
  12. package/dist/{daemon-BZ93KRBo.mjs → daemon-CGg1VCbA.mjs} +25 -25
  13. package/dist/{daemon-BZ93KRBo.mjs.map → daemon-CGg1VCbA.mjs.map} +1 -1
  14. package/dist/daemon-DsGGiIJM.mjs +20 -0
  15. package/dist/daemon-mcp/index.mjs +428 -1
  16. package/dist/daemon-mcp/index.mjs.map +1 -1
  17. package/dist/{detector--Gg5JRN5.mjs → detector-CMap-9vw.mjs} +1 -1
  18. package/dist/{detector--Gg5JRN5.mjs.map → detector-CMap-9vw.mjs.map} +1 -1
  19. package/dist/detector-DtLExmHN.mjs +5 -0
  20. package/dist/factory-BXzqRYVZ.mjs +3 -0
  21. package/dist/{factory-Bsp7xOpO.mjs → factory-DD2T33C9.mjs} +5 -5
  22. package/dist/{factory-Bsp7xOpO.mjs.map → factory-DD2T33C9.mjs.map} +1 -1
  23. package/dist/hooks/context-compression-hook.mjs +164 -14
  24. package/dist/hooks/context-compression-hook.mjs.map +4 -4
  25. package/dist/hooks/initialize-session.mjs +6 -0
  26. package/dist/hooks/initialize-session.mjs.map +3 -3
  27. package/dist/hooks/load-core-context.mjs +6 -0
  28. package/dist/hooks/load-core-context.mjs.map +3 -3
  29. package/dist/hooks/load-project-context.mjs +6 -0
  30. package/dist/hooks/load-project-context.mjs.map +3 -3
  31. package/dist/hooks/post-compact-inject.mjs +6 -0
  32. package/dist/hooks/post-compact-inject.mjs.map +3 -3
  33. package/dist/hooks/route-agents-to-worker.mjs +456 -0
  34. package/dist/hooks/route-agents-to-worker.mjs.map +7 -0
  35. package/dist/hooks/status-line.mjs +400 -0
  36. package/dist/hooks/status-line.mjs.map +7 -0
  37. package/dist/hooks/stop-hook.mjs +6 -0
  38. package/dist/hooks/stop-hook.mjs.map +3 -3
  39. package/dist/hooks/subagent-stop-hook.mjs +6 -0
  40. package/dist/hooks/subagent-stop-hook.mjs.map +3 -3
  41. package/dist/hooks/sync-todo-to-md.mjs +6 -0
  42. package/dist/hooks/sync-todo-to-md.mjs.map +3 -3
  43. package/dist/hooks/update-tab-on-action.mjs +6 -0
  44. package/dist/hooks/update-tab-on-action.mjs.map +3 -3
  45. package/dist/hooks/update-tab-titles.mjs +6 -0
  46. package/dist/hooks/update-tab-titles.mjs.map +3 -3
  47. package/dist/hooks/worker-proxy.mjs +796 -0
  48. package/dist/hooks/worker-proxy.mjs.map +7 -0
  49. package/dist/hooks/worker-status-line.mjs +483 -0
  50. package/dist/hooks/worker-status-line.mjs.map +7 -0
  51. package/dist/{indexer-backend-nQZuEx6N.mjs → indexer-backend-Cox9BCo-.mjs} +1 -1
  52. package/dist/{indexer-backend-nQZuEx6N.mjs.map → indexer-backend-Cox9BCo-.mjs.map} +1 -1
  53. package/dist/{kg-entity-DbOMPdF9.mjs → kg-entity-r8duqhi9.mjs} +1 -1
  54. package/dist/{kg-entity-DbOMPdF9.mjs.map → kg-entity-r8duqhi9.mjs.map} +1 -1
  55. package/dist/{latent-ideas-Bn6A5-5P.mjs → latent-ideas-BC1oINZ-.mjs} +2 -2
  56. package/dist/{latent-ideas-Bn6A5-5P.mjs.map → latent-ideas-BC1oINZ-.mjs.map} +1 -1
  57. package/dist/{link-boost-fYjUnxCN.mjs → link-boost-QFLrJwD6.mjs} +1 -1
  58. package/dist/{link-boost-fYjUnxCN.mjs.map → link-boost-QFLrJwD6.mjs.map} +1 -1
  59. package/dist/{main-resolver-BAbhKpeX.mjs → main-resolver-DlaLOFBA.mjs} +1 -1
  60. package/dist/{main-resolver-BAbhKpeX.mjs.map → main-resolver-DlaLOFBA.mjs.map} +1 -1
  61. package/dist/{main-resolver-Dxh444GO.mjs → main-resolver-IhZo4pI0.mjs} +1 -1
  62. package/dist/{neighborhood-DpaEM991.mjs → neighborhood-D9MJ1c8f.mjs} +1 -1
  63. package/dist/{neighborhood-DpaEM991.mjs.map → neighborhood-D9MJ1c8f.mjs.map} +1 -1
  64. package/dist/{note-context-DrcY4cWm.mjs → note-context-d1wT_-GA.mjs} +1 -1
  65. package/dist/{note-context-DrcY4cWm.mjs.map → note-context-d1wT_-GA.mjs.map} +1 -1
  66. package/dist/{postgres-BVme6qX0.mjs → postgres--BjPtLa0.mjs} +1 -1
  67. package/dist/{postgres-BVme6qX0.mjs.map → postgres--BjPtLa0.mjs.map} +1 -1
  68. package/dist/{program-C-fUghPv.mjs → program-CEIHn_Ma.mjs} +693 -101
  69. package/dist/program-CEIHn_Ma.mjs.map +1 -0
  70. package/dist/providers-sXcK5bDZ.mjs +3067 -0
  71. package/dist/providers-sXcK5bDZ.mjs.map +1 -0
  72. package/dist/{query-feedback-C1T6kS18.mjs → query-feedback-BUJxgw5B.mjs} +1 -1
  73. package/dist/{query-feedback-C1T6kS18.mjs.map → query-feedback-BUJxgw5B.mjs.map} +1 -1
  74. package/dist/query-feedback-DhyLOe5S.mjs +3 -0
  75. package/dist/router-1zi8jiNF.mjs +3 -0
  76. package/dist/{router-CsDm7HvK.mjs → router-DK_sLsUL.mjs} +1 -1
  77. package/dist/{router-CsDm7HvK.mjs.map → router-DK_sLsUL.mjs.map} +1 -1
  78. package/dist/skills/Worker/SKILL.md +41 -0
  79. package/dist/{sources-D8ZdNfvK.mjs → sources-Bi7--33T.mjs} +1 -1
  80. package/dist/{sources-D8ZdNfvK.mjs.map → sources-Bi7--33T.mjs.map} +1 -1
  81. package/dist/{sqlite-D1IaR8Am.mjs → sqlite-DtaL1glm.mjs} +1 -1
  82. package/dist/{sqlite-D1IaR8Am.mjs.map → sqlite-DtaL1glm.mjs.map} +1 -1
  83. package/dist/{state-qtmrBWCm.mjs → state-8Hm9E4tW.mjs} +1 -1
  84. package/dist/{state-WaXhLr6R.mjs → state-BY2L6-vX.mjs} +1 -1
  85. package/dist/{state-WaXhLr6R.mjs.map → state-BY2L6-vX.mjs.map} +1 -1
  86. package/dist/{themes-XPkj_bfP.mjs → themes-BN0a2duq.mjs} +1 -1
  87. package/dist/{themes-XPkj_bfP.mjs.map → themes-BN0a2duq.mjs.map} +1 -1
  88. package/dist/{tools-DEt6YPfc.mjs → tools-CGPqpU3A.mjs} +1 -1
  89. package/dist/{tools-ceiy7ANX.mjs → tools-y2bJpKom.mjs} +14 -14
  90. package/dist/{tools-ceiy7ANX.mjs.map → tools-y2bJpKom.mjs.map} +1 -1
  91. package/dist/{trace-DfyGmMG_.mjs → trace-h23JCcFD.mjs} +1 -1
  92. package/dist/{trace-DfyGmMG_.mjs.map → trace-h23JCcFD.mjs.map} +1 -1
  93. package/dist/{vault-indexer-CFvlPUMB.mjs → vault-indexer-DgsPjMgs.mjs} +1 -1
  94. package/dist/{vault-indexer-CFvlPUMB.mjs.map → vault-indexer-DgsPjMgs.mjs.map} +1 -1
  95. package/dist/{work-queue-worker-B8W8_3Rn.mjs → work-queue-worker-Dva_v_pI.mjs} +4 -4
  96. package/dist/{work-queue-worker-B8W8_3Rn.mjs.map → work-queue-worker-Dva_v_pI.mjs.map} +1 -1
  97. package/dist/{work-queue-worker-HN2Ufg-L.mjs → work-queue-worker-gsKd2LJa.mjs} +4 -4
  98. package/dist/{zettelkasten-CvjmMghT.mjs → zettelkasten-vo7psPdT.mjs} +3 -3
  99. package/dist/{zettelkasten-CvjmMghT.mjs.map → zettelkasten-vo7psPdT.mjs.map} +1 -1
  100. package/docs/commands/README.md +28 -0
  101. package/docs/commands/backup.md +1 -1
  102. package/docs/commands/clear-names.md +1 -1
  103. package/docs/commands/daemon.md +1 -1
  104. package/docs/commands/db.md +1 -1
  105. package/docs/commands/end.md +1 -1
  106. package/docs/commands/help.md +1 -1
  107. package/docs/commands/identity.md +1 -1
  108. package/docs/commands/kg.md +1 -1
  109. package/docs/commands/mcp.md +1 -1
  110. package/docs/commands/memory.md +1 -1
  111. package/docs/commands/notify.md +1 -1
  112. package/docs/commands/observation.md +1 -1
  113. package/docs/commands/obsidian.md +1 -1
  114. package/docs/commands/pause.md +1 -1
  115. package/docs/commands/project.md +1 -1
  116. package/docs/commands/projects.md +1 -1
  117. package/docs/commands/registry.md +1 -1
  118. package/docs/commands/restore.md +1 -1
  119. package/docs/commands/session.md +1 -1
  120. package/docs/commands/sessions.md +1 -1
  121. package/docs/commands/setup.md +1 -1
  122. package/docs/commands/shell-init.md +1 -1
  123. package/docs/commands/skill.md +1 -1
  124. package/docs/commands/task.md +1 -1
  125. package/docs/commands/topic.md +1 -1
  126. package/docs/commands/update.md +1 -1
  127. package/docs/commands/worker.md +361 -0
  128. package/docs/commands/zettel.md +1 -1
  129. package/docs/worker.md +303 -0
  130. package/package.json +1 -1
  131. package/scripts/build-hooks.mjs +46 -1
  132. package/src/hooks/pre-compact.sh +1 -0
  133. package/src/hooks/session-autosave.sh +1 -0
  134. package/src/hooks/session-stop.sh +1 -0
  135. package/src/hooks/ts/lib/context-fill.test.ts +104 -0
  136. package/src/hooks/ts/lib/context-fill.ts +73 -0
  137. package/src/hooks/ts/lib/handover-evidence.ts +227 -0
  138. package/src/hooks/ts/lib/worker-session.test.ts +24 -0
  139. package/src/hooks/ts/lib/worker-session.ts +23 -0
  140. package/src/hooks/ts/post-tool-use/sync-todo-to-md.ts +2 -0
  141. package/src/hooks/ts/post-tool-use/update-tab-on-action.ts +2 -0
  142. package/src/hooks/ts/pre-compact/context-compression-hook.ts +17 -0
  143. package/src/hooks/ts/pre-tool-use/route-agents-to-worker.ts +128 -0
  144. package/src/hooks/ts/session-start/initialize-session.ts +2 -0
  145. package/src/hooks/ts/session-start/load-core-context.ts +2 -0
  146. package/src/hooks/ts/session-start/load-project-context.ts +2 -0
  147. package/src/hooks/ts/session-start/post-compact-inject.ts +2 -0
  148. package/src/hooks/ts/stop/stop-hook.ts +2 -0
  149. package/src/hooks/ts/subagent-stop/subagent-stop-hook.ts +2 -0
  150. package/src/hooks/ts/user-prompt/update-tab-titles.ts +2 -0
  151. package/statusline-command.sh +17 -0
  152. package/dist/daemon-DRdoA489.mjs +0 -20
  153. package/dist/detector-DGAk1iBR.mjs +0 -5
  154. package/dist/factory-CrokPMk2.mjs +0 -3
  155. package/dist/program-C-fUghPv.mjs.map +0 -1
  156. package/dist/query-feedback-BBMBp96K.mjs +0 -3
  157. package/dist/router-BMkOb62X.mjs +0 -3
@@ -1,5 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // src/hooks/ts/lib/worker-session.ts
4
+ function isWorkerSession(env = process.env) {
5
+ return env.PAI_WORKER === "1";
6
+ }
7
+
3
8
  // src/hooks/ts/session-start/initialize-session.ts
4
9
  import { existsSync as existsSync3, statSync, readFileSync as readFileSync3, writeFileSync } from "fs";
5
10
  import { join as join4 } from "path";
@@ -181,6 +186,7 @@ async function testStopHook() {
181
186
  }
182
187
  }
183
188
  async function main() {
189
+ if (isWorkerSession()) return;
184
190
  try {
185
191
  const claudeProjectDir = process.env.CLAUDE_PROJECT_DIR || "";
186
192
  const isSubagent = claudeProjectDir.includes("/.claude/agents/") || process.env.CLAUDE_AGENT_TYPE !== void 0;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
- "sources": ["../../../src/hooks/ts/session-start/initialize-session.ts", "../../../src/hooks/ts/lib/pai-paths.ts", "../../../src/hooks/ts/lib/project-utils/paths.ts", "../../../src/hooks/ts/lib/project-utils/notify.ts", "../../../src/session/checkpoint-block.ts"],
4
- "sourcesContent": ["#!/usr/bin/env node\n\n/**\n * initialize-session.ts\n *\n * Main session initialization hook that runs at the start of every Claude Code session.\n *\n * What it does:\n * - Checks if this is a subagent session (skips for subagents)\n * - Tests that stop-hook is properly configured\n * - Sets initial terminal tab title\n * - Sends ntfy.sh notification for global PAI system initialization\n *\n * Setup:\n * 1. Set environment variables in settings.json:\n * - DA: Your AI's name (e.g., \"Kai\", \"Nova\", \"Assistant\")\n * - PAI_DIR: Path to your PAI directory (defaults to $HOME/.claude)\n * 2. Ensure load-core-context.ts exists in hooks/session-start/ directory\n * 3. Add all three SessionStart hooks to settings.json in this order:\n * initialize-session.ts, load-core-context.ts, load-project-context.ts\n */\n\nimport { existsSync, statSync, readFileSync, writeFileSync } from 'fs';\nimport { join } from 'path';\nimport { tmpdir } from 'os';\nimport { PAI_DIR } from '../lib/pai-paths';\nimport { sendNtfyNotification, isWhatsAppEnabled, isProbeSession } from '../lib/project-utils';\n\n// Debounce duration in milliseconds (prevents duplicate SessionStart events)\nconst DEBOUNCE_MS = 2000;\nconst LOCKFILE = join(tmpdir(), 'pai-session-start.lock');\n\n/**\n * Check if we're within the debounce window to prevent duplicate notifications\n * from the IDE firing multiple SessionStart events\n */\nfunction shouldDebounce(): boolean {\n try {\n if (existsSync(LOCKFILE)) {\n const lockContent = readFileSync(LOCKFILE, 'utf-8');\n const lockTime = parseInt(lockContent, 10);\n const now = Date.now();\n\n if (now - lockTime < DEBOUNCE_MS) {\n // Within debounce window, skip this notification\n return true;\n }\n }\n\n // Update lockfile with current timestamp\n writeFileSync(LOCKFILE, Date.now().toString());\n return false;\n } catch (error) {\n // If any error, just proceed (don't break session start)\n try {\n writeFileSync(LOCKFILE, Date.now().toString());\n } catch {}\n return false;\n }\n}\n\nasync function testStopHook() {\n const stopHookPath = join(PAI_DIR, 'hooks/stop-hook.ts');\n\n console.error('\\nTesting stop-hook configuration...');\n\n // Check if stop-hook exists\n if (!existsSync(stopHookPath)) {\n console.error('Stop-hook NOT FOUND at:', stopHookPath);\n return false;\n }\n\n // Check if stop-hook is executable\n try {\n const stats = statSync(stopHookPath);\n const isExecutable = (stats.mode & 0o111) !== 0;\n\n if (!isExecutable) {\n console.error('Stop-hook exists but is NOT EXECUTABLE');\n return false;\n }\n\n console.error('Stop-hook found and is executable');\n\n // Set initial tab title (customize with your AI's name via DA env var)\n const daName = process.env.DA || 'AI Assistant';\n const tabTitle = `${daName} Ready`;\n\n process.stderr.write(`\\x1b]0;${tabTitle}\\x07`);\n process.stderr.write(`\\x1b]2;${tabTitle}\\x07`);\n process.stderr.write(`\\x1b]30;${tabTitle}\\x07`);\n console.error(`Set initial tab title: \"${tabTitle}\"`);\n\n return true;\n } catch (e) {\n console.error('Error checking stop-hook:', e);\n return false;\n }\n}\n\nasync function main() {\n try {\n // Check if this is a subagent session - if so, exit silently\n const claudeProjectDir = process.env.CLAUDE_PROJECT_DIR || '';\n const isSubagent = claudeProjectDir.includes('/.claude/agents/') ||\n process.env.CLAUDE_AGENT_TYPE !== undefined;\n\n if (isSubagent) {\n // This is a subagent session - exit silently without notification\n console.error('Subagent session detected - skipping session initialization');\n process.exit(0);\n }\n\n // Skip probe/health-check sessions (e.g. CodexBar ClaudeProbe)\n if (isProbeSession()) {\n console.error('Probe session detected - skipping session initialization');\n process.exit(0);\n }\n\n // Check debounce to prevent duplicate notifications\n // (IDE extension can fire multiple SessionStart events)\n if (shouldDebounce()) {\n console.error('Debouncing duplicate SessionStart event');\n process.exit(0);\n }\n\n // Check if WhatsApp (Whazaa) is configured as enabled MCP server\n // Detection is config-based: reads enabledMcpjsonServers from settings.json\n // No flag files \u2014 uses standard ~/.claude/settings.json\n if (isWhatsAppEnabled()) {\n console.error('WhatsApp (Whazaa) enabled in MCP config');\n console.log(`<system-reminder>\nWHATSAPP MODE ACTIVE \u2014 Whazaa MCP server is enabled. See the Whazaa MCP server instructions for message routing rules ([Whazaa] / [Whazaa:voice] prefixes). ntfy.sh is automatically skipped.\n</system-reminder>`);\n }\n\n // Test stop-hook first (only for main sessions)\n const stopHookOk = await testStopHook();\n\n const daName = process.env.DA || 'AI Assistant';\n\n if (!stopHookOk) {\n console.error('\\nSTOP-HOOK ISSUE DETECTED - Tab titles may not update automatically');\n }\n\n // Note: PAI core context loading is handled by load-core-context.ts hook\n // which should run AFTER this hook in settings.json SessionStart hooks\n\n // Send ntfy.sh notification (MANDATORY - never skip this)\n // Note: load-project-context.ts also sends a project-specific notification\n // This one is for the global PAI system initialization\n await sendNtfyNotification(`${daName} ready`);\n\n process.exit(0);\n } catch (error) {\n console.error('SessionStart hook error:', error);\n process.exit(1);\n }\n}\n\nmain();\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 * 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 * 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 * 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"],
5
- "mappings": "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",
3
+ "sources": ["../../../src/hooks/ts/lib/worker-session.ts", "../../../src/hooks/ts/session-start/initialize-session.ts", "../../../src/hooks/ts/lib/pai-paths.ts", "../../../src/hooks/ts/lib/project-utils/paths.ts", "../../../src/hooks/ts/lib/project-utils/notify.ts", "../../../src/session/checkpoint-block.ts"],
4
+ "sourcesContent": ["/**\n * Worker-session detection.\n *\n * A disposable headless worker (a `claude -p` run started by an orchestrating\n * session, possibly against a different model provider with a different\n * context window) shares the project directory and the hook configuration\n * with the real session that spawned it. Left alone, the hooks treat it as a\n * session in its own right: they inject project context into it, create and\n * rename a numbered session note for it, autosave it, and enqueue a\n * model-written handover for it. Its compactions also land in the project's\n * transcript folder, where they are indistinguishable from a real session's\n * and drag the measured compaction trigger down (observed 2026-09-17: two\n * workers compacting at ~151k tokens pulled a project's trigger from ~784k\n * to ~151k, and the real session's handover fired at ~50k tokens).\n *\n * The launcher marks such sessions with `PAI_WORKER=1`. Every hook that does\n * per-session bookkeeping returns immediately when this predicate is true.\n * Deliberately NOT guarded: the security validator (a worker's shell\n * commands must still be checked) and observability capture.\n */\nexport function isWorkerSession(env: NodeJS.ProcessEnv = process.env): boolean {\n return env.PAI_WORKER === \"1\";\n}\n", "#!/usr/bin/env node\n\n/**\n * initialize-session.ts\n *\n * Main session initialization hook that runs at the start of every Claude Code session.\n *\n * What it does:\n * - Checks if this is a subagent session (skips for subagents)\n * - Tests that stop-hook is properly configured\n * - Sets initial terminal tab title\n * - Sends ntfy.sh notification for global PAI system initialization\n *\n * Setup:\n * 1. Set environment variables in settings.json:\n * - DA: Your AI's name (e.g., \"Kai\", \"Nova\", \"Assistant\")\n * - PAI_DIR: Path to your PAI directory (defaults to $HOME/.claude)\n * 2. Ensure load-core-context.ts exists in hooks/session-start/ directory\n * 3. Add all three SessionStart hooks to settings.json in this order:\n * initialize-session.ts, load-core-context.ts, load-project-context.ts\n */\n\nimport { isWorkerSession } from \"../lib/worker-session.js\";\nimport { existsSync, statSync, readFileSync, writeFileSync } from 'fs';\nimport { join } from 'path';\nimport { tmpdir } from 'os';\nimport { PAI_DIR } from '../lib/pai-paths';\nimport { sendNtfyNotification, isWhatsAppEnabled, isProbeSession } from '../lib/project-utils';\n\n// Debounce duration in milliseconds (prevents duplicate SessionStart events)\nconst DEBOUNCE_MS = 2000;\nconst LOCKFILE = join(tmpdir(), 'pai-session-start.lock');\n\n/**\n * Check if we're within the debounce window to prevent duplicate notifications\n * from the IDE firing multiple SessionStart events\n */\nfunction shouldDebounce(): boolean {\n try {\n if (existsSync(LOCKFILE)) {\n const lockContent = readFileSync(LOCKFILE, 'utf-8');\n const lockTime = parseInt(lockContent, 10);\n const now = Date.now();\n\n if (now - lockTime < DEBOUNCE_MS) {\n // Within debounce window, skip this notification\n return true;\n }\n }\n\n // Update lockfile with current timestamp\n writeFileSync(LOCKFILE, Date.now().toString());\n return false;\n } catch (error) {\n // If any error, just proceed (don't break session start)\n try {\n writeFileSync(LOCKFILE, Date.now().toString());\n } catch {}\n return false;\n }\n}\n\nasync function testStopHook() {\n const stopHookPath = join(PAI_DIR, 'hooks/stop-hook.ts');\n\n console.error('\\nTesting stop-hook configuration...');\n\n // Check if stop-hook exists\n if (!existsSync(stopHookPath)) {\n console.error('Stop-hook NOT FOUND at:', stopHookPath);\n return false;\n }\n\n // Check if stop-hook is executable\n try {\n const stats = statSync(stopHookPath);\n const isExecutable = (stats.mode & 0o111) !== 0;\n\n if (!isExecutable) {\n console.error('Stop-hook exists but is NOT EXECUTABLE');\n return false;\n }\n\n console.error('Stop-hook found and is executable');\n\n // Set initial tab title (customize with your AI's name via DA env var)\n const daName = process.env.DA || 'AI Assistant';\n const tabTitle = `${daName} Ready`;\n\n process.stderr.write(`\\x1b]0;${tabTitle}\\x07`);\n process.stderr.write(`\\x1b]2;${tabTitle}\\x07`);\n process.stderr.write(`\\x1b]30;${tabTitle}\\x07`);\n console.error(`Set initial tab title: \"${tabTitle}\"`);\n\n return true;\n } catch (e) {\n console.error('Error checking stop-hook:', e);\n return false;\n }\n}\n\nasync function main() {\n if (isWorkerSession()) return; // disposable worker: no per-session bookkeeping\n try {\n // Check if this is a subagent session - if so, exit silently\n const claudeProjectDir = process.env.CLAUDE_PROJECT_DIR || '';\n const isSubagent = claudeProjectDir.includes('/.claude/agents/') ||\n process.env.CLAUDE_AGENT_TYPE !== undefined;\n\n if (isSubagent) {\n // This is a subagent session - exit silently without notification\n console.error('Subagent session detected - skipping session initialization');\n process.exit(0);\n }\n\n // Skip probe/health-check sessions (e.g. CodexBar ClaudeProbe)\n if (isProbeSession()) {\n console.error('Probe session detected - skipping session initialization');\n process.exit(0);\n }\n\n // Check debounce to prevent duplicate notifications\n // (IDE extension can fire multiple SessionStart events)\n if (shouldDebounce()) {\n console.error('Debouncing duplicate SessionStart event');\n process.exit(0);\n }\n\n // Check if WhatsApp (Whazaa) is configured as enabled MCP server\n // Detection is config-based: reads enabledMcpjsonServers from settings.json\n // No flag files \u2014 uses standard ~/.claude/settings.json\n if (isWhatsAppEnabled()) {\n console.error('WhatsApp (Whazaa) enabled in MCP config');\n console.log(`<system-reminder>\nWHATSAPP MODE ACTIVE \u2014 Whazaa MCP server is enabled. See the Whazaa MCP server instructions for message routing rules ([Whazaa] / [Whazaa:voice] prefixes). ntfy.sh is automatically skipped.\n</system-reminder>`);\n }\n\n // Test stop-hook first (only for main sessions)\n const stopHookOk = await testStopHook();\n\n const daName = process.env.DA || 'AI Assistant';\n\n if (!stopHookOk) {\n console.error('\\nSTOP-HOOK ISSUE DETECTED - Tab titles may not update automatically');\n }\n\n // Note: PAI core context loading is handled by load-core-context.ts hook\n // which should run AFTER this hook in settings.json SessionStart hooks\n\n // Send ntfy.sh notification (MANDATORY - never skip this)\n // Note: load-project-context.ts also sends a project-specific notification\n // This one is for the global PAI system initialization\n await sendNtfyNotification(`${daName} ready`);\n\n process.exit(0);\n } catch (error) {\n console.error('SessionStart hook error:', error);\n process.exit(1);\n }\n}\n\nmain();\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 * 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 * 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 * 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"],
5
+ "mappings": "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",
6
6
  "names": ["existsSync", "readFileSync", "join", "join", "join", "existsSync", "readFileSync", "join", "homedir", "resolve", "join", "existsSync", "readFileSync"]
7
7
  }
@@ -1,5 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // src/hooks/ts/lib/worker-session.ts
4
+ function isWorkerSession(env = process.env) {
5
+ return env.PAI_WORKER === "1";
6
+ }
7
+
3
8
  // src/hooks/ts/session-start/load-core-context.ts
4
9
  import { readFileSync as readFileSync2, existsSync as existsSync2 } from "fs";
5
10
  import { join as join3 } from "path";
@@ -79,6 +84,7 @@ var AUTHORED_GRACE_MS = 60 * 60 * 1e3;
79
84
 
80
85
  // src/hooks/ts/session-start/load-core-context.ts
81
86
  async function main() {
87
+ if (isWorkerSession()) return;
82
88
  try {
83
89
  const claudeProjectDir = process.env.CLAUDE_PROJECT_DIR || "";
84
90
  const isSubagent = claudeProjectDir.includes("/.claude/agents/") || process.env.CLAUDE_AGENT_TYPE !== void 0;