Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e60c8e1c0f | ||
|
|
99b041c690 | ||
|
|
5e71c1ee05 | ||
|
|
6d8b30e4f2 | ||
|
|
4ffd78ce7a | ||
|
|
14ea1099b5 | ||
|
|
5df903e05c | ||
|
|
59edf7bb11 | ||
|
|
a4e60f32f6 | ||
|
|
4efa945ea5 | ||
|
|
9acf593982 | ||
|
|
bb3a21ab0d | ||
|
|
02a4ad6034 | ||
|
|
c7f89340d6 |
@@ -9,7 +9,7 @@
|
||||
"name": "planning-with-files",
|
||||
"source": "./",
|
||||
"description": "Manus-style persistent markdown files for planning, progress tracking, and knowledge storage. Now with hooks integration.",
|
||||
"version": "2.23.0"
|
||||
"version": "2.32.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "planning-with-files",
|
||||
"version": "2.23.0",
|
||||
"version": "2.32.0",
|
||||
"description": "Manus-style persistent markdown files for planning, progress tracking, and knowledge storage. Works with Claude Code, Kiro, Clawd CLI, Gemini CLI, Cursor, Continue, and 11+ AI coding assistants.",
|
||||
"author": {
|
||||
"name": "OthmanAdi",
|
||||
|
||||
@@ -21,9 +21,9 @@ hooks:
|
||||
Stop:
|
||||
- hooks:
|
||||
- type: command
|
||||
command: "SD=\"${CODEBUDDY_PLUGIN_ROOT:-$HOME/.codebuddy/skills/planning-with-files}/scripts\"; powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"$SD/check-complete.ps1\" 2>/dev/null || sh \"$SD/check-complete.sh\""
|
||||
command: "export SD=\"${CODEBUDDY_PLUGIN_ROOT:-$HOME/.codebuddy/skills/planning-with-files}/scripts\"; powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"$SD/check-complete.ps1\" 2>/dev/null || sh \"$SD/check-complete.sh\""
|
||||
metadata:
|
||||
version: "2.23.0"
|
||||
version: "2.32.0"
|
||||
---
|
||||
|
||||
# Planning with Files
|
||||
|
||||
Regular → Executable
+249
-67
@@ -12,9 +12,35 @@ import json
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
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:
|
||||
@@ -43,8 +69,8 @@ def normalize_path(project_path: str) -> str:
|
||||
return p
|
||||
|
||||
|
||||
def get_project_dir(project_path: str) -> Tuple[Optional[Path], Optional[str]]:
|
||||
"""Resolve session storage path for the current runtime variant."""
|
||||
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 -
|
||||
@@ -54,44 +80,141 @@ def get_project_dir(project_path: str) -> Tuple[Optional[Path], Optional[str]]:
|
||||
if sanitized.startswith('-'):
|
||||
sanitized = sanitized[1:]
|
||||
|
||||
claude_path = Path.home() / '.claude' / 'projects' / sanitized
|
||||
|
||||
# Codex stores sessions in ~/.codex/sessions with a different format.
|
||||
# Avoid silently scanning Claude paths when running from Codex skill folder.
|
||||
script_path = Path(__file__).as_posix().lower()
|
||||
is_codex_variant = '/.codex/' in script_path
|
||||
codex_sessions_dir = Path.home() / '.codex' / 'sessions'
|
||||
if is_codex_variant and codex_sessions_dir.exists() and not claude_path.exists():
|
||||
return None, (
|
||||
"[planning-with-files] Session catchup skipped: Codex stores sessions "
|
||||
"in ~/.codex/sessions and native Codex parsing is not implemented yet."
|
||||
)
|
||||
|
||||
return claude_path, None
|
||||
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=lambda p: p.stat().st_mtime, reverse=True)
|
||||
return sorted(main_sessions, key=safe_stat_mtime, reverse=True)
|
||||
|
||||
|
||||
def parse_session_messages(session_file: Path) -> List[Dict]:
|
||||
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):
|
||||
try:
|
||||
data = json.loads(line)
|
||||
data = json_loads(line)
|
||||
if data is not None:
|
||||
data['_line_num'] = line_num
|
||||
messages.append(data)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return messages
|
||||
|
||||
|
||||
def find_last_planning_update(messages: List[Dict]) -> Tuple[int, Optional[str]]:
|
||||
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.
|
||||
@@ -100,6 +223,9 @@ def find_last_planning_update(messages: List[Dict]) -> Tuple[int, Optional[str]]
|
||||
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':
|
||||
@@ -109,57 +235,90 @@ def find_last_planning_update(messages: List[Dict]) -> Tuple[int, Optional[str]]
|
||||
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'):
|
||||
file_path = tool_input.get('file_path', '')
|
||||
for pf in PLANNING_FILES:
|
||||
if file_path.endswith(pf):
|
||||
last_update_line = msg['_line_num']
|
||||
last_update_file = pf
|
||||
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 extract_messages_after(messages: List[Dict], after_line: int) -> List[Dict]:
|
||||
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:
|
||||
if msg['_line_num'] <= after_line:
|
||||
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 = 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 = ''
|
||||
content = text_content(msg.get('message', {}).get('content', ''))
|
||||
|
||||
if content and isinstance(content, str):
|
||||
if content:
|
||||
if content.startswith(('<local-command', '<command-', '<task-notification')):
|
||||
continue
|
||||
if len(content) > 20:
|
||||
result.append({'role': 'user', 'content': content, 'line': msg['_line_num']})
|
||||
result.append({'role': 'user', 'content': content, 'line': line_num})
|
||||
|
||||
elif msg_type == 'assistant':
|
||||
msg_content = msg.get('message', {}).get('content', '')
|
||||
text_content = ''
|
||||
text = text_content(msg_content)
|
||||
tool_uses = []
|
||||
|
||||
if isinstance(msg_content, str):
|
||||
text_content = msg_content
|
||||
elif isinstance(msg_content, list):
|
||||
if 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':
|
||||
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':
|
||||
@@ -170,12 +329,43 @@ def extract_messages_after(messages: List[Dict], after_line: int) -> List[Dict]:
|
||||
else:
|
||||
tool_uses.append(f"{tool_name}")
|
||||
|
||||
if text_content or tool_uses:
|
||||
if text or tool_uses:
|
||||
result.append({
|
||||
'role': 'assistant',
|
||||
'content': text_content[:600] if text_content else '',
|
||||
'content': text[:600] if text else '',
|
||||
'tools': tool_uses,
|
||||
'line': msg['_line_num']
|
||||
'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
|
||||
@@ -192,25 +382,15 @@ def main():
|
||||
# No planning files in this project; skip catchup to avoid noise.
|
||||
return
|
||||
|
||||
project_dir, skip_reason = get_project_dir(project_path)
|
||||
if skip_reason:
|
||||
print(skip_reason)
|
||||
return
|
||||
|
||||
if not project_dir.exists():
|
||||
# No previous sessions, nothing to catch up on
|
||||
return
|
||||
|
||||
sessions = get_sessions_sorted(project_dir)
|
||||
if len(sessions) < 1:
|
||||
return
|
||||
runtime_name, sessions = get_session_candidates(project_path)
|
||||
|
||||
# Find a substantial previous session
|
||||
target_session = None
|
||||
for session in sessions:
|
||||
if session.stat().st_size > 5000:
|
||||
target_session = session
|
||||
break
|
||||
if runtime_name == 'claude' and not is_substantial_session(session):
|
||||
continue
|
||||
target_session = session
|
||||
break
|
||||
|
||||
if not target_session:
|
||||
return
|
||||
@@ -231,17 +411,19 @@ def main():
|
||||
# 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"CLAUDE: {msg['content'][:300]}")
|
||||
print(f"{assistant_label}: {msg['content'][:300]}")
|
||||
if msg.get('tools'):
|
||||
print(f" Tools: {', '.join(msg['tools'][:4])}")
|
||||
|
||||
|
||||
@@ -21,9 +21,9 @@ hooks:
|
||||
Stop:
|
||||
- hooks:
|
||||
- type: command
|
||||
command: "SD=\"${CODEX_SKILL_ROOT:-$HOME/.codex/skills/planning-with-files}/scripts\"; powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"$SD/check-complete.ps1\" 2>/dev/null || sh \"$SD/check-complete.sh\""
|
||||
command: "export SD=\"${CODEX_SKILL_ROOT:-$HOME/.codex/skills/planning-with-files}/scripts\"; powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"$SD/check-complete.ps1\" 2>/dev/null || sh \"$SD/check-complete.sh\""
|
||||
metadata:
|
||||
version: "2.23.0"
|
||||
version: "2.32.0"
|
||||
---
|
||||
|
||||
# Planning with Files
|
||||
|
||||
Regular → Executable
+249
-67
@@ -12,9 +12,35 @@ import json
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
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:
|
||||
@@ -43,8 +69,8 @@ def normalize_path(project_path: str) -> str:
|
||||
return p
|
||||
|
||||
|
||||
def get_project_dir(project_path: str) -> Tuple[Optional[Path], Optional[str]]:
|
||||
"""Resolve session storage path for the current runtime variant."""
|
||||
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 -
|
||||
@@ -54,44 +80,141 @@ def get_project_dir(project_path: str) -> Tuple[Optional[Path], Optional[str]]:
|
||||
if sanitized.startswith('-'):
|
||||
sanitized = sanitized[1:]
|
||||
|
||||
claude_path = Path.home() / '.claude' / 'projects' / sanitized
|
||||
|
||||
# Codex stores sessions in ~/.codex/sessions with a different format.
|
||||
# Avoid silently scanning Claude paths when running from Codex skill folder.
|
||||
script_path = Path(__file__).as_posix().lower()
|
||||
is_codex_variant = '/.codex/' in script_path
|
||||
codex_sessions_dir = Path.home() / '.codex' / 'sessions'
|
||||
if is_codex_variant and codex_sessions_dir.exists() and not claude_path.exists():
|
||||
return None, (
|
||||
"[planning-with-files] Session catchup skipped: Codex stores sessions "
|
||||
"in ~/.codex/sessions and native Codex parsing is not implemented yet."
|
||||
)
|
||||
|
||||
return claude_path, None
|
||||
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=lambda p: p.stat().st_mtime, reverse=True)
|
||||
return sorted(main_sessions, key=safe_stat_mtime, reverse=True)
|
||||
|
||||
|
||||
def parse_session_messages(session_file: Path) -> List[Dict]:
|
||||
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):
|
||||
try:
|
||||
data = json.loads(line)
|
||||
data = json_loads(line)
|
||||
if data is not None:
|
||||
data['_line_num'] = line_num
|
||||
messages.append(data)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return messages
|
||||
|
||||
|
||||
def find_last_planning_update(messages: List[Dict]) -> Tuple[int, Optional[str]]:
|
||||
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.
|
||||
@@ -100,6 +223,9 @@ def find_last_planning_update(messages: List[Dict]) -> Tuple[int, Optional[str]]
|
||||
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':
|
||||
@@ -109,57 +235,90 @@ def find_last_planning_update(messages: List[Dict]) -> Tuple[int, Optional[str]]
|
||||
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'):
|
||||
file_path = tool_input.get('file_path', '')
|
||||
for pf in PLANNING_FILES:
|
||||
if file_path.endswith(pf):
|
||||
last_update_line = msg['_line_num']
|
||||
last_update_file = pf
|
||||
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 extract_messages_after(messages: List[Dict], after_line: int) -> List[Dict]:
|
||||
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:
|
||||
if msg['_line_num'] <= after_line:
|
||||
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 = 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 = ''
|
||||
content = text_content(msg.get('message', {}).get('content', ''))
|
||||
|
||||
if content and isinstance(content, str):
|
||||
if content:
|
||||
if content.startswith(('<local-command', '<command-', '<task-notification')):
|
||||
continue
|
||||
if len(content) > 20:
|
||||
result.append({'role': 'user', 'content': content, 'line': msg['_line_num']})
|
||||
result.append({'role': 'user', 'content': content, 'line': line_num})
|
||||
|
||||
elif msg_type == 'assistant':
|
||||
msg_content = msg.get('message', {}).get('content', '')
|
||||
text_content = ''
|
||||
text = text_content(msg_content)
|
||||
tool_uses = []
|
||||
|
||||
if isinstance(msg_content, str):
|
||||
text_content = msg_content
|
||||
elif isinstance(msg_content, list):
|
||||
if 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':
|
||||
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':
|
||||
@@ -170,12 +329,43 @@ def extract_messages_after(messages: List[Dict], after_line: int) -> List[Dict]:
|
||||
else:
|
||||
tool_uses.append(f"{tool_name}")
|
||||
|
||||
if text_content or tool_uses:
|
||||
if text or tool_uses:
|
||||
result.append({
|
||||
'role': 'assistant',
|
||||
'content': text_content[:600] if text_content else '',
|
||||
'content': text[:600] if text else '',
|
||||
'tools': tool_uses,
|
||||
'line': msg['_line_num']
|
||||
'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
|
||||
@@ -192,25 +382,15 @@ def main():
|
||||
# No planning files in this project; skip catchup to avoid noise.
|
||||
return
|
||||
|
||||
project_dir, skip_reason = get_project_dir(project_path)
|
||||
if skip_reason:
|
||||
print(skip_reason)
|
||||
return
|
||||
|
||||
if not project_dir.exists():
|
||||
# No previous sessions, nothing to catch up on
|
||||
return
|
||||
|
||||
sessions = get_sessions_sorted(project_dir)
|
||||
if len(sessions) < 1:
|
||||
return
|
||||
runtime_name, sessions = get_session_candidates(project_path)
|
||||
|
||||
# Find a substantial previous session
|
||||
target_session = None
|
||||
for session in sessions:
|
||||
if session.stat().st_size > 5000:
|
||||
target_session = session
|
||||
break
|
||||
if runtime_name == 'claude' and not is_substantial_session(session):
|
||||
continue
|
||||
target_session = session
|
||||
break
|
||||
|
||||
if not target_session:
|
||||
return
|
||||
@@ -231,17 +411,19 @@ def main():
|
||||
# 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"CLAUDE: {msg['content'][:300]}")
|
||||
print(f"{assistant_label}: {msg['content'][:300]}")
|
||||
if msg.get('tools'):
|
||||
print(f" Tools: {', '.join(msg['tools'][:4])}")
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name: planning-with-files
|
||||
description: Implements Manus-style file-based planning to organize and track progress on complex tasks. Creates task_plan.md, findings.md, and progress.md. Use when asked to plan out, break down, or organize a multi-step project, research task, or any work requiring >5 tool calls. Supports automatic session recovery after /clear.
|
||||
metadata:
|
||||
version: "2.22.0"
|
||||
version: "2.32.0"
|
||||
---
|
||||
|
||||
# Planning with Files
|
||||
|
||||
Regular → Executable
+249
-67
@@ -12,9 +12,35 @@ import json
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
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:
|
||||
@@ -43,8 +69,8 @@ def normalize_path(project_path: str) -> str:
|
||||
return p
|
||||
|
||||
|
||||
def get_project_dir(project_path: str) -> Tuple[Optional[Path], Optional[str]]:
|
||||
"""Resolve session storage path for the current runtime variant."""
|
||||
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 -
|
||||
@@ -54,44 +80,141 @@ def get_project_dir(project_path: str) -> Tuple[Optional[Path], Optional[str]]:
|
||||
if sanitized.startswith('-'):
|
||||
sanitized = sanitized[1:]
|
||||
|
||||
claude_path = Path.home() / '.claude' / 'projects' / sanitized
|
||||
|
||||
# Codex stores sessions in ~/.codex/sessions with a different format.
|
||||
# Avoid silently scanning Claude paths when running from Codex skill folder.
|
||||
script_path = Path(__file__).as_posix().lower()
|
||||
is_codex_variant = '/.codex/' in script_path
|
||||
codex_sessions_dir = Path.home() / '.codex' / 'sessions'
|
||||
if is_codex_variant and codex_sessions_dir.exists() and not claude_path.exists():
|
||||
return None, (
|
||||
"[planning-with-files] Session catchup skipped: Codex stores sessions "
|
||||
"in ~/.codex/sessions and native Codex parsing is not implemented yet."
|
||||
)
|
||||
|
||||
return claude_path, None
|
||||
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=lambda p: p.stat().st_mtime, reverse=True)
|
||||
return sorted(main_sessions, key=safe_stat_mtime, reverse=True)
|
||||
|
||||
|
||||
def parse_session_messages(session_file: Path) -> List[Dict]:
|
||||
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):
|
||||
try:
|
||||
data = json.loads(line)
|
||||
data = json_loads(line)
|
||||
if data is not None:
|
||||
data['_line_num'] = line_num
|
||||
messages.append(data)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return messages
|
||||
|
||||
|
||||
def find_last_planning_update(messages: List[Dict]) -> Tuple[int, Optional[str]]:
|
||||
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.
|
||||
@@ -100,6 +223,9 @@ def find_last_planning_update(messages: List[Dict]) -> Tuple[int, Optional[str]]
|
||||
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':
|
||||
@@ -109,57 +235,90 @@ def find_last_planning_update(messages: List[Dict]) -> Tuple[int, Optional[str]]
|
||||
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'):
|
||||
file_path = tool_input.get('file_path', '')
|
||||
for pf in PLANNING_FILES:
|
||||
if file_path.endswith(pf):
|
||||
last_update_line = msg['_line_num']
|
||||
last_update_file = pf
|
||||
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 extract_messages_after(messages: List[Dict], after_line: int) -> List[Dict]:
|
||||
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:
|
||||
if msg['_line_num'] <= after_line:
|
||||
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 = 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 = ''
|
||||
content = text_content(msg.get('message', {}).get('content', ''))
|
||||
|
||||
if content and isinstance(content, str):
|
||||
if content:
|
||||
if content.startswith(('<local-command', '<command-', '<task-notification')):
|
||||
continue
|
||||
if len(content) > 20:
|
||||
result.append({'role': 'user', 'content': content, 'line': msg['_line_num']})
|
||||
result.append({'role': 'user', 'content': content, 'line': line_num})
|
||||
|
||||
elif msg_type == 'assistant':
|
||||
msg_content = msg.get('message', {}).get('content', '')
|
||||
text_content = ''
|
||||
text = text_content(msg_content)
|
||||
tool_uses = []
|
||||
|
||||
if isinstance(msg_content, str):
|
||||
text_content = msg_content
|
||||
elif isinstance(msg_content, list):
|
||||
if 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':
|
||||
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':
|
||||
@@ -170,12 +329,43 @@ def extract_messages_after(messages: List[Dict], after_line: int) -> List[Dict]:
|
||||
else:
|
||||
tool_uses.append(f"{tool_name}")
|
||||
|
||||
if text_content or tool_uses:
|
||||
if text or tool_uses:
|
||||
result.append({
|
||||
'role': 'assistant',
|
||||
'content': text_content[:600] if text_content else '',
|
||||
'content': text[:600] if text else '',
|
||||
'tools': tool_uses,
|
||||
'line': msg['_line_num']
|
||||
'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
|
||||
@@ -192,25 +382,15 @@ def main():
|
||||
# No planning files in this project; skip catchup to avoid noise.
|
||||
return
|
||||
|
||||
project_dir, skip_reason = get_project_dir(project_path)
|
||||
if skip_reason:
|
||||
print(skip_reason)
|
||||
return
|
||||
|
||||
if not project_dir.exists():
|
||||
# No previous sessions, nothing to catch up on
|
||||
return
|
||||
|
||||
sessions = get_sessions_sorted(project_dir)
|
||||
if len(sessions) < 1:
|
||||
return
|
||||
runtime_name, sessions = get_session_candidates(project_path)
|
||||
|
||||
# Find a substantial previous session
|
||||
target_session = None
|
||||
for session in sessions:
|
||||
if session.stat().st_size > 5000:
|
||||
target_session = session
|
||||
break
|
||||
if runtime_name == 'claude' and not is_substantial_session(session):
|
||||
continue
|
||||
target_session = session
|
||||
break
|
||||
|
||||
if not target_session:
|
||||
return
|
||||
@@ -231,17 +411,19 @@ def main():
|
||||
# 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"CLAUDE: {msg['content'][:300]}")
|
||||
print(f"{assistant_label}: {msg['content'][:300]}")
|
||||
if msg.get('tools'):
|
||||
print(f" Tools: {', '.join(msg['tools'][:4])}")
|
||||
|
||||
|
||||
@@ -21,9 +21,9 @@ hooks:
|
||||
Stop:
|
||||
- hooks:
|
||||
- type: command
|
||||
command: "SD=\"${CURSOR_SKILL_ROOT:-.cursor/skills/planning-with-files}/scripts\"; powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"$SD/check-complete.ps1\" 2>/dev/null || sh \"$SD/check-complete.sh\""
|
||||
command: "export SD=\"${CURSOR_SKILL_ROOT:-.cursor/skills/planning-with-files}/scripts\"; powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"$SD/check-complete.ps1\" 2>/dev/null || sh \"$SD/check-complete.sh\""
|
||||
metadata:
|
||||
version: "2.23.0"
|
||||
version: "2.32.0"
|
||||
---
|
||||
|
||||
# Planning with Files
|
||||
|
||||
@@ -21,9 +21,9 @@ hooks:
|
||||
Stop:
|
||||
- hooks:
|
||||
- type: command
|
||||
command: "SD=\"${FACTORY_PROJECT_DIR:-.factory/skills/planning-with-files}/scripts\"; sh \"$SD/check-complete.sh\" 2>/dev/null || true"
|
||||
command: "export SD=\"${FACTORY_PROJECT_DIR:-.factory/skills/planning-with-files}/scripts\"; sh \"$SD/check-complete.sh\" 2>/dev/null || true"
|
||||
metadata:
|
||||
version: "2.26.0"
|
||||
version: "2.32.0"
|
||||
---
|
||||
|
||||
# Planning with Files
|
||||
|
||||
Regular → Executable
+249
-67
@@ -12,9 +12,35 @@ import json
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
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:
|
||||
@@ -43,8 +69,8 @@ def normalize_path(project_path: str) -> str:
|
||||
return p
|
||||
|
||||
|
||||
def get_project_dir(project_path: str) -> Tuple[Optional[Path], Optional[str]]:
|
||||
"""Resolve session storage path for the current runtime variant."""
|
||||
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 -
|
||||
@@ -54,44 +80,141 @@ def get_project_dir(project_path: str) -> Tuple[Optional[Path], Optional[str]]:
|
||||
if sanitized.startswith('-'):
|
||||
sanitized = sanitized[1:]
|
||||
|
||||
claude_path = Path.home() / '.claude' / 'projects' / sanitized
|
||||
|
||||
# Codex stores sessions in ~/.codex/sessions with a different format.
|
||||
# Avoid silently scanning Claude paths when running from Codex skill folder.
|
||||
script_path = Path(__file__).as_posix().lower()
|
||||
is_codex_variant = '/.codex/' in script_path
|
||||
codex_sessions_dir = Path.home() / '.codex' / 'sessions'
|
||||
if is_codex_variant and codex_sessions_dir.exists() and not claude_path.exists():
|
||||
return None, (
|
||||
"[planning-with-files] Session catchup skipped: Codex stores sessions "
|
||||
"in ~/.codex/sessions and native Codex parsing is not implemented yet."
|
||||
)
|
||||
|
||||
return claude_path, None
|
||||
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=lambda p: p.stat().st_mtime, reverse=True)
|
||||
return sorted(main_sessions, key=safe_stat_mtime, reverse=True)
|
||||
|
||||
|
||||
def parse_session_messages(session_file: Path) -> List[Dict]:
|
||||
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):
|
||||
try:
|
||||
data = json.loads(line)
|
||||
data = json_loads(line)
|
||||
if data is not None:
|
||||
data['_line_num'] = line_num
|
||||
messages.append(data)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return messages
|
||||
|
||||
|
||||
def find_last_planning_update(messages: List[Dict]) -> Tuple[int, Optional[str]]:
|
||||
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.
|
||||
@@ -100,6 +223,9 @@ def find_last_planning_update(messages: List[Dict]) -> Tuple[int, Optional[str]]
|
||||
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':
|
||||
@@ -109,57 +235,90 @@ def find_last_planning_update(messages: List[Dict]) -> Tuple[int, Optional[str]]
|
||||
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'):
|
||||
file_path = tool_input.get('file_path', '')
|
||||
for pf in PLANNING_FILES:
|
||||
if file_path.endswith(pf):
|
||||
last_update_line = msg['_line_num']
|
||||
last_update_file = pf
|
||||
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 extract_messages_after(messages: List[Dict], after_line: int) -> List[Dict]:
|
||||
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:
|
||||
if msg['_line_num'] <= after_line:
|
||||
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 = 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 = ''
|
||||
content = text_content(msg.get('message', {}).get('content', ''))
|
||||
|
||||
if content and isinstance(content, str):
|
||||
if content:
|
||||
if content.startswith(('<local-command', '<command-', '<task-notification')):
|
||||
continue
|
||||
if len(content) > 20:
|
||||
result.append({'role': 'user', 'content': content, 'line': msg['_line_num']})
|
||||
result.append({'role': 'user', 'content': content, 'line': line_num})
|
||||
|
||||
elif msg_type == 'assistant':
|
||||
msg_content = msg.get('message', {}).get('content', '')
|
||||
text_content = ''
|
||||
text = text_content(msg_content)
|
||||
tool_uses = []
|
||||
|
||||
if isinstance(msg_content, str):
|
||||
text_content = msg_content
|
||||
elif isinstance(msg_content, list):
|
||||
if 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':
|
||||
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':
|
||||
@@ -170,12 +329,43 @@ def extract_messages_after(messages: List[Dict], after_line: int) -> List[Dict]:
|
||||
else:
|
||||
tool_uses.append(f"{tool_name}")
|
||||
|
||||
if text_content or tool_uses:
|
||||
if text or tool_uses:
|
||||
result.append({
|
||||
'role': 'assistant',
|
||||
'content': text_content[:600] if text_content else '',
|
||||
'content': text[:600] if text else '',
|
||||
'tools': tool_uses,
|
||||
'line': msg['_line_num']
|
||||
'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
|
||||
@@ -192,25 +382,15 @@ def main():
|
||||
# No planning files in this project; skip catchup to avoid noise.
|
||||
return
|
||||
|
||||
project_dir, skip_reason = get_project_dir(project_path)
|
||||
if skip_reason:
|
||||
print(skip_reason)
|
||||
return
|
||||
|
||||
if not project_dir.exists():
|
||||
# No previous sessions, nothing to catch up on
|
||||
return
|
||||
|
||||
sessions = get_sessions_sorted(project_dir)
|
||||
if len(sessions) < 1:
|
||||
return
|
||||
runtime_name, sessions = get_session_candidates(project_path)
|
||||
|
||||
# Find a substantial previous session
|
||||
target_session = None
|
||||
for session in sessions:
|
||||
if session.stat().st_size > 5000:
|
||||
target_session = session
|
||||
break
|
||||
if runtime_name == 'claude' and not is_substantial_session(session):
|
||||
continue
|
||||
target_session = session
|
||||
break
|
||||
|
||||
if not target_session:
|
||||
return
|
||||
@@ -231,17 +411,19 @@ def main():
|
||||
# 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"CLAUDE: {msg['content'][:300]}")
|
||||
print(f"{assistant_label}: {msg['content'][:300]}")
|
||||
if msg.get('tools'):
|
||||
print(f" Tools: {', '.join(msg['tools'][:4])}")
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name: planning-with-files
|
||||
description: Implements Manus-style file-based planning to organize and track progress on complex tasks. Creates task_plan.md, findings.md, and progress.md. Use when asked to plan out, break down, or organize a multi-step project, research task, or any work requiring >5 tool calls. Supports automatic session recovery after /clear.
|
||||
metadata:
|
||||
version: "2.26.0"
|
||||
version: "2.32.0"
|
||||
hooks: "Configured in .gemini/settings.json (SessionStart, BeforeTool, AfterTool, BeforeModel)"
|
||||
---
|
||||
|
||||
|
||||
Regular → Executable
+249
-67
@@ -12,9 +12,35 @@ import json
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
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:
|
||||
@@ -43,8 +69,8 @@ def normalize_path(project_path: str) -> str:
|
||||
return p
|
||||
|
||||
|
||||
def get_project_dir(project_path: str) -> Tuple[Optional[Path], Optional[str]]:
|
||||
"""Resolve session storage path for the current runtime variant."""
|
||||
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 -
|
||||
@@ -54,44 +80,141 @@ def get_project_dir(project_path: str) -> Tuple[Optional[Path], Optional[str]]:
|
||||
if sanitized.startswith('-'):
|
||||
sanitized = sanitized[1:]
|
||||
|
||||
claude_path = Path.home() / '.claude' / 'projects' / sanitized
|
||||
|
||||
# Codex stores sessions in ~/.codex/sessions with a different format.
|
||||
# Avoid silently scanning Claude paths when running from Codex skill folder.
|
||||
script_path = Path(__file__).as_posix().lower()
|
||||
is_codex_variant = '/.codex/' in script_path
|
||||
codex_sessions_dir = Path.home() / '.codex' / 'sessions'
|
||||
if is_codex_variant and codex_sessions_dir.exists() and not claude_path.exists():
|
||||
return None, (
|
||||
"[planning-with-files] Session catchup skipped: Codex stores sessions "
|
||||
"in ~/.codex/sessions and native Codex parsing is not implemented yet."
|
||||
)
|
||||
|
||||
return claude_path, None
|
||||
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=lambda p: p.stat().st_mtime, reverse=True)
|
||||
return sorted(main_sessions, key=safe_stat_mtime, reverse=True)
|
||||
|
||||
|
||||
def parse_session_messages(session_file: Path) -> List[Dict]:
|
||||
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):
|
||||
try:
|
||||
data = json.loads(line)
|
||||
data = json_loads(line)
|
||||
if data is not None:
|
||||
data['_line_num'] = line_num
|
||||
messages.append(data)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return messages
|
||||
|
||||
|
||||
def find_last_planning_update(messages: List[Dict]) -> Tuple[int, Optional[str]]:
|
||||
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.
|
||||
@@ -100,6 +223,9 @@ def find_last_planning_update(messages: List[Dict]) -> Tuple[int, Optional[str]]
|
||||
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':
|
||||
@@ -109,57 +235,90 @@ def find_last_planning_update(messages: List[Dict]) -> Tuple[int, Optional[str]]
|
||||
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'):
|
||||
file_path = tool_input.get('file_path', '')
|
||||
for pf in PLANNING_FILES:
|
||||
if file_path.endswith(pf):
|
||||
last_update_line = msg['_line_num']
|
||||
last_update_file = pf
|
||||
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 extract_messages_after(messages: List[Dict], after_line: int) -> List[Dict]:
|
||||
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:
|
||||
if msg['_line_num'] <= after_line:
|
||||
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 = 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 = ''
|
||||
content = text_content(msg.get('message', {}).get('content', ''))
|
||||
|
||||
if content and isinstance(content, str):
|
||||
if content:
|
||||
if content.startswith(('<local-command', '<command-', '<task-notification')):
|
||||
continue
|
||||
if len(content) > 20:
|
||||
result.append({'role': 'user', 'content': content, 'line': msg['_line_num']})
|
||||
result.append({'role': 'user', 'content': content, 'line': line_num})
|
||||
|
||||
elif msg_type == 'assistant':
|
||||
msg_content = msg.get('message', {}).get('content', '')
|
||||
text_content = ''
|
||||
text = text_content(msg_content)
|
||||
tool_uses = []
|
||||
|
||||
if isinstance(msg_content, str):
|
||||
text_content = msg_content
|
||||
elif isinstance(msg_content, list):
|
||||
if 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':
|
||||
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':
|
||||
@@ -170,12 +329,43 @@ def extract_messages_after(messages: List[Dict], after_line: int) -> List[Dict]:
|
||||
else:
|
||||
tool_uses.append(f"{tool_name}")
|
||||
|
||||
if text_content or tool_uses:
|
||||
if text or tool_uses:
|
||||
result.append({
|
||||
'role': 'assistant',
|
||||
'content': text_content[:600] if text_content else '',
|
||||
'content': text[:600] if text else '',
|
||||
'tools': tool_uses,
|
||||
'line': msg['_line_num']
|
||||
'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
|
||||
@@ -192,25 +382,15 @@ def main():
|
||||
# No planning files in this project; skip catchup to avoid noise.
|
||||
return
|
||||
|
||||
project_dir, skip_reason = get_project_dir(project_path)
|
||||
if skip_reason:
|
||||
print(skip_reason)
|
||||
return
|
||||
|
||||
if not project_dir.exists():
|
||||
# No previous sessions, nothing to catch up on
|
||||
return
|
||||
|
||||
sessions = get_sessions_sorted(project_dir)
|
||||
if len(sessions) < 1:
|
||||
return
|
||||
runtime_name, sessions = get_session_candidates(project_path)
|
||||
|
||||
# Find a substantial previous session
|
||||
target_session = None
|
||||
for session in sessions:
|
||||
if session.stat().st_size > 5000:
|
||||
target_session = session
|
||||
break
|
||||
if runtime_name == 'claude' and not is_substantial_session(session):
|
||||
continue
|
||||
target_session = session
|
||||
break
|
||||
|
||||
if not target_session:
|
||||
return
|
||||
@@ -231,17 +411,19 @@ def main():
|
||||
# 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"CLAUDE: {msg['content'][:300]}")
|
||||
print(f"{assistant_label}: {msg['content'][:300]}")
|
||||
if msg.get('tools'):
|
||||
print(f" Tools: {', '.join(msg['tools'][:4])}")
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ license: MIT
|
||||
compatibility: Requires a POSIX shell or PowerShell, Python 3 for session-catchup, and read/write access to the workspace. See Kiro Agent Skills — https://kiro.dev/docs/skills/
|
||||
allowed-tools: shell read write
|
||||
metadata:
|
||||
version: "3.0.0-kiro"
|
||||
version: "2.32.0-kiro"
|
||||
integration: kiro
|
||||
---
|
||||
|
||||
|
||||
@@ -21,9 +21,9 @@ hooks:
|
||||
Stop:
|
||||
- hooks:
|
||||
- type: command
|
||||
command: "SD=\"$HOME/.mastracode/skills/planning-with-files/scripts\"; [ -f \"$SD/check-complete.sh\" ] || SD=\".mastracode/skills/planning-with-files/scripts\"; powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"$SD/check-complete.ps1\" 2>/dev/null || sh \"$SD/check-complete.sh\""
|
||||
command: "export SD=\"$HOME/.mastracode/skills/planning-with-files/scripts\"; [ -f \"$SD/check-complete.sh\" ] || export SD=\".mastracode/skills/planning-with-files/scripts\"; powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"$SD/check-complete.ps1\" 2>/dev/null || sh \"$SD/check-complete.sh\""
|
||||
metadata:
|
||||
version: "2.23.0"
|
||||
version: "2.32.0"
|
||||
---
|
||||
|
||||
# Planning with Files
|
||||
|
||||
@@ -21,9 +21,9 @@ hooks:
|
||||
Stop:
|
||||
- hooks:
|
||||
- type: command
|
||||
command: "SD=\"${OPENCODE_SKILL_ROOT:-$HOME/.config/opencode/skills/planning-with-files}/scripts\"; powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"$SD/check-complete.ps1\" 2>/dev/null || sh \"$SD/check-complete.sh\""
|
||||
command: "export SD=\"${OPENCODE_SKILL_ROOT:-$HOME/.config/opencode/skills/planning-with-files}/scripts\"; powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"$SD/check-complete.ps1\" 2>/dev/null || sh \"$SD/check-complete.sh\""
|
||||
metadata:
|
||||
version: "2.23.0"
|
||||
version: "2.32.0"
|
||||
---
|
||||
|
||||
# Planning with Files
|
||||
|
||||
@@ -12,9 +12,35 @@ import json
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
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:
|
||||
@@ -43,8 +69,8 @@ def normalize_path(project_path: str) -> str:
|
||||
return p
|
||||
|
||||
|
||||
def get_project_dir(project_path: str) -> Tuple[Optional[Path], Optional[str]]:
|
||||
"""Resolve session storage path for the current runtime variant."""
|
||||
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 -
|
||||
@@ -54,44 +80,141 @@ def get_project_dir(project_path: str) -> Tuple[Optional[Path], Optional[str]]:
|
||||
if sanitized.startswith('-'):
|
||||
sanitized = sanitized[1:]
|
||||
|
||||
claude_path = Path.home() / '.claude' / 'projects' / sanitized
|
||||
|
||||
# Codex stores sessions in ~/.codex/sessions with a different format.
|
||||
# Avoid silently scanning Claude paths when running from Codex skill folder.
|
||||
script_path = Path(__file__).as_posix().lower()
|
||||
is_codex_variant = '/.codex/' in script_path
|
||||
codex_sessions_dir = Path.home() / '.codex' / 'sessions'
|
||||
if is_codex_variant and codex_sessions_dir.exists() and not claude_path.exists():
|
||||
return None, (
|
||||
"[planning-with-files] Session catchup skipped: Codex stores sessions "
|
||||
"in ~/.codex/sessions and native Codex parsing is not implemented yet."
|
||||
)
|
||||
|
||||
return claude_path, None
|
||||
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=lambda p: p.stat().st_mtime, reverse=True)
|
||||
return sorted(main_sessions, key=safe_stat_mtime, reverse=True)
|
||||
|
||||
|
||||
def parse_session_messages(session_file: Path) -> List[Dict]:
|
||||
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):
|
||||
try:
|
||||
data = json.loads(line)
|
||||
data = json_loads(line)
|
||||
if data is not None:
|
||||
data['_line_num'] = line_num
|
||||
messages.append(data)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return messages
|
||||
|
||||
|
||||
def find_last_planning_update(messages: List[Dict]) -> Tuple[int, Optional[str]]:
|
||||
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.
|
||||
@@ -100,6 +223,9 @@ def find_last_planning_update(messages: List[Dict]) -> Tuple[int, Optional[str]]
|
||||
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':
|
||||
@@ -109,57 +235,90 @@ def find_last_planning_update(messages: List[Dict]) -> Tuple[int, Optional[str]]
|
||||
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'):
|
||||
file_path = tool_input.get('file_path', '')
|
||||
for pf in PLANNING_FILES:
|
||||
if file_path.endswith(pf):
|
||||
last_update_line = msg['_line_num']
|
||||
last_update_file = pf
|
||||
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 extract_messages_after(messages: List[Dict], after_line: int) -> List[Dict]:
|
||||
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:
|
||||
if msg['_line_num'] <= after_line:
|
||||
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 = 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 = ''
|
||||
content = text_content(msg.get('message', {}).get('content', ''))
|
||||
|
||||
if content and isinstance(content, str):
|
||||
if content:
|
||||
if content.startswith(('<local-command', '<command-', '<task-notification')):
|
||||
continue
|
||||
if len(content) > 20:
|
||||
result.append({'role': 'user', 'content': content, 'line': msg['_line_num']})
|
||||
result.append({'role': 'user', 'content': content, 'line': line_num})
|
||||
|
||||
elif msg_type == 'assistant':
|
||||
msg_content = msg.get('message', {}).get('content', '')
|
||||
text_content = ''
|
||||
text = text_content(msg_content)
|
||||
tool_uses = []
|
||||
|
||||
if isinstance(msg_content, str):
|
||||
text_content = msg_content
|
||||
elif isinstance(msg_content, list):
|
||||
if 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':
|
||||
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':
|
||||
@@ -170,12 +329,43 @@ def extract_messages_after(messages: List[Dict], after_line: int) -> List[Dict]:
|
||||
else:
|
||||
tool_uses.append(f"{tool_name}")
|
||||
|
||||
if text_content or tool_uses:
|
||||
if text or tool_uses:
|
||||
result.append({
|
||||
'role': 'assistant',
|
||||
'content': text_content[:600] if text_content else '',
|
||||
'content': text[:600] if text else '',
|
||||
'tools': tool_uses,
|
||||
'line': msg['_line_num']
|
||||
'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
|
||||
@@ -192,25 +382,15 @@ def main():
|
||||
# No planning files in this project; skip catchup to avoid noise.
|
||||
return
|
||||
|
||||
project_dir, skip_reason = get_project_dir(project_path)
|
||||
if skip_reason:
|
||||
print(skip_reason)
|
||||
return
|
||||
|
||||
if not project_dir.exists():
|
||||
# No previous sessions, nothing to catch up on
|
||||
return
|
||||
|
||||
sessions = get_sessions_sorted(project_dir)
|
||||
if len(sessions) < 1:
|
||||
return
|
||||
runtime_name, sessions = get_session_candidates(project_path)
|
||||
|
||||
# Find a substantial previous session
|
||||
target_session = None
|
||||
for session in sessions:
|
||||
if session.stat().st_size > 5000:
|
||||
target_session = session
|
||||
break
|
||||
if runtime_name == 'claude' and not is_substantial_session(session):
|
||||
continue
|
||||
target_session = session
|
||||
break
|
||||
|
||||
if not target_session:
|
||||
return
|
||||
@@ -231,17 +411,19 @@ def main():
|
||||
# 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"CLAUDE: {msg['content'][:300]}")
|
||||
print(f"{assistant_label}: {msg['content'][:300]}")
|
||||
if msg.get('tools'):
|
||||
print(f" Tools: {', '.join(msg['tools'][:4])}")
|
||||
|
||||
|
||||
@@ -2,6 +2,68 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [2.32.0] - 2026-04-08
|
||||
|
||||
### Added
|
||||
|
||||
- **Codex session catchup** (PR #124 by @ebrevdo)
|
||||
- `session-catchup.py` now reads Codex rollout JSONL from `~/.codex/sessions`, prefers `CODEX_THREAD_ID` when skipping the current thread, filters subagent and tiny sessions, and detects planning-file updates from structured Codex `patch_apply_end` events
|
||||
- Major rewrite: +1,945/-486 lines across session-catchup, tests, and docs
|
||||
|
||||
- **Loaditout security badge** (PR #126, closes #123)
|
||||
- Added A-grade security badge to README (top 20.5% of 20,000+ MCP servers scanned)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Stop hook fails on Windows Git Bash (MSYS2)** (PR #126, closes #125)
|
||||
- Root cause: MSYS2 treats bare `SD="/c/Users/..."` as a command to execute rather than a variable assignment
|
||||
- Fix: changed `SD="..."` to `export SD="..."` across all 9 SKILL.md variants (Claude Code, Codex, CodeBuddy, Cursor, Factory, Gemini, Mastra Code, OpenCode, + zh/zht)
|
||||
|
||||
### Changed
|
||||
|
||||
- Version bumped to 2.32.0 across all 12 SKILL.md files, plugin.json, marketplace.json, and CITATION.cff
|
||||
|
||||
### Thanks
|
||||
|
||||
- @ebrevdo (Eugene Brevdo) for the Codex session catchup rewrite (PR #124)
|
||||
|
||||
## [2.29.0] - 2026-03-24
|
||||
|
||||
### Added
|
||||
|
||||
- **Analytics workflow template** (PR #115 by @mvanhorn, addresses #103)
|
||||
- New `--template analytics` flag on `init-session.sh` and `init-session.ps1`
|
||||
- `templates/analytics_task_plan.md` with 4 analytics-specific phases: Data Discovery, Exploratory Analysis, Hypothesis Testing, Synthesis
|
||||
- `templates/analytics_findings.md` with Data Sources table, Hypothesis Log, Query Results, and Statistical Findings sections
|
||||
- Analytics-specific `progress.md` generates a Query Log table instead of Test Results
|
||||
- Default behavior unchanged; existing users are not affected
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
./scripts/init-session.sh --template analytics my-project
|
||||
```
|
||||
|
||||
### Thanks
|
||||
|
||||
- @mvanhorn (Matt Van Horn) for implementing the analytics template that @sedlukha requested in #103
|
||||
|
||||
---
|
||||
|
||||
## [2.28.0] - 2026-03-22
|
||||
|
||||
### Added
|
||||
|
||||
- **Traditional Chinese (zh-TW) skill variant** (PR #113 by @waynelee2048)
|
||||
- Fully translated SKILL.md, templates, and scripts under `skills/planning-with-files-zht/`
|
||||
- Localized hooks, check-complete, init-session, and session-catchup scripts
|
||||
|
||||
### Thanks
|
||||
|
||||
- @waynelee2048 for the Traditional Chinese translation
|
||||
|
||||
---
|
||||
|
||||
## [2.27.0] - 2026-03-20
|
||||
|
||||
### Added
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
cff-version: 1.2.0
|
||||
message: "If you use this software, please cite it using the metadata below."
|
||||
type: software
|
||||
title: "planning-with-files"
|
||||
abstract: "Claude Code skill implementing Manus-style persistent markdown planning — the workflow pattern behind the $2B acquisition. Enables AI agents to plan, track progress, and resume work across sessions using structured markdown files."
|
||||
authors:
|
||||
- family-names: Adi
|
||||
given-names: Ahmad Othman Ammar
|
||||
alias: OthmanAdi
|
||||
version: "2.32.0"
|
||||
date-released: "2026-04-08"
|
||||
license: MIT
|
||||
url: "https://github.com/OthmanAdi/planning-with-files"
|
||||
repository-code: "https://github.com/OthmanAdi/planning-with-files"
|
||||
keywords:
|
||||
- claude-code
|
||||
- agent-skill
|
||||
- planning
|
||||
- task-management
|
||||
- manus-style
|
||||
- autonomous-agents
|
||||
- markdown
|
||||
- ai-agents
|
||||
+15
-2
@@ -82,6 +82,19 @@ These amazing people have contributed code, documentation, or significant improv
|
||||
- Added Pi Agent support with full skill integration
|
||||
- **Impact:** Expands the skill to the Pi Agent ecosystem
|
||||
|
||||
- **[@mvanhorn](https://github.com/mvanhorn)** (Matt Van Horn) - [PR #115](https://github.com/OthmanAdi/planning-with-files/pull/115)
|
||||
- Added analytics workflow template with `--template analytics` flag on `init-session.sh` and `init-session.ps1`
|
||||
- Created `analytics_task_plan.md` with 4 analytics-specific phases (Data Discovery, Exploratory Analysis, Hypothesis Testing, Synthesis)
|
||||
- Created `analytics_findings.md` with Data Sources table, Hypothesis Log, Query Results, and Statistical Findings sections
|
||||
- Analytics-specific `progress.md` with Query Log replacing Test Results
|
||||
- **Impact:** Extends the planning pattern to data analytics workflows (addresses #103)
|
||||
|
||||
- **[@ebrevdo](https://github.com/ebrevdo)** (Eugene Brevdo) - [PR #124](https://github.com/OthmanAdi/planning-with-files/pull/124)
|
||||
- Rewrote `session-catchup.py` to support Codex rollout JSONL session format
|
||||
- Added `CODEX_THREAD_ID` preference, subagent/tiny session filtering, and structured `patch_apply_end` event detection
|
||||
- Updated tests and docs for the new Codex catchup behavior
|
||||
- **Impact:** Brings session recovery parity to Codex users
|
||||
|
||||
### Other Contributors
|
||||
|
||||
- **[@popey](https://github.com/popey)** - [PR #83](https://github.com/OthmanAdi/planning-with-files/pull/83)
|
||||
@@ -185,6 +198,6 @@ If you've contributed and don't see your name here, please open an issue! We wan
|
||||
|
||||
---
|
||||
|
||||
**Total Contributors:** 26+ and growing!
|
||||
**Total Contributors:** 28+ and growing!
|
||||
|
||||
*Last updated: March 22, 2026*
|
||||
*Last updated: April 8, 2026*
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
<div align="center">
|
||||
<img src="media/banner.png" alt="planning-with-files" width="100%">
|
||||
</div>
|
||||
|
||||
# Planning with Files
|
||||
|
||||
> **Work like Manus** — the AI agent company Meta acquired for **$2 billion**.
|
||||
|
||||
[](https://github.com/OthmanAdi/planning-with-files/issues?q=is%3Aissue+is%3Aclosed)
|
||||
[](https://skillsplayground.com/skills/othmanadi-planning-with-files-planning-with-files/)
|
||||
[](https://github.com/OthmanAdi/planning-with-files/pulls?q=is%3Apr+is%3Aclosed)
|
||||
[](docs/evals.md)
|
||||
[](docs/evals.md)
|
||||
[](docs/evals.md)
|
||||
[](https://loaditout.ai/skills/OthmanAdi/planning-with-files)
|
||||
|
||||
<details>
|
||||
<summary><strong>💬 A Note from the Author</strong></summary>
|
||||
@@ -42,18 +48,24 @@ See the full list of everyone who made this project better in [CONTRIBUTORS.md](
|
||||
<details>
|
||||
<summary><strong>📦 Releases & Session Recovery</strong></summary>
|
||||
|
||||
### Current Version: v2.26.2
|
||||
### Current Version: v2.32.0
|
||||
|
||||
| Version | Highlights |
|
||||
|---------|------------|
|
||||
| **v2.26.2** | Fix: `---` in hook commands broke YAML frontmatter parsing — hooks now register correctly |
|
||||
| **v2.26.1** | Fix: session catchup after `/clear` — path sanitization on Windows + content injection (thanks @tony-stark-eth!) |
|
||||
| **v2.32.0** | Codex session catchup rewrite (thanks @ebrevdo!), Loaditout A-grade security badge, Stop hook Git Bash fix |
|
||||
| **v2.31.0** | Codex hooks.json integration with full lifecycle hooks (thanks @Leon-Algo!) |
|
||||
| **v2.30.1** | Fix: Codex script executable bits restored (thanks @Leon-Algo!) |
|
||||
| **v2.30.0** | `CLAUDE_SKILL_DIR` variable, IDE configs moved to per-IDE branches, plugin.json bumped from 2.23.0 |
|
||||
| **v2.29.0** | Analytics workflow template: `--template analytics` flag for data exploration sessions (thanks @mvanhorn!) |
|
||||
| **v2.28.0** | Traditional Chinese (zh-TW) skill variant (thanks @waynelee2048!) |
|
||||
| **v2.26.2** | Fix: `---` in hook commands broke YAML frontmatter parsing, hooks now register correctly |
|
||||
| **v2.26.1** | Fix: session catchup after `/clear`, path sanitization on Windows + content injection (thanks @tony-stark-eth!) |
|
||||
| **v2.26.0** | IDE audit: Factory hooks, Copilot errorOccurred hook, Gemini hooks, bug fixes |
|
||||
| **v2.18.2** | Mastra Code hooks fix (hooks.json + docs accuracy) |
|
||||
| **v2.18.1** | Copilot garbled characters complete fix |
|
||||
| **v2.18.0** | BoxLite sandbox runtime integration |
|
||||
| **v2.17.0** | Mastra Code support + all IDE SKILL.md spec fixes |
|
||||
| **v2.16.1** | Copilot garbled characters fix — PS1 UTF-8 encoding + bash ensure_ascii (thanks @Hexiaopi!) |
|
||||
| **v2.16.1** | Copilot garbled characters fix: PS1 UTF-8 encoding + bash ensure_ascii (thanks @Hexiaopi!) |
|
||||
| **v2.16.0** | GitHub Copilot hooks support (thanks @lincolnwan!) |
|
||||
| **v2.27.0** | Kiro Agent Skill layout (thanks @EListenX!) |
|
||||
| **v2.15.1** | Session catchup false-positive fix (thanks @gydx6!) |
|
||||
@@ -75,7 +87,7 @@ See the full list of everyone who made this project better in [CONTRIBUTORS.md](
|
||||
When your context fills up and you run `/clear`, this skill **automatically recovers** your previous session.
|
||||
|
||||
**How it works:**
|
||||
1. Checks for previous session data in `~/.claude/projects/`
|
||||
1. Checks for previous session data in the active IDE's session store (`~/.claude/projects/` for Claude Code, `~/.codex/sessions/` for Codex)
|
||||
2. Finds when planning files were last updated
|
||||
3. Extracts conversation that happened after (potentially lost context)
|
||||
4. Shows a catchup report so you can sync
|
||||
@@ -152,7 +164,7 @@ A Claude Code plugin that transforms your workflow to use persistent markdown fi
|
||||
[](https://docs.github.com/en/copilot/reference/hooks-configuration)
|
||||
[](https://code.mastra.ai)
|
||||
[](https://boxlite.ai)
|
||||
[](https://github.com/OthmanAdi/planning-with-files/releases)
|
||||
[](https://github.com/OthmanAdi/planning-with-files/releases)
|
||||
[](https://getskillcheck.com)
|
||||
|
||||
## Quick Install
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
@@ -1,17 +1,34 @@
|
||||
# Initialize planning files for a new session
|
||||
# Usage: .\init-session.ps1 [project-name]
|
||||
# Usage: .\init-session.ps1 [-Template TYPE] [project-name]
|
||||
# Templates: default, analytics
|
||||
|
||||
param(
|
||||
[string]$ProjectName = "project"
|
||||
[string]$ProjectName = "project",
|
||||
[string]$Template = "default"
|
||||
)
|
||||
|
||||
$DATE = Get-Date -Format "yyyy-MM-dd"
|
||||
|
||||
Write-Host "Initializing planning files for: $ProjectName"
|
||||
# Resolve template directory (skill root is one level up from scripts/)
|
||||
$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$SkillRoot = Split-Path -Parent $ScriptDir
|
||||
$TemplateDir = Join-Path $SkillRoot "templates"
|
||||
|
||||
Write-Host "Initializing planning files for: $ProjectName (template: $Template)"
|
||||
|
||||
# Validate template
|
||||
if ($Template -ne "default" -and $Template -ne "analytics") {
|
||||
Write-Host "Unknown template: $Template (available: default, analytics). Using default."
|
||||
$Template = "default"
|
||||
}
|
||||
|
||||
# Create task_plan.md if it doesn't exist
|
||||
if (-not (Test-Path "task_plan.md")) {
|
||||
@"
|
||||
$AnalyticsPlan = Join-Path $TemplateDir "analytics_task_plan.md"
|
||||
if ($Template -eq "analytics" -and (Test-Path $AnalyticsPlan)) {
|
||||
Copy-Item $AnalyticsPlan "task_plan.md"
|
||||
} else {
|
||||
@"
|
||||
# Task Plan: [Brief Description]
|
||||
|
||||
## Goal
|
||||
@@ -56,6 +73,7 @@ Phase 1
|
||||
| Error | Resolution |
|
||||
|-------|------------|
|
||||
"@ | Out-File -FilePath "task_plan.md" -Encoding UTF8
|
||||
}
|
||||
Write-Host "Created task_plan.md"
|
||||
} else {
|
||||
Write-Host "task_plan.md already exists, skipping"
|
||||
@@ -63,7 +81,11 @@ Phase 1
|
||||
|
||||
# Create findings.md if it doesn't exist
|
||||
if (-not (Test-Path "findings.md")) {
|
||||
@"
|
||||
$AnalyticsFindings = Join-Path $TemplateDir "analytics_findings.md"
|
||||
if ($Template -eq "analytics" -and (Test-Path $AnalyticsFindings)) {
|
||||
Copy-Item $AnalyticsFindings "findings.md"
|
||||
} else {
|
||||
@"
|
||||
# Findings & Decisions
|
||||
|
||||
## Requirements
|
||||
@@ -83,6 +105,7 @@ if (-not (Test-Path "findings.md")) {
|
||||
## Resources
|
||||
-
|
||||
"@ | Out-File -FilePath "findings.md" -Encoding UTF8
|
||||
}
|
||||
Write-Host "Created findings.md"
|
||||
} else {
|
||||
Write-Host "findings.md already exists, skipping"
|
||||
@@ -90,7 +113,29 @@ if (-not (Test-Path "findings.md")) {
|
||||
|
||||
# Create progress.md if it doesn't exist
|
||||
if (-not (Test-Path "progress.md")) {
|
||||
@"
|
||||
if ($Template -eq "analytics") {
|
||||
@"
|
||||
# Progress Log
|
||||
|
||||
## Session: $DATE
|
||||
|
||||
### Current Status
|
||||
- **Phase:** 1 - Data Discovery
|
||||
- **Started:** $DATE
|
||||
|
||||
### Actions Taken
|
||||
-
|
||||
|
||||
### Query Log
|
||||
| Query | Result Summary | Interpretation |
|
||||
|-------|---------------|----------------|
|
||||
|
||||
### Errors
|
||||
| Error | Resolution |
|
||||
|-------|------------|
|
||||
"@ | Out-File -FilePath "progress.md" -Encoding UTF8
|
||||
} else {
|
||||
@"
|
||||
# Progress Log
|
||||
|
||||
## Session: $DATE
|
||||
@@ -110,6 +155,7 @@ if (-not (Test-Path "progress.md")) {
|
||||
| Error | Resolution |
|
||||
|-------|------------|
|
||||
"@ | Out-File -FilePath "progress.md" -Encoding UTF8
|
||||
}
|
||||
Write-Host "Created progress.md"
|
||||
} else {
|
||||
Write-Host "progress.md already exists, skipping"
|
||||
|
||||
+65
-6
@@ -1,17 +1,48 @@
|
||||
#!/bin/bash
|
||||
# Initialize planning files for a new session
|
||||
# Usage: ./init-session.sh [project-name]
|
||||
# Usage: ./init-session.sh [--template TYPE] [project-name]
|
||||
# Templates: default, analytics
|
||||
|
||||
set -e
|
||||
|
||||
PROJECT_NAME="${1:-project}"
|
||||
# Parse arguments
|
||||
TEMPLATE="default"
|
||||
PROJECT_NAME="project"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--template|-t)
|
||||
TEMPLATE="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
PROJECT_NAME="$1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
|
||||
echo "Initializing planning files for: $PROJECT_NAME"
|
||||
# Resolve template directory (skill root is one level up from scripts/)
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
SKILL_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||
TEMPLATE_DIR="$SKILL_ROOT/templates"
|
||||
|
||||
echo "Initializing planning files for: $PROJECT_NAME (template: $TEMPLATE)"
|
||||
|
||||
# Validate template
|
||||
if [ "$TEMPLATE" != "default" ] && [ "$TEMPLATE" != "analytics" ]; then
|
||||
echo "Unknown template: $TEMPLATE (available: default, analytics). Using default."
|
||||
TEMPLATE="default"
|
||||
fi
|
||||
|
||||
# Create task_plan.md if it doesn't exist
|
||||
if [ ! -f "task_plan.md" ]; then
|
||||
cat > task_plan.md << 'EOF'
|
||||
if [ "$TEMPLATE" = "analytics" ] && [ -f "$TEMPLATE_DIR/analytics_task_plan.md" ]; then
|
||||
cp "$TEMPLATE_DIR/analytics_task_plan.md" task_plan.md
|
||||
else
|
||||
cat > task_plan.md << 'EOF'
|
||||
# Task Plan: [Brief Description]
|
||||
|
||||
## Goal
|
||||
@@ -56,6 +87,7 @@ Phase 1
|
||||
| Error | Resolution |
|
||||
|-------|------------|
|
||||
EOF
|
||||
fi
|
||||
echo "Created task_plan.md"
|
||||
else
|
||||
echo "task_plan.md already exists, skipping"
|
||||
@@ -63,7 +95,10 @@ fi
|
||||
|
||||
# Create findings.md if it doesn't exist
|
||||
if [ ! -f "findings.md" ]; then
|
||||
cat > findings.md << 'EOF'
|
||||
if [ "$TEMPLATE" = "analytics" ] && [ -f "$TEMPLATE_DIR/analytics_findings.md" ]; then
|
||||
cp "$TEMPLATE_DIR/analytics_findings.md" findings.md
|
||||
else
|
||||
cat > findings.md << 'EOF'
|
||||
# Findings & Decisions
|
||||
|
||||
## Requirements
|
||||
@@ -83,6 +118,7 @@ if [ ! -f "findings.md" ]; then
|
||||
## Resources
|
||||
-
|
||||
EOF
|
||||
fi
|
||||
echo "Created findings.md"
|
||||
else
|
||||
echo "findings.md already exists, skipping"
|
||||
@@ -90,7 +126,29 @@ fi
|
||||
|
||||
# Create progress.md if it doesn't exist
|
||||
if [ ! -f "progress.md" ]; then
|
||||
cat > progress.md << EOF
|
||||
if [ "$TEMPLATE" = "analytics" ]; then
|
||||
cat > progress.md << EOF
|
||||
# Progress Log
|
||||
|
||||
## Session: $DATE
|
||||
|
||||
### Current Status
|
||||
- **Phase:** 1 - Data Discovery
|
||||
- **Started:** $DATE
|
||||
|
||||
### Actions Taken
|
||||
-
|
||||
|
||||
### Query Log
|
||||
| Query | Result Summary | Interpretation |
|
||||
|-------|---------------|----------------|
|
||||
|
||||
### Errors
|
||||
| Error | Resolution |
|
||||
|-------|------------|
|
||||
EOF
|
||||
else
|
||||
cat > progress.md << EOF
|
||||
# Progress Log
|
||||
|
||||
## Session: $DATE
|
||||
@@ -110,6 +168,7 @@ if [ ! -f "progress.md" ]; then
|
||||
| Error | Resolution |
|
||||
|-------|------------|
|
||||
EOF
|
||||
fi
|
||||
echo "Created progress.md"
|
||||
else
|
||||
echo "progress.md already exists, skipping"
|
||||
|
||||
@@ -21,9 +21,9 @@ hooks:
|
||||
Stop:
|
||||
- hooks:
|
||||
- type: command
|
||||
command: "SD=\"${CLAUDE_PLUGIN_ROOT:-$HOME/.claude/plugins/planning-with-files}/scripts\"; powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"$SD/check-complete.ps1\" 2>/dev/null || sh \"$SD/check-complete.sh\""
|
||||
command: "export SD=\"${CLAUDE_PLUGIN_ROOT:-$HOME/.claude/plugins/planning-with-files}/scripts\"; powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"$SD/check-complete.ps1\" 2>/dev/null || sh \"$SD/check-complete.sh\""
|
||||
metadata:
|
||||
version: "2.24.0"
|
||||
version: "2.32.0"
|
||||
---
|
||||
|
||||
# 文件规划系统
|
||||
|
||||
@@ -21,9 +21,9 @@ hooks:
|
||||
Stop:
|
||||
- hooks:
|
||||
- type: command
|
||||
command: "SD=\"${CLAUDE_PLUGIN_ROOT:-$HOME/.claude/plugins/planning-with-files-zht}/scripts\"; powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"$SD/check-complete.ps1\" 2>/dev/null || sh \"$SD/check-complete.sh\""
|
||||
command: "export SD=\"${CLAUDE_PLUGIN_ROOT:-$HOME/.claude/plugins/planning-with-files-zht}/scripts\"; powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"$SD/check-complete.ps1\" 2>/dev/null || sh \"$SD/check-complete.sh\""
|
||||
metadata:
|
||||
version: "2.28.0"
|
||||
version: "2.32.0"
|
||||
---
|
||||
|
||||
# 檔案規劃系統
|
||||
|
||||
@@ -21,9 +21,9 @@ hooks:
|
||||
Stop:
|
||||
- hooks:
|
||||
- type: command
|
||||
command: "SD=\"${CLAUDE_PLUGIN_ROOT:-$HOME/.claude/plugins/planning-with-files}/scripts\"; powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"$SD/check-complete.ps1\" 2>/dev/null || sh \"$SD/check-complete.sh\""
|
||||
command: "export SD=\"${CLAUDE_PLUGIN_ROOT:-$HOME/.claude/plugins/planning-with-files}/scripts\"; powershell.exe -NoProfile -ExecutionPolicy Bypass -File \"$SD/check-complete.ps1\" 2>/dev/null || sh \"$SD/check-complete.sh\""
|
||||
metadata:
|
||||
version: "2.26.1"
|
||||
version: "2.32.0"
|
||||
---
|
||||
|
||||
# Planning with Files
|
||||
|
||||
@@ -12,9 +12,35 @@ import json
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
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:
|
||||
@@ -43,8 +69,8 @@ def normalize_path(project_path: str) -> str:
|
||||
return p
|
||||
|
||||
|
||||
def get_project_dir(project_path: str) -> Tuple[Optional[Path], Optional[str]]:
|
||||
"""Resolve session storage path for the current runtime variant."""
|
||||
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 -
|
||||
@@ -54,44 +80,141 @@ def get_project_dir(project_path: str) -> Tuple[Optional[Path], Optional[str]]:
|
||||
if sanitized.startswith('-'):
|
||||
sanitized = sanitized[1:]
|
||||
|
||||
claude_path = Path.home() / '.claude' / 'projects' / sanitized
|
||||
|
||||
# Codex stores sessions in ~/.codex/sessions with a different format.
|
||||
# Avoid silently scanning Claude paths when running from Codex skill folder.
|
||||
script_path = Path(__file__).as_posix().lower()
|
||||
is_codex_variant = '/.codex/' in script_path
|
||||
codex_sessions_dir = Path.home() / '.codex' / 'sessions'
|
||||
if is_codex_variant and codex_sessions_dir.exists() and not claude_path.exists():
|
||||
return None, (
|
||||
"[planning-with-files] Session catchup skipped: Codex stores sessions "
|
||||
"in ~/.codex/sessions and native Codex parsing is not implemented yet."
|
||||
)
|
||||
|
||||
return claude_path, None
|
||||
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=lambda p: p.stat().st_mtime, reverse=True)
|
||||
return sorted(main_sessions, key=safe_stat_mtime, reverse=True)
|
||||
|
||||
|
||||
def parse_session_messages(session_file: Path) -> List[Dict]:
|
||||
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):
|
||||
try:
|
||||
data = json.loads(line)
|
||||
data = json_loads(line)
|
||||
if data is not None:
|
||||
data['_line_num'] = line_num
|
||||
messages.append(data)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return messages
|
||||
|
||||
|
||||
def find_last_planning_update(messages: List[Dict]) -> Tuple[int, Optional[str]]:
|
||||
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.
|
||||
@@ -100,6 +223,9 @@ def find_last_planning_update(messages: List[Dict]) -> Tuple[int, Optional[str]]
|
||||
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':
|
||||
@@ -109,57 +235,90 @@ def find_last_planning_update(messages: List[Dict]) -> Tuple[int, Optional[str]]
|
||||
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'):
|
||||
file_path = tool_input.get('file_path', '')
|
||||
for pf in PLANNING_FILES:
|
||||
if file_path.endswith(pf):
|
||||
last_update_line = msg['_line_num']
|
||||
last_update_file = pf
|
||||
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 extract_messages_after(messages: List[Dict], after_line: int) -> List[Dict]:
|
||||
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:
|
||||
if msg['_line_num'] <= after_line:
|
||||
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 = 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 = ''
|
||||
content = text_content(msg.get('message', {}).get('content', ''))
|
||||
|
||||
if content and isinstance(content, str):
|
||||
if content:
|
||||
if content.startswith(('<local-command', '<command-', '<task-notification')):
|
||||
continue
|
||||
if len(content) > 20:
|
||||
result.append({'role': 'user', 'content': content, 'line': msg['_line_num']})
|
||||
result.append({'role': 'user', 'content': content, 'line': line_num})
|
||||
|
||||
elif msg_type == 'assistant':
|
||||
msg_content = msg.get('message', {}).get('content', '')
|
||||
text_content = ''
|
||||
text = text_content(msg_content)
|
||||
tool_uses = []
|
||||
|
||||
if isinstance(msg_content, str):
|
||||
text_content = msg_content
|
||||
elif isinstance(msg_content, list):
|
||||
if 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':
|
||||
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':
|
||||
@@ -170,12 +329,43 @@ def extract_messages_after(messages: List[Dict], after_line: int) -> List[Dict]:
|
||||
else:
|
||||
tool_uses.append(f"{tool_name}")
|
||||
|
||||
if text_content or tool_uses:
|
||||
if text or tool_uses:
|
||||
result.append({
|
||||
'role': 'assistant',
|
||||
'content': text_content[:600] if text_content else '',
|
||||
'content': text[:600] if text else '',
|
||||
'tools': tool_uses,
|
||||
'line': msg['_line_num']
|
||||
'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
|
||||
@@ -192,25 +382,15 @@ def main():
|
||||
# No planning files in this project; skip catchup to avoid noise.
|
||||
return
|
||||
|
||||
project_dir, skip_reason = get_project_dir(project_path)
|
||||
if skip_reason:
|
||||
print(skip_reason)
|
||||
return
|
||||
|
||||
if not project_dir.exists():
|
||||
# No previous sessions, nothing to catch up on
|
||||
return
|
||||
|
||||
sessions = get_sessions_sorted(project_dir)
|
||||
if len(sessions) < 1:
|
||||
return
|
||||
runtime_name, sessions = get_session_candidates(project_path)
|
||||
|
||||
# Find a substantial previous session
|
||||
target_session = None
|
||||
for session in sessions:
|
||||
if session.stat().st_size > 5000:
|
||||
target_session = session
|
||||
break
|
||||
if runtime_name == 'claude' and not is_substantial_session(session):
|
||||
continue
|
||||
target_session = session
|
||||
break
|
||||
|
||||
if not target_session:
|
||||
return
|
||||
@@ -231,17 +411,19 @@ def main():
|
||||
# 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"CLAUDE: {msg['content'][:300]}")
|
||||
print(f"{assistant_label}: {msg['content'][:300]}")
|
||||
if msg.get('tools'):
|
||||
print(f" Tools: {', '.join(msg['tools'][:4])}")
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
# Findings & Decisions
|
||||
<!--
|
||||
WHAT: Knowledge base for your analytics session. Stores data sources, hypotheses, and results.
|
||||
WHY: Context windows are limited. This file is your "external memory" for analytical work.
|
||||
WHEN: Update after ANY discovery, especially after running queries or viewing charts.
|
||||
-->
|
||||
|
||||
## Data Sources
|
||||
<!--
|
||||
WHAT: Every data source you connected to, with schema details and quality notes.
|
||||
WHY: Knowing where your data came from and its limitations is critical for reproducibility.
|
||||
EXAMPLE:
|
||||
| user_events | PostgreSQL prod replica | 2.3M rows | user_id, event_type, ts | 0.2% null user_id |
|
||||
| revenue.csv | Finance team export | 45K rows | account_id, mrr, churn_date | Complete, no nulls |
|
||||
-->
|
||||
| Source | Location | Size | Key Fields | Quality Notes |
|
||||
|--------|----------|------|------------|---------------|
|
||||
| | | | | |
|
||||
|
||||
## Hypothesis Log
|
||||
<!--
|
||||
WHAT: Each hypothesis you tested, the method used, and the result.
|
||||
WHY: Structured tracking prevents p-hacking and makes your reasoning auditable.
|
||||
EXAMPLE:
|
||||
| H1: Churn > 50% for low-activity users | Chi-squared test | Confirmed (p=0.003) | High |
|
||||
| H2: Feature X correlates with retention | Pearson correlation | Rejected (r=0.08) | High |
|
||||
-->
|
||||
| Hypothesis | Test Method | Result | Confidence |
|
||||
|------------|-------------|--------|------------|
|
||||
| | | | |
|
||||
|
||||
## Query Results
|
||||
<!--
|
||||
WHAT: Key queries you ran and what they revealed.
|
||||
WHY: Queries are ephemeral - if you don't write down the results, they're lost on context reset.
|
||||
WHEN: After EVERY significant query. Don't wait.
|
||||
EXAMPLE:
|
||||
### Churn rate by activity segment
|
||||
Query: SELECT activity_bucket, COUNT(*), AVG(churned) FROM user_segments GROUP BY 1
|
||||
Result: Low activity: 62% churn, Medium: 28%, High: 8%
|
||||
Interpretation: Strong inverse relationship between activity and churn
|
||||
-->
|
||||
<!-- Record query, result summary, and interpretation for each significant query -->
|
||||
|
||||
## Statistical Findings
|
||||
<!--
|
||||
WHAT: Formal statistical test results with all relevant metrics.
|
||||
WHY: Recording p-values, effect sizes, and confidence intervals makes results reproducible.
|
||||
EXAMPLE:
|
||||
| Chi-squared (churn ~ activity) | p=0.003 | Cramer's V=0.31 | Reject null: activity segments differ significantly in churn |
|
||||
| Pearson (feature_x ~ retention) | p=0.42 | r=0.08 | Fail to reject: no meaningful correlation |
|
||||
-->
|
||||
| Test | p-value | Effect Size | Conclusion |
|
||||
|------|---------|-------------|------------|
|
||||
| | | | |
|
||||
|
||||
## Technical Decisions
|
||||
<!--
|
||||
WHAT: Analytical method choices with reasoning.
|
||||
EXAMPLE:
|
||||
| Use log transform on revenue | Right-skewed distribution, normalizes for parametric tests |
|
||||
-->
|
||||
| Decision | Rationale |
|
||||
|----------|-----------|
|
||||
| | |
|
||||
|
||||
## Issues Encountered
|
||||
| Issue | Resolution |
|
||||
|-------|------------|
|
||||
| | |
|
||||
|
||||
## Resources
|
||||
<!-- URLs, file paths, documentation links -->
|
||||
-
|
||||
|
||||
## Visual/Browser Findings
|
||||
<!--
|
||||
CRITICAL: Update after viewing charts, dashboards, or browser results.
|
||||
Multimodal content doesn't persist in context - capture as text immediately.
|
||||
-->
|
||||
-
|
||||
|
||||
---
|
||||
*Update this file after every 2 view/browser/search operations*
|
||||
*This prevents visual information from being lost*
|
||||
@@ -0,0 +1,106 @@
|
||||
# Task Plan: [Analytics Project Description]
|
||||
<!--
|
||||
WHAT: Roadmap for a data analytics or exploration session.
|
||||
WHY: Analytics workflows have different phases than software development — hypothesis testing,
|
||||
data quality checks, and statistical validation don't map to a generic build cycle.
|
||||
WHEN: Create this FIRST before starting any data exploration. Update after each phase.
|
||||
-->
|
||||
|
||||
## Goal
|
||||
<!--
|
||||
WHAT: One clear sentence describing what you're trying to learn or produce.
|
||||
EXAMPLE: "Determine which user segments have the highest churn risk using last 90 days of activity data."
|
||||
-->
|
||||
[One sentence describing the analytical objective]
|
||||
|
||||
## Current Phase
|
||||
<!--
|
||||
WHAT: Which phase you're currently working on (e.g., "Phase 1", "Phase 3").
|
||||
WHY: Quick reference for where you are. Update this as you progress.
|
||||
-->
|
||||
Phase 1
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 1: Data Discovery
|
||||
<!--
|
||||
WHAT: Connect to data sources, understand schemas, assess data quality.
|
||||
WHY: Bad data produces bad analysis. This phase prevents wasted effort on unreliable inputs.
|
||||
-->
|
||||
- [ ] Identify and connect to data sources
|
||||
- [ ] Document schemas and field descriptions in findings.md
|
||||
- [ ] Assess data quality (nulls, duplicates, outliers, date ranges)
|
||||
- [ ] Estimate dataset size and query performance
|
||||
- **Status:** in_progress
|
||||
|
||||
### Phase 2: Exploratory Analysis
|
||||
<!--
|
||||
WHAT: Distributions, correlations, outliers, initial patterns.
|
||||
WHY: Understanding the shape of your data before testing hypotheses prevents false conclusions.
|
||||
-->
|
||||
- [ ] Compute summary statistics for key variables
|
||||
- [ ] Visualize distributions and relationships
|
||||
- [ ] Identify outliers and anomalies
|
||||
- [ ] Document initial patterns in findings.md
|
||||
- **Status:** pending
|
||||
|
||||
### Phase 3: Hypothesis Testing
|
||||
<!--
|
||||
WHAT: Formalize hypotheses, run statistical tests, validate findings.
|
||||
WHY: Moving from "it looks like X" to "we can confidently say X" requires structured testing.
|
||||
-->
|
||||
- [ ] Formalize hypotheses from exploratory phase
|
||||
- [ ] Select appropriate statistical tests
|
||||
- [ ] Run tests and record results in findings.md
|
||||
- [ ] Validate findings against holdout data or alternative methods
|
||||
- **Status:** pending
|
||||
|
||||
### Phase 4: Synthesis & Reporting
|
||||
<!--
|
||||
WHAT: Summarize findings, create visualizations, document conclusions.
|
||||
WHY: Analysis without clear communication is wasted work. This phase produces the deliverable.
|
||||
-->
|
||||
- [ ] Summarize key findings with supporting evidence
|
||||
- [ ] Create final visualizations
|
||||
- [ ] Document conclusions and recommendations
|
||||
- [ ] Note limitations and areas for further investigation
|
||||
- **Status:** pending
|
||||
|
||||
## Hypotheses
|
||||
<!--
|
||||
WHAT: Questions you're investigating, stated as testable hypotheses.
|
||||
WHY: Explicit hypotheses prevent fishing expeditions and keep analysis focused.
|
||||
EXAMPLE:
|
||||
1. Users who logged in < 3 times in the last 30 days have > 50% churn rate (H1)
|
||||
2. Feature X adoption correlates with retention (r > 0.3) (H2)
|
||||
-->
|
||||
1. [Hypothesis to test]
|
||||
2. [Hypothesis to test]
|
||||
|
||||
## Decisions Made
|
||||
<!--
|
||||
WHAT: Analytical decisions with reasoning (e.g., choosing a test, filtering criteria).
|
||||
EXAMPLE:
|
||||
| Use median instead of mean | Revenue data is heavily right-skewed |
|
||||
| Filter to last 90 days | Earlier data uses a different tracking schema |
|
||||
-->
|
||||
| Decision | Rationale |
|
||||
|----------|-----------|
|
||||
| | |
|
||||
|
||||
## Errors Encountered
|
||||
<!--
|
||||
WHAT: Every error you encounter, what attempt number it was, and how you resolved it.
|
||||
EXAMPLE:
|
||||
| Query timeout on raw table | 1 | Added date partition filter |
|
||||
| Null join keys in user_events | 2 | Inner join instead of left join, documented data loss |
|
||||
-->
|
||||
| Error | Attempt | Resolution |
|
||||
|-------|---------|------------|
|
||||
| | 1 | |
|
||||
|
||||
## Notes
|
||||
- Update phase status as you progress: pending -> in_progress -> complete
|
||||
- Re-read this plan before major analytical decisions
|
||||
- Log ALL errors - they help avoid repetition
|
||||
- Write query results and visual findings to findings.md immediately
|
||||
Binary file not shown.
+195
-16
@@ -1,7 +1,11 @@
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stdout
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
@@ -27,7 +31,11 @@ class SessionCatchupCodexTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tempdir = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.tempdir.name)
|
||||
self.project_path = "/tmp/project"
|
||||
self.project_dir = self.root / "project"
|
||||
self.project_dir.mkdir()
|
||||
self.project_path = str(self.project_dir)
|
||||
self.sessions_dir = self.root / ".codex/sessions"
|
||||
self.sessions_dir.mkdir(parents=True)
|
||||
self.codex_script = (
|
||||
self.root / ".codex/skills/planning-with-files/scripts/session-catchup.py"
|
||||
)
|
||||
@@ -38,26 +46,197 @@ class SessionCatchupCodexTests(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
self.tempdir.cleanup()
|
||||
|
||||
def test_codex_variant_skips_when_only_codex_sessions_exist(self):
|
||||
(self.root / ".codex/sessions").mkdir(parents=True, exist_ok=True)
|
||||
def write_codex_session(
|
||||
self,
|
||||
name,
|
||||
*,
|
||||
cwd=None,
|
||||
source="codex",
|
||||
records=(),
|
||||
substantial=True,
|
||||
mtime=100,
|
||||
):
|
||||
path = self.sessions_dir / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
session_records = [
|
||||
{
|
||||
"timestamp": "2026-04-07T00:00:00.000Z",
|
||||
"type": "session_meta",
|
||||
"payload": {"cwd": cwd or self.project_path, "source": source},
|
||||
}
|
||||
]
|
||||
if substantial:
|
||||
session_records.append(
|
||||
{
|
||||
"timestamp": "2026-04-07T00:00:01.000Z",
|
||||
"type": "response_item",
|
||||
"payload": {
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "output_text", "text": "x" * 6000},
|
||||
],
|
||||
},
|
||||
}
|
||||
)
|
||||
session_records.extend(records)
|
||||
with path.open("w", encoding="utf-8") as f:
|
||||
for record in session_records:
|
||||
f.write(json.dumps(record) + "\n")
|
||||
os.utime(path, (mtime, mtime))
|
||||
return path
|
||||
|
||||
with mock.patch("pathlib.Path.home", return_value=self.root):
|
||||
project_dir, skip_reason = self.module.get_project_dir(self.project_path)
|
||||
def codex_candidates(self, *, thread_id=None):
|
||||
updates = {"CODEX_SESSIONS_DIR": str(self.sessions_dir)}
|
||||
if thread_id is not None:
|
||||
updates["CODEX_THREAD_ID"] = thread_id
|
||||
with mock.patch.dict(os.environ, updates, clear=False):
|
||||
if thread_id is None:
|
||||
os.environ.pop("CODEX_THREAD_ID", None)
|
||||
with mock.patch("pathlib.Path.home", return_value=self.root):
|
||||
runtime, sessions = self.module.get_session_candidates(self.project_path)
|
||||
return runtime, list(sessions)
|
||||
|
||||
self.assertIsNone(project_dir)
|
||||
self.assertIn("~/.codex/sessions", skip_reason)
|
||||
self.assertIn("not implemented yet", skip_reason)
|
||||
def test_codex_variant_finds_matching_project_sessions(self):
|
||||
session = self.write_codex_session(
|
||||
"rollout-2026-04-07T00-00-00-previous-thread.jsonl"
|
||||
)
|
||||
|
||||
def test_codex_variant_uses_claude_path_when_project_exists(self):
|
||||
(self.root / ".codex/sessions").mkdir(parents=True, exist_ok=True)
|
||||
expected = self.root / ".claude/projects/-tmp-project"
|
||||
expected.mkdir(parents=True, exist_ok=True)
|
||||
runtime, sessions = self.codex_candidates()
|
||||
|
||||
with mock.patch("pathlib.Path.home", return_value=self.root):
|
||||
project_dir, skip_reason = self.module.get_project_dir(self.project_path)
|
||||
self.assertEqual("codex", runtime)
|
||||
self.assertEqual([session], sessions)
|
||||
|
||||
self.assertEqual(expected, project_dir)
|
||||
self.assertIsNone(skip_reason)
|
||||
def test_codex_variant_prefers_current_thread_for_same_project(self):
|
||||
previous = self.write_codex_session(
|
||||
"rollout-2026-04-07T00-00-00-previous-thread.jsonl",
|
||||
mtime=200,
|
||||
)
|
||||
current = self.write_codex_session(
|
||||
"rollout-2026-04-07T01-00-00-current-thread.jsonl",
|
||||
mtime=100,
|
||||
)
|
||||
|
||||
runtime, sessions = self.codex_candidates(thread_id="current-thread")
|
||||
|
||||
self.assertEqual("codex", runtime)
|
||||
self.assertEqual([current, previous], sessions)
|
||||
|
||||
def test_codex_variant_skips_small_sessions_and_subagents(self):
|
||||
valid = self.write_codex_session(
|
||||
"rollout-2026-04-07T00-00-00-valid-thread.jsonl",
|
||||
mtime=100,
|
||||
)
|
||||
self.write_codex_session(
|
||||
"rollout-2026-04-07T01-00-00-small-thread.jsonl",
|
||||
substantial=False,
|
||||
mtime=200,
|
||||
)
|
||||
self.write_codex_session(
|
||||
"rollout-2026-04-07T02-00-00-subagent-thread.jsonl",
|
||||
source={"subagent": "worker"},
|
||||
mtime=300,
|
||||
)
|
||||
|
||||
runtime, sessions = self.codex_candidates()
|
||||
|
||||
self.assertEqual("codex", runtime)
|
||||
self.assertEqual([valid], sessions)
|
||||
|
||||
def test_codex_structured_patch_event_marks_planning_update(self):
|
||||
messages = [
|
||||
{
|
||||
"_line_num": 7,
|
||||
"type": "event_msg",
|
||||
"payload": {
|
||||
"type": "patch_apply_end",
|
||||
"success": True,
|
||||
"changes": {"progress.md": {"operation": "modified"}},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
self.assertEqual(
|
||||
(7, "progress.md"),
|
||||
self.module.find_last_planning_update(messages),
|
||||
)
|
||||
|
||||
def test_messages_without_line_numbers_are_ignored(self):
|
||||
messages = [
|
||||
{
|
||||
"type": "event_msg",
|
||||
"payload": {
|
||||
"type": "patch_apply_end",
|
||||
"success": True,
|
||||
"changes": {"progress.md": {"operation": "modified"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "response_item",
|
||||
"payload": {
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "ignored"}],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
self.assertEqual((-1, None), self.module.find_last_planning_update(messages))
|
||||
self.assertEqual([], self.module.extract_messages_after(messages, -1))
|
||||
|
||||
def test_codex_main_prints_catchup_from_matching_session(self):
|
||||
for filename in self.module.PLANNING_FILES:
|
||||
(self.project_dir / filename).write_text("# test\n", encoding="utf-8")
|
||||
self.write_codex_session(
|
||||
"rollout-2026-04-07T00-00-00-previous-thread.jsonl",
|
||||
records=[
|
||||
{
|
||||
"timestamp": "2026-04-07T00:00:02.000Z",
|
||||
"type": "event_msg",
|
||||
"payload": {
|
||||
"type": "patch_apply_end",
|
||||
"success": True,
|
||||
"changes": {"task_plan.md": {"operation": "modified"}},
|
||||
},
|
||||
},
|
||||
{
|
||||
"timestamp": "2026-04-07T00:00:03.000Z",
|
||||
"type": "response_item",
|
||||
"payload": {
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"text": "Codex summary after planning update",
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
stdout = io.StringIO()
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{"CODEX_SESSIONS_DIR": str(self.sessions_dir)},
|
||||
clear=False,
|
||||
):
|
||||
os.environ.pop("CODEX_THREAD_ID", None)
|
||||
with mock.patch("pathlib.Path.home", return_value=self.root):
|
||||
with mock.patch.object(
|
||||
self.module.sys,
|
||||
"argv",
|
||||
["session-catchup.py", self.project_path],
|
||||
):
|
||||
with redirect_stdout(stdout):
|
||||
self.module.main()
|
||||
|
||||
output = stdout.getvalue()
|
||||
self.assertIn("SESSION CATCHUP DETECTED", output)
|
||||
self.assertIn("Runtime: codex", output)
|
||||
self.assertIn("Last planning update: task_plan.md", output)
|
||||
self.assertIn("CODEX: Codex summary after planning update", output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user