| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438 |
- #!/usr/bin/env python3
- """
- Session Catchup Script for planning-with-files
- Analyzes the previous session to find unsynced context after the last
- planning file update. Designed to run on SessionStart.
- Usage: python3 session-catchup.py [project-path]
- """
- import json
- import sys
- import os
- from pathlib import Path
- from typing import Any, Dict, Iterable, List, Optional, Tuple
- try:
- import orjson
- except ImportError:
- orjson = None
- PLANNING_FILES = ['task_plan.md', 'progress.md', 'findings.md']
- MIN_SESSION_BYTES = 5000
- def json_loads(line: str) -> Optional[Dict[str, Any]]:
- """Prefer optional orjson while keeping the hook dependency-free."""
- try:
- if orjson is not None:
- data = orjson.loads(line)
- else:
- data = json.loads(line)
- except (ValueError, TypeError, UnicodeDecodeError):
- return None
- return data if isinstance(data, dict) else None
- def normalize_for_compare(path_value: str) -> str:
- expanded = os.path.expanduser(path_value)
- try:
- return str(Path(expanded).resolve())
- except (OSError, ValueError):
- return os.path.abspath(expanded)
- def normalize_path(project_path: str) -> str:
- """Normalize project path to match Claude Code's internal representation.
- Claude Code stores session directories using the Windows-native path
- (e.g., C:\\Users\\...) sanitized with separators replaced by dashes.
- Git Bash passes /c/Users/... which produces a DIFFERENT sanitized
- string. This function converts Git Bash paths to Windows paths first.
- """
- p = project_path
- # Git Bash / MSYS2: /c/Users/... -> C:/Users/...
- if len(p) >= 3 and p[0] == '/' and p[2] == '/':
- p = p[1].upper() + ':' + p[2:]
- # Resolve to absolute path to handle relative paths and symlinks
- try:
- resolved = str(Path(p).resolve())
- # On Windows, resolve() returns C:\Users\... which is what we want
- if os.name == 'nt' or '\\' in resolved:
- p = resolved
- except (OSError, ValueError):
- pass
- return p
- def get_claude_project_dir(project_path: str) -> Path:
- """Resolve Claude Code's project-specific session storage path."""
- normalized = normalize_path(project_path)
- # Claude Code's sanitization: replace path separators and : with -
- sanitized = normalized.replace('\\', '-').replace('/', '-').replace(':', '-')
- sanitized = sanitized.replace('_', '-')
- # Strip leading dash if present (Unix absolute paths start with /)
- if sanitized.startswith('-'):
- sanitized = sanitized[1:]
- return Path.home() / '.claude' / 'projects' / sanitized
- def get_sessions_sorted(project_dir: Path) -> List[Path]:
- """Get all session files sorted by modification time (newest first)."""
- sessions = list(project_dir.glob('*.jsonl'))
- main_sessions = [s for s in sessions if not s.name.startswith('agent-')]
- return sorted(main_sessions, key=safe_stat_mtime, reverse=True)
- def safe_stat_mtime(path: Path) -> float:
- try:
- return path.stat().st_mtime
- except OSError:
- return 0.0
- def is_substantial_session(session: Path) -> bool:
- try:
- return session.stat().st_size > MIN_SESSION_BYTES
- except OSError:
- return False
- def read_codex_meta(session_file: Path) -> Optional[Dict[str, Any]]:
- """Read the first session_meta; later meta records may be copied parent context."""
- try:
- with open(session_file, 'r', encoding='utf-8', errors='replace') as f:
- for line in f:
- data = json_loads(line)
- if not data or data.get('type') != 'session_meta':
- continue
- payload = data.get('payload')
- return payload if isinstance(payload, dict) else None
- except OSError:
- return None
- return None
- def codex_meta_cwd(meta: Dict[str, Any]) -> Optional[str]:
- cwd = meta.get('cwd')
- return cwd if isinstance(cwd, str) else None
- def find_current_codex_session(sessions: List[Path]) -> Optional[Path]:
- thread_id = os.getenv('CODEX_THREAD_ID', '').strip()
- if not thread_id:
- return None
- for session in sessions:
- if thread_id in session.name:
- return session
- return None
- def is_codex_project_session(session: Path, project_cmp: str) -> bool:
- if not is_substantial_session(session):
- return False
- meta = read_codex_meta(session)
- if not meta:
- return False
- source = meta.get('source')
- if isinstance(source, dict) and 'subagent' in source:
- return False
- cwd = codex_meta_cwd(meta)
- return bool(cwd and normalize_for_compare(cwd) == project_cmp)
- def get_codex_sessions(project_path: str) -> Iterable[Path]:
- sessions_dir = Path(os.path.expanduser(os.getenv('CODEX_SESSIONS_DIR', '~/.codex/sessions')))
- if not sessions_dir.exists():
- return
- project_cmp = normalize_for_compare(project_path)
- sessions = sorted(sessions_dir.rglob('rollout-*.jsonl'), key=safe_stat_mtime, reverse=True)
- current = find_current_codex_session(sessions)
- if current and is_codex_project_session(current, project_cmp):
- yield current
- for session in sessions:
- if session == current:
- continue
- if is_codex_project_session(session, project_cmp):
- yield session
- def get_session_candidates(project_path: str) -> Tuple[str, Iterable[Path]]:
- if '/.codex/' in Path(__file__).resolve().as_posix().lower():
- return 'codex', get_codex_sessions(project_path)
- claude_project_dir = get_claude_project_dir(project_path)
- if claude_project_dir.exists():
- return 'claude', get_sessions_sorted(claude_project_dir)
- return 'claude', []
- def parse_session_messages(session_file: Path) -> List[Dict[str, Any]]:
- """Parse all messages from a session file, preserving order."""
- messages = []
- with open(session_file, 'r', encoding='utf-8', errors='replace') as f:
- for line_num, line in enumerate(f):
- data = json_loads(line)
- if data is not None:
- data['_line_num'] = line_num
- messages.append(data)
- return messages
- def planning_file_from_path(path_value: Any) -> Optional[str]:
- if not isinstance(path_value, str):
- return None
- for pf in PLANNING_FILES:
- if path_value.endswith(pf):
- return pf
- return None
- def planning_file_from_paths(paths: Iterable[Any]) -> Optional[str]:
- matches = {pf for path in paths if (pf := planning_file_from_path(path))}
- for pf in PLANNING_FILES:
- if pf in matches:
- return pf
- return None
- def codex_planning_update(payload: Dict[str, Any]) -> Optional[str]:
- """Use Codex's structured apply_patch result instead of parsing tool text."""
- if payload.get('type') != 'patch_apply_end' or payload.get('success') is not True:
- return None
- changes = payload.get('changes')
- return planning_file_from_paths(changes.keys()) if isinstance(changes, dict) else None
- def find_last_planning_update(messages: List[Dict[str, Any]]) -> Tuple[int, Optional[str]]:
- """
- Find the last time a planning file was written/edited.
- Returns (line_number, filename) or (-1, None) if not found.
- """
- last_update_line = -1
- last_update_file = None
- for msg in messages:
- line_num = msg.get('_line_num')
- if not isinstance(line_num, int):
- continue
- msg_type = msg.get('type')
- if msg_type == 'assistant':
- content = msg.get('message', {}).get('content', [])
- if isinstance(content, list):
- for item in content:
- if item.get('type') == 'tool_use':
- tool_name = item.get('name', '')
- tool_input = item.get('input', {})
- if not isinstance(tool_input, dict):
- tool_input = {}
- if tool_name in ('Write', 'Edit'):
- planning_file = planning_file_from_path(tool_input.get('file_path', ''))
- if planning_file:
- last_update_line = line_num
- last_update_file = planning_file
- elif msg_type == 'event_msg':
- payload = msg.get('payload')
- if isinstance(payload, dict):
- planning_file = codex_planning_update(payload)
- if planning_file:
- last_update_line = line_num
- last_update_file = planning_file
- return last_update_line, last_update_file
- def text_content(content: Any) -> str:
- if isinstance(content, str):
- return content
- if not isinstance(content, list):
- return ''
- return '\n'.join(
- item.get('text', '')
- for item in content
- if isinstance(item, dict) and isinstance(item.get('text'), str)
- )
- def parse_codex_tool_args(payload: Dict[str, Any]) -> Tuple[Dict[str, Any], str]:
- raw_args = payload.get('arguments', payload.get('input', ''))
- if isinstance(raw_args, dict):
- return raw_args, json.dumps(raw_args, ensure_ascii=True)
- if not isinstance(raw_args, str):
- return {}, ''
- decoded = json_loads(raw_args)
- return (decoded, raw_args) if isinstance(decoded, dict) else ({}, raw_args)
- def summarize_codex_tool(payload: Dict[str, Any]) -> str:
- tool_name = payload.get('name', 'tool')
- tool_args, raw_args = parse_codex_tool_args(payload)
- if tool_name == 'exec_command':
- command = tool_args.get('cmd', raw_args)
- if isinstance(command, str):
- return f"exec_command: {command[:80]}"
- return str(tool_name)
- def extract_messages_after(messages: List[Dict[str, Any]], after_line: int) -> List[Dict[str, Any]]:
- """Extract conversation messages after a certain line number."""
- result = []
- for msg in messages:
- line_num = msg.get('_line_num')
- if not isinstance(line_num, int) or line_num <= after_line:
- continue
- msg_type = msg.get('type')
- is_meta = msg.get('isMeta', False)
- if msg_type == 'user' and not is_meta:
- content = text_content(msg.get('message', {}).get('content', ''))
- if content:
- if content.startswith(('<local-command', '<command-', '<task-notification')):
- continue
- if len(content) > 20:
- result.append({'role': 'user', 'content': content, 'line': line_num})
- elif msg_type == 'assistant':
- msg_content = msg.get('message', {}).get('content', '')
- text = text_content(msg_content)
- tool_uses = []
- if isinstance(msg_content, list):
- for item in msg_content:
- if isinstance(item, dict) and item.get('type') == 'tool_use':
- tool_name = item.get('name', '')
- tool_input = item.get('input', {})
- if not isinstance(tool_input, dict):
- tool_input = {}
- if tool_name == 'Edit':
- tool_uses.append(f"Edit: {tool_input.get('file_path', 'unknown')}")
- elif tool_name == 'Write':
- tool_uses.append(f"Write: {tool_input.get('file_path', 'unknown')}")
- elif tool_name == 'Bash':
- cmd = tool_input.get('command', '')[:80]
- tool_uses.append(f"Bash: {cmd}")
- else:
- tool_uses.append(f"{tool_name}")
- if text or tool_uses:
- result.append({
- 'role': 'assistant',
- 'content': text[:600] if text else '',
- 'tools': tool_uses,
- 'line': line_num
- })
- elif msg_type == 'response_item':
- payload = msg.get('payload')
- if not isinstance(payload, dict):
- continue
- payload_type = payload.get('type')
- if payload_type == 'message':
- role = payload.get('role')
- if role not in ('user', 'assistant'):
- continue
- content = text_content(payload.get('content'))
- if role == 'user':
- if content.startswith(('<local-command', '<command-', '<task-notification')):
- continue
- if len(content) > 20:
- result.append({'role': 'user', 'content': content, 'line': line_num})
- elif content:
- result.append({
- 'role': 'assistant',
- 'content': content[:600],
- 'tools': [],
- 'line': line_num
- })
- elif payload_type in ('function_call', 'custom_tool_call'):
- result.append({
- 'role': 'assistant',
- 'content': '',
- 'tools': [summarize_codex_tool(payload)],
- 'line': line_num
- })
- return result
- def main():
- project_path = sys.argv[1] if len(sys.argv) > 1 else os.getcwd()
- # Check if planning files exist (indicates active task)
- has_planning_files = any(
- Path(project_path, f).exists() for f in PLANNING_FILES
- )
- if not has_planning_files:
- # No planning files in this project; skip catchup to avoid noise.
- return
- runtime_name, sessions = get_session_candidates(project_path)
- # Find a substantial previous session
- target_session = None
- for session in sessions:
- if runtime_name == 'claude' and not is_substantial_session(session):
- continue
- target_session = session
- break
- if not target_session:
- return
- messages = parse_session_messages(target_session)
- last_update_line, last_update_file = find_last_planning_update(messages)
- # No planning updates in the target session; skip catchup output.
- if last_update_line < 0:
- return
- # Only output if there's unsynced content
- messages_after = extract_messages_after(messages, last_update_line)
- if not messages_after:
- return
- # Output catchup report
- print("\n[planning-with-files] SESSION CATCHUP DETECTED")
- print(f"Previous session: {target_session.stem}")
- print(f"Runtime: {runtime_name}")
- print(f"Last planning update: {last_update_file} at message #{last_update_line}")
- print(f"Unsynced messages: {len(messages_after)}")
- print("\n--- UNSYNCED CONTEXT ---")
- assistant_label = 'CODEX' if runtime_name == 'codex' else 'CLAUDE'
- for msg in messages_after[-15:]: # Last 15 messages
- if msg['role'] == 'user':
- print(f"USER: {msg['content'][:300]}")
- else:
- if msg.get('content'):
- print(f"{assistant_label}: {msg['content'][:300]}")
- if msg.get('tools'):
- print(f" Tools: {', '.join(msg['tools'][:4])}")
- print("\n--- RECOMMENDED ---")
- print("1. Run: git diff --stat")
- print("2. Read: task_plan.md, progress.md, findings.md")
- print("3. Update planning files based on above context")
- print("4. Continue with task")
- if __name__ == '__main__':
- main()
|