Files
othmanadi__planning-with-files/scripts/session-catchup.py
T
OthmanAdi b49ea488fb feat: make the Stop hook and session recovery fire everywhere, add long-run injection controls
The Stop scalar never dispatched on macOS or Linux (the PowerShell branch
was always selected because check-complete.ps1 ships on every platform)
and was a silent no-op anywhere CLAUDE_SKILL_DIR was unset (the :- install
path fallback can never substitute because the probed variable is always a
non-empty string). Both the legacy completion advisory and the v3
completion gate were dead in those environments. The scalar now selects
targets by file existence and dispatches by platform, PowerShell only
under MINGW/MSYS/CYGWIN, sh elsewhere. Windows output is unchanged.
Patched in all 14 SKILL.md variants that carry the scalar.

session-catchup probed a ~/.claude/projects name that Claude Code never
writes for POSIX paths (leading dash stripped) or paths containing an
underscore (replaced with a dash), so recovery after /clear silently
found nothing there. The mapper now probes the exact spelling first,
keeps both legacy spellings for stores created by older versions, and
settles ambiguity via the cwd recorded in the newest session file.
Propagated to every shipped copy including the older-generation root,
.hermes, and .mastracode scripts.

Also: the attestation SHA cache is keyed on the absolute plan path so two
projects can no longer share a slot and report a false PLAN TAMPERED;
resolve-plan-dir.ps1 reaches parity with the sh resolver (slug filter,
task_plan.md requirement in the newest-dir scan, fail-closed containment);
ledger-append.sh no longer truncates summaries mid-codepoint (UTF-8-safe
trim via iconv with a pure-sh fallback).

New long-run features, all opt-in or additive: structure-aware injection
(PWF_INJECT=smart or an inject-smart .mode token) keeps the active phase,
phase counts, and the last three decisions in the window late in long
plans; a Next Step section in both plan templates plus a sixth reboot
question; session-catchup annotates tool results (ok or FAILED with the
first error line) instead of only listing attempts.

Infrastructure: macos-latest joins the CI matrix, a BSD-userland
simulation harness runs the script fleet without realpath, readlink,
flock, or sha256sum on the Linux leg, and .gitattributes pins LF for
scripts. SEO surfaces: llms.txt rewritten as a Q&A page, three
problem-query docs pages, plugin.json and CITATION.cff keyword hygiene.

Suite grows from 217 to 301 passed, 11 skipped, on Windows and both
existing CI legs; default hook output stays byte-identical to v2.43
(legacy invariant proven by the existing invariant tests).
2026-07-21 21:17:19 +02:00

567 lines
20 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Session Catchup Script for planning-with-files
Session-agnostic scanning: finds the most recent planning file update across
ALL sessions, then collects all conversation from that point forward through
all subsequent sessions until now.
Supports multiple AI IDEs:
- Claude Code (.claude/projects/)
- OpenCode (.local/share/opencode/storage/)
Usage: python3 session-catchup.py [project-path]
"""
import json
import sys
import os
from pathlib import Path
from typing import List, Dict, Optional, Tuple
PLANNING_FILES = ['task_plan.md', 'progress.md', 'findings.md']
def detect_ide() -> str:
"""
Detect which IDE is being used based on environment and file structure.
Returns 'claude-code', 'opencode', or 'unknown'.
"""
# Check for OpenCode environment
if os.environ.get('OPENCODE_DATA_DIR'):
return 'opencode'
# Check for Claude Code directory
claude_dir = Path.home() / '.claude'
if claude_dir.exists():
return 'claude-code'
# Check for OpenCode directory
opencode_dir = Path.home() / '.local' / 'share' / 'opencode'
if opencode_dir.exists():
return 'opencode'
return 'unknown'
def get_project_dir_claude(project_path: str) -> Path:
"""Convert project path to Claude's storage path format.
Windows paths (C:\\Users\\... or Git Bash /c/Users/...) need the drive
colon and backslashes sanitized too, not just forward slashes, and
Claude's actual sanitized form has no leading dash for those (the drive
letter isn't a separator). Real Unix absolute paths keep their existing
leading-dash behavior unchanged (path is used as-is, matching Claude's
convention there).
"""
p = project_path
if len(p) >= 3 and p[0] == '/' and p[2] == '/' and p[1].isalpha():
# Git Bash / MSYS2: /c/Users/... -> C:/Users/...
p = p[1].upper() + ':' + p[2:]
if ':' in p or '\\' in p:
try:
p = str(Path(p).resolve())
except (OSError, ValueError):
pass
sanitized = p.replace('\\', '-').replace('/', '-').replace(':', '-')
else:
sanitized = p.replace('/', '-')
if not sanitized.startswith('-'):
sanitized = '-' + sanitized
# Claude Code keeps underscores in project-dir names; probe the exact
# spelling first and fall back to the legacy '-' spelling for stores
# created by older versions of this script (v3.8.0 fix).
projects_root = Path.home() / '.claude' / 'projects'
if not (projects_root / sanitized).is_dir():
legacy = sanitized.replace('_', '-')
if (projects_root / legacy).is_dir():
return projects_root / legacy
return projects_root / sanitized
def get_project_dir_opencode(project_path: str) -> Optional[Path]:
"""
Get OpenCode session storage directory.
OpenCode uses: ~/.local/share/opencode/storage/session/{projectHash}/
Note: OpenCode's structure is different - this function returns the storage root.
Session discovery happens differently in OpenCode.
"""
data_dir = os.environ.get('OPENCODE_DATA_DIR',
str(Path.home() / '.local' / 'share' / 'opencode'))
storage_dir = Path(data_dir) / 'storage'
if not storage_dir.exists():
return None
return storage_dir
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=lambda p: p.stat().st_mtime, reverse=True)
def get_sessions_sorted_opencode(storage_dir: Path) -> List[Path]:
"""
Get all OpenCode session files sorted by modification time.
OpenCode stores sessions at: storage/session/{projectHash}/{sessionID}.json
"""
session_dir = storage_dir / 'session'
if not session_dir.exists():
return []
sessions = []
for project_hash_dir in session_dir.iterdir():
if project_hash_dir.is_dir():
for session_file in project_hash_dir.glob('*.json'):
sessions.append(session_file)
return sorted(sessions, key=lambda p: p.stat().st_mtime, reverse=True)
def get_session_first_timestamp(session_file: Path) -> Optional[str]:
"""Get the timestamp of the first message in a session."""
try:
with open(session_file, 'r', encoding='utf-8', errors='replace') as f:
for line in f:
try:
data = json.loads(line)
ts = data.get('timestamp')
if ts:
return ts
except:
continue
except:
pass
return None
def scan_for_planning_update(session_file: Path) -> Tuple[int, Optional[str]]:
"""
Quickly scan a session file for planning file updates.
Returns (line_number, filename) of last update, or (-1, None) if none found.
"""
last_update_line = -1
last_update_file = None
try:
with open(session_file, 'r', encoding='utf-8', errors='replace') as f:
for line_num, line in enumerate(f):
if '"Write"' not in line and '"Edit"' not in line:
continue
try:
data = json.loads(line)
if data.get('type') != 'assistant':
continue
content = data.get('message', {}).get('content', [])
if not isinstance(content, list):
continue
for item in content:
if item.get('type') != 'tool_use':
continue
tool_name = item.get('name', '')
if tool_name not in ('Write', 'Edit'):
continue
file_path = item.get('input', {}).get('file_path', '')
for pf in PLANNING_FILES:
if file_path.endswith(pf):
last_update_line = line_num
last_update_file = pf
break
except json.JSONDecodeError:
continue
except Exception:
pass
return last_update_line, last_update_file
def extract_messages_from_session(session_file: Path, after_line: int = -1) -> List[Dict]:
"""
Extract conversation messages from a session file.
If after_line >= 0, only extract messages after that line.
If after_line < 0, extract all messages.
"""
result = []
try:
with open(session_file, 'r', encoding='utf-8', errors='replace') as f:
for line_num, line in enumerate(f):
if after_line >= 0 and line_num <= after_line:
continue
try:
msg = json.loads(line)
except json.JSONDecodeError:
continue
msg_type = msg.get('type')
is_meta = msg.get('isMeta', False)
if msg_type == 'user' and not is_meta:
content = msg.get('message', {}).get('content', '')
if isinstance(content, list):
for item in content:
if isinstance(item, dict) and item.get('type') == 'text':
content = item.get('text', '')
break
else:
content = ''
if content and isinstance(content, str):
# Skip system/command messages
if content.startswith(('<local-command', '<command-', '<task-notification')):
continue
if len(content) > 20:
result.append({
'role': 'user',
'content': content,
'line': line_num,
'session': session_file.stem[:8]
})
elif msg_type == 'assistant':
msg_content = msg.get('message', {}).get('content', '')
text_content = ''
tool_uses = []
if isinstance(msg_content, str):
text_content = msg_content
elif isinstance(msg_content, list):
for item in msg_content:
if item.get('type') == 'text':
text_content = item.get('text', '')
elif item.get('type') == 'tool_use':
tool_name = item.get('name', '')
tool_input = item.get('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}")
elif tool_name == 'AskUserQuestion':
tool_uses.append("AskUserQuestion")
else:
tool_uses.append(f"{tool_name}")
if text_content or tool_uses:
result.append({
'role': 'assistant',
'content': text_content[:600] if text_content else '',
'tools': tool_uses,
'line': line_num,
'session': session_file.stem[:8]
})
except Exception:
pass
return result
PLANNING_LIKE = ('%task_plan.md', '%findings.md', '%progress.md')
def get_opencode_db_path() -> Optional[Path]:
"""Resolve OpenCode SQLite path.
xdg-basedir resolution is the same on every OS (Linux, macOS, Windows):
${XDG_DATA_HOME ?? ~/.local/share}/opencode/opencode.db. The legacy
OPENCODE_DATA_DIR env var is honored as a fallback for users who set
it under the pre-SQLite scheme.
"""
xdg = os.environ.get('XDG_DATA_HOME')
if xdg:
base = Path(xdg) / 'opencode'
elif os.environ.get('OPENCODE_DATA_DIR'):
base = Path(os.environ['OPENCODE_DATA_DIR'])
else:
base = Path.home() / '.local' / 'share' / 'opencode'
db = base / 'opencode.db'
return db if db.exists() else None
def _format_opencode_part(data: Dict, session_id: str) -> Optional[Dict]:
"""Convert one OpenCode part row's JSON `data` blob into a print-ready summary."""
ptype = data.get('type')
short = session_id[:8] if session_id else '????????'
if ptype == 'tool':
tool = (data.get('tool') or '').lower()
state = data.get('state') or {}
input_ = state.get('input') or {}
if tool in ('write', 'edit'):
fp = input_.get('filePath', '')
return {'session': short, 'summary': f"Tool {tool}: {fp}"}
if tool == 'patch':
return {'session': short, 'summary': f"Tool patch: {input_.get('filePath', '')}"}
if tool == 'bash':
cmd = (input_.get('command') or '')[:80]
return {'session': short, 'summary': f"Tool bash: {cmd}"}
return {'session': short, 'summary': f"Tool {tool}"}
if ptype == 'text':
text = (data.get('text') or '')[:300]
if text.strip():
return {'session': short, 'summary': f"text: {text}"}
return None
def opencode_catchup(project_path: str) -> None:
"""Session catchup for OpenCode (SQLite at ~/.local/share/opencode/opencode.db).
Schema reference (sst/opencode dev @ 2026-05-14):
session (id, directory, time_created, ...)
part (id, session_id, message_id, time_created, data TEXT JSON)
Tool calls are stored as part rows where data.type='tool',
data.tool='write'|'edit'|'patch', data.state.input.filePath=<abs path>.
"""
import sqlite3
db_path = get_opencode_db_path()
if not db_path:
return
try:
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
except sqlite3.OperationalError as exc:
print(f"\n[planning-with-files] Could not open OpenCode DB read-only: {exc}")
return
cur = conn.cursor()
try:
cur.execute("PRAGMA table_info(session)")
session_cols = {row[1] for row in cur.fetchall()}
cur.execute("PRAGMA table_info(part)")
part_cols = {row[1] for row in cur.fetchall()}
except sqlite3.OperationalError:
conn.close()
return
if 'directory' not in session_cols or 'data' not in part_cols:
conn.close()
return
project_abs = str(Path(project_path).resolve())
cur.execute(
"SELECT id, time_created FROM session WHERE directory = ? ORDER BY time_created DESC",
(project_abs,),
)
sessions = cur.fetchall()
if len(sessions) < 2:
conn.close()
return
previous_sessions = sessions[1:]
update_sid = None
update_time = None
update_idx = -1
for idx, (sid, _) in enumerate(previous_sessions):
params = (sid,) + PLANNING_LIKE
cur.execute(
"""
SELECT time_created FROM part
WHERE session_id = ?
AND json_extract(data, '$.type') = 'tool'
AND lower(json_extract(data, '$.tool')) IN ('write', 'edit', 'patch')
AND (
json_extract(data, '$.state.input.filePath') LIKE ?
OR json_extract(data, '$.state.input.filePath') LIKE ?
OR json_extract(data, '$.state.input.filePath') LIKE ?
)
ORDER BY time_created DESC
LIMIT 1
""",
params,
)
row = cur.fetchone()
if row:
update_sid = sid
update_time = row[0]
update_idx = idx
break
if not update_sid:
conn.close()
return
newer_sessions = list(reversed(previous_sessions[:update_idx]))
all_messages: List[Dict] = []
cur.execute(
"SELECT data FROM part WHERE session_id = ? AND time_created > ? ORDER BY time_created ASC, id ASC",
(update_sid, update_time),
)
for (data_str,) in cur.fetchall():
try:
data = json.loads(data_str)
except json.JSONDecodeError:
continue
msg = _format_opencode_part(data, update_sid)
if msg:
all_messages.append(msg)
for sid, _ in newer_sessions:
cur.execute(
"SELECT data FROM part WHERE session_id = ? ORDER BY time_created ASC, id ASC",
(sid,),
)
for (data_str,) in cur.fetchall():
try:
data = json.loads(data_str)
except json.JSONDecodeError:
continue
msg = _format_opencode_part(data, sid)
if msg:
all_messages.append(msg)
conn.close()
if not all_messages:
return
print(f"\n[planning-with-files] SESSION CATCHUP DETECTED (IDE: opencode)")
print(f"Last planning update in session {update_sid[:8]}...")
if update_idx + 1 > 1:
print(f"Scanning {update_idx + 1} previous sessions for unsynced context")
print(f"Unsynced parts: {len(all_messages)}")
print("\n--- UNSYNCED CONTEXT ---")
MAX_PARTS = 100
if len(all_messages) > MAX_PARTS:
print(f"(Showing last {MAX_PARTS} of {len(all_messages)} parts)\n")
to_show = all_messages[-MAX_PARTS:]
else:
to_show = all_messages
current_session = None
for msg in to_show:
if msg.get('session') != current_session:
current_session = msg.get('session')
print(f"\n[Session: {current_session}...]")
print(f" {msg['summary']}")
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")
def main():
project_path = sys.argv[1] if len(sys.argv) > 1 else os.getcwd()
ide = detect_ide()
if ide == 'opencode':
opencode_catchup(project_path)
return
# Claude Code path
project_dir = get_project_dir_claude(project_path)
if not project_dir.exists():
return
sessions = get_sessions_sorted(project_dir)
if len(sessions) < 2:
return
# Skip the current session (most recently modified = index 0)
previous_sessions = sessions[1:]
# Find the most recent planning file update across ALL previous sessions
# Sessions are sorted newest first, so we scan in order
update_session = None
update_line = -1
update_file = None
update_session_idx = -1
for idx, session in enumerate(previous_sessions):
line, filename = scan_for_planning_update(session)
if line >= 0:
update_session = session
update_line = line
update_file = filename
update_session_idx = idx
break
if not update_session:
# No planning file updates found in any previous session
return
# Collect ALL messages from the update point forward, across all sessions
all_messages = []
# 1. Get messages from the session with the update (after the update line)
messages_from_update_session = extract_messages_from_session(update_session, after_line=update_line)
all_messages.extend(messages_from_update_session)
# 2. Get ALL messages from sessions between update_session and current
# These are sessions[1:update_session_idx] (newer than update_session)
intermediate_sessions = previous_sessions[:update_session_idx]
# Process from oldest to newest for correct chronological order
for session in reversed(intermediate_sessions):
messages = extract_messages_from_session(session, after_line=-1) # Get all messages
all_messages.extend(messages)
if not all_messages:
return
# Output catchup report
print(f"\n[planning-with-files] SESSION CATCHUP DETECTED (IDE: {ide})")
print(f"Last planning update: {update_file} in session {update_session.stem[:8]}...")
sessions_covered = update_session_idx + 1
if sessions_covered > 1:
print(f"Scanning {sessions_covered} sessions for unsynced context")
print(f"Unsynced messages: {len(all_messages)}")
print("\n--- UNSYNCED CONTEXT ---")
# Show up to 100 messages
MAX_MESSAGES = 100
if len(all_messages) > MAX_MESSAGES:
print(f"(Showing last {MAX_MESSAGES} of {len(all_messages)} messages)\n")
messages_to_show = all_messages[-MAX_MESSAGES:]
else:
messages_to_show = all_messages
current_session = None
for msg in messages_to_show:
# Show session marker when it changes
if msg.get('session') != current_session:
current_session = msg.get('session')
print(f"\n[Session: {current_session}...]")
if msg['role'] == 'user':
print(f"USER: {msg['content'][:300]}")
else:
if msg.get('content'):
print(f"CLAUDE: {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()