planning-with-files 3.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +131 -0
- package/SKILL.md +262 -0
- package/examples.md +202 -0
- package/extensions/planning-with-files/README.md +35 -0
- package/extensions/planning-with-files/__tests__/attestation.test.ts +79 -0
- package/extensions/planning-with-files/__tests__/plan-anchor.test.ts +228 -0
- package/extensions/planning-with-files/__tests__/runtime.test.ts +688 -0
- package/extensions/planning-with-files/attestation.ts +55 -0
- package/extensions/planning-with-files/constants.ts +31 -0
- package/extensions/planning-with-files/index.ts +6 -0
- package/extensions/planning-with-files/package.json +17 -0
- package/extensions/planning-with-files/plan.ts +263 -0
- package/extensions/planning-with-files/runtime.ts +788 -0
- package/package.json +46 -0
- package/reference.md +218 -0
- package/scripts/attest-plan.ps1 +137 -0
- package/scripts/attest-plan.sh +206 -0
- package/scripts/check-complete.ps1 +253 -0
- package/scripts/check-complete.sh +253 -0
- package/scripts/init-session.ps1 +230 -0
- package/scripts/init-session.sh +370 -0
- package/scripts/plan-doctor.sh +148 -0
- package/scripts/resolve-plan-dir.ps1 +106 -0
- package/scripts/resolve-plan-dir.sh +263 -0
- package/scripts/session-catchup.py +876 -0
- package/scripts/set-active-plan.ps1 +51 -0
- package/scripts/set-active-plan.sh +50 -0
- package/templates/analytics_findings.md +85 -0
- package/templates/analytics_task_plan.md +106 -0
- package/templates/findings.md +95 -0
- package/templates/progress.md +114 -0
- package/templates/task_plan.md +140 -0
|
@@ -0,0 +1,876 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Session Catchup Script for planning-with-files
|
|
4
|
+
|
|
5
|
+
Analyzes the previous session to find unsynced context after the last
|
|
6
|
+
planning file update. Designed to run on SessionStart.
|
|
7
|
+
|
|
8
|
+
Usage: python3 session-catchup.py [project-path]
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def configure_utf8_stdio() -> None:
|
|
20
|
+
"""Make catchup output deterministic on Windows legacy code pages.
|
|
21
|
+
|
|
22
|
+
Codex sessions and planning files are UTF-8 and can contain arbitrary
|
|
23
|
+
Unicode. Windows PowerShell may nevertheless launch Python with a cp1252
|
|
24
|
+
(or another OEM/ANSI) stdout codec. A report containing Chinese text then
|
|
25
|
+
used to fail at the first ``print`` with ``UnicodeEncodeError``. Configure
|
|
26
|
+
both streams before any report is emitted; ``errors='replace'`` also keeps
|
|
27
|
+
this advisory hook fail-safe if a malformed surrogate reaches the output.
|
|
28
|
+
"""
|
|
29
|
+
for stream in (sys.stdout, sys.stderr):
|
|
30
|
+
reconfigure = getattr(stream, 'reconfigure', None)
|
|
31
|
+
if callable(reconfigure):
|
|
32
|
+
try:
|
|
33
|
+
reconfigure(encoding='utf-8', errors='replace')
|
|
34
|
+
except (OSError, ValueError):
|
|
35
|
+
# Replaced/captured streams may not permit reconfiguration.
|
|
36
|
+
# The hook remains advisory, so retain the existing stream.
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
configure_utf8_stdio()
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
import orjson
|
|
44
|
+
except ImportError:
|
|
45
|
+
orjson = None
|
|
46
|
+
|
|
47
|
+
PLANNING_FILES = ['task_plan.md', 'progress.md', 'findings.md']
|
|
48
|
+
MIN_SESSION_BYTES = 5000
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def json_loads(line: str) -> Optional[Dict[str, Any]]:
|
|
52
|
+
"""Prefer optional orjson while keeping the hook dependency-free."""
|
|
53
|
+
try:
|
|
54
|
+
if orjson is not None:
|
|
55
|
+
data = orjson.loads(line)
|
|
56
|
+
else:
|
|
57
|
+
data = json.loads(line)
|
|
58
|
+
except (ValueError, TypeError, UnicodeDecodeError):
|
|
59
|
+
return None
|
|
60
|
+
return data if isinstance(data, dict) else None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def normalize_for_compare(path_value: str) -> str:
|
|
64
|
+
expanded = os.path.expanduser(path_value)
|
|
65
|
+
try:
|
|
66
|
+
return str(Path(expanded).resolve())
|
|
67
|
+
except (OSError, ValueError):
|
|
68
|
+
return os.path.abspath(expanded)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def normalize_path(project_path: str) -> str:
|
|
72
|
+
"""Normalize project path to match Claude Code's internal representation.
|
|
73
|
+
|
|
74
|
+
Claude Code stores session directories using the Windows-native path
|
|
75
|
+
(e.g., C:\\Users\\...) sanitized with separators replaced by dashes.
|
|
76
|
+
Git Bash passes /c/Users/... which produces a DIFFERENT sanitized
|
|
77
|
+
string. This function converts Git Bash paths to Windows paths first.
|
|
78
|
+
"""
|
|
79
|
+
p = project_path
|
|
80
|
+
|
|
81
|
+
# Git Bash / MSYS2: /c/Users/... -> C:/Users/...
|
|
82
|
+
if len(p) >= 3 and p[0] == '/' and p[2] == '/':
|
|
83
|
+
p = p[1].upper() + ':' + p[2:]
|
|
84
|
+
|
|
85
|
+
# Resolve to absolute path to handle relative paths and symlinks
|
|
86
|
+
try:
|
|
87
|
+
resolved = str(Path(p).resolve())
|
|
88
|
+
# On Windows, resolve() returns C:\Users\... which is what we want
|
|
89
|
+
if os.name == 'nt' or '\\' in resolved:
|
|
90
|
+
p = resolved
|
|
91
|
+
except (OSError, ValueError):
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
return p
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _claude_sanitize(path_str: str, astral_width: int = 2) -> str:
|
|
98
|
+
"""Claude Code's project-dir name for a project path.
|
|
99
|
+
|
|
100
|
+
Every character outside [A-Za-z0-9_-] becomes '-', and the leading dash of
|
|
101
|
+
POSIX absolute paths is kept (real stores look like -home-user-proj). The
|
|
102
|
+
count is in UTF-16 code units rather than codepoints, so a non-BMP
|
|
103
|
+
character such as an emoji in a folder name costs TWO dashes; passing
|
|
104
|
+
astral_width=1 produces the codepoint-width spelling for older stores.
|
|
105
|
+
|
|
106
|
+
Underscores are NOT universally kept: current versions fold '_' to '-'
|
|
107
|
+
while older stores kept it, and both spellings are live on disk, so
|
|
108
|
+
get_claude_project_dir() probes both.
|
|
109
|
+
"""
|
|
110
|
+
return re.sub(
|
|
111
|
+
r'[^A-Za-z0-9_-]',
|
|
112
|
+
lambda m: '-' * (astral_width if ord(m.group()) > 0xFFFF else 1),
|
|
113
|
+
path_str,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _newest_session_cwd_matches(project_dir: Path, normalized: str) -> bool:
|
|
118
|
+
"""True when a recent session in project_dir records normalized as its cwd."""
|
|
119
|
+
for session in get_sessions_sorted(project_dir)[:3]:
|
|
120
|
+
try:
|
|
121
|
+
with open(session, 'r', encoding='utf-8', errors='replace') as f:
|
|
122
|
+
for _ in range(50):
|
|
123
|
+
line = f.readline()
|
|
124
|
+
if not line:
|
|
125
|
+
break
|
|
126
|
+
match = re.search(r'"cwd"\s*:\s*"((?:[^"\\]|\\.)*)"', line)
|
|
127
|
+
if not match:
|
|
128
|
+
continue
|
|
129
|
+
try:
|
|
130
|
+
cwd = json.loads('"' + match.group(1) + '"')
|
|
131
|
+
except ValueError:
|
|
132
|
+
cwd = match.group(1)
|
|
133
|
+
a = cwd.replace('\\', '/').rstrip('/')
|
|
134
|
+
b = normalized.replace('\\', '/').rstrip('/')
|
|
135
|
+
if os.name == 'nt':
|
|
136
|
+
a, b = a.lower(), b.lower()
|
|
137
|
+
return a == b
|
|
138
|
+
except OSError:
|
|
139
|
+
continue
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def get_claude_project_dir(project_path: str) -> Path:
|
|
144
|
+
"""Resolve Claude Code's project-specific session storage path.
|
|
145
|
+
|
|
146
|
+
Claude Code keeps underscores and the leading dash of POSIX absolute
|
|
147
|
+
paths when it names ~/.claude/projects/ entries. Earlier versions of
|
|
148
|
+
this script guessed a single name with '_' replaced by '-' and the
|
|
149
|
+
leading dash stripped, which silently missed the real store on every
|
|
150
|
+
macOS/Linux install and on any project path containing an underscore.
|
|
151
|
+
The legacy spellings are still probed so stores created under them keep
|
|
152
|
+
working, and ambiguity is settled by the cwd recorded in the newest
|
|
153
|
+
session file.
|
|
154
|
+
"""
|
|
155
|
+
normalized = normalize_path(project_path)
|
|
156
|
+
projects_root = Path.home() / '.claude' / 'projects'
|
|
157
|
+
|
|
158
|
+
primary = _claude_sanitize(normalized)
|
|
159
|
+
candidates = [primary]
|
|
160
|
+
for width in (2, 1):
|
|
161
|
+
exact = _claude_sanitize(normalized, width)
|
|
162
|
+
for spelling in (exact, exact.replace('_', '-')):
|
|
163
|
+
if spelling not in candidates:
|
|
164
|
+
candidates.append(spelling)
|
|
165
|
+
for cand in list(candidates):
|
|
166
|
+
stripped = cand[1:] if cand.startswith('-') else cand
|
|
167
|
+
if stripped and stripped not in candidates:
|
|
168
|
+
candidates.append(stripped)
|
|
169
|
+
|
|
170
|
+
existing = [projects_root / c for c in candidates
|
|
171
|
+
if (projects_root / c).is_dir()]
|
|
172
|
+
if not existing:
|
|
173
|
+
return projects_root / primary
|
|
174
|
+
if len(existing) == 1:
|
|
175
|
+
return existing[0]
|
|
176
|
+
for directory in existing:
|
|
177
|
+
if _newest_session_cwd_matches(directory, normalized):
|
|
178
|
+
return directory
|
|
179
|
+
return existing[0]
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def get_sessions_sorted(project_dir: Path) -> List[Path]:
|
|
183
|
+
"""Get all session files sorted by modification time (newest first)."""
|
|
184
|
+
sessions = list(project_dir.glob('*.jsonl'))
|
|
185
|
+
main_sessions = [s for s in sessions if not s.name.startswith('agent-')]
|
|
186
|
+
return sorted(main_sessions, key=safe_stat_mtime, reverse=True)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def claude_session_cwd(session_file: Path) -> Optional[str]:
|
|
190
|
+
"""The cwd a Claude Code transcript records, or None if it records none."""
|
|
191
|
+
try:
|
|
192
|
+
with open(session_file, 'r', encoding='utf-8', errors='replace') as f:
|
|
193
|
+
for _ in range(50):
|
|
194
|
+
line = f.readline()
|
|
195
|
+
if not line:
|
|
196
|
+
break
|
|
197
|
+
data = json_loads(line)
|
|
198
|
+
if data:
|
|
199
|
+
cwd = data.get('cwd')
|
|
200
|
+
if isinstance(cwd, str) and cwd:
|
|
201
|
+
return cwd
|
|
202
|
+
except OSError:
|
|
203
|
+
return None
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def same_project_path(left: str, right: str) -> bool:
|
|
208
|
+
"""Compare two absolute paths the way the host filesystem would."""
|
|
209
|
+
a, b = normalize_for_compare(left), normalize_for_compare(right)
|
|
210
|
+
if os.name == 'nt':
|
|
211
|
+
a, b = a.lower(), b.lower()
|
|
212
|
+
return a == b
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def filter_sessions_by_cwd(sessions: List[Path], project_path: str) -> Tuple[List[Path], Optional[str]]:
|
|
216
|
+
"""Drop transcripts that positively belong to a different project.
|
|
217
|
+
|
|
218
|
+
Claude Code folds project paths into a single directory name, so two
|
|
219
|
+
projects whose paths differ only in folded characters (client.acme and
|
|
220
|
+
client-acme both fold to client-acme) share one store. Without this
|
|
221
|
+
filter a catchup in one of them prints the other's conversation into the
|
|
222
|
+
fresh context.
|
|
223
|
+
|
|
224
|
+
Fail open: transcripts that record no cwd are kept, because that field is
|
|
225
|
+
not present in every generation of the format, and a store whose sessions
|
|
226
|
+
all record another project is reported rather than silently used.
|
|
227
|
+
Returns (sessions_to_use, notice).
|
|
228
|
+
"""
|
|
229
|
+
project_cmp = normalize_path(project_path)
|
|
230
|
+
mine: List[Path] = []
|
|
231
|
+
unknown: List[Path] = []
|
|
232
|
+
foreign: List[str] = []
|
|
233
|
+
for session in sessions:
|
|
234
|
+
cwd = claude_session_cwd(session)
|
|
235
|
+
if cwd is None:
|
|
236
|
+
unknown.append(session)
|
|
237
|
+
elif same_project_path(cwd, project_cmp):
|
|
238
|
+
mine.append(session)
|
|
239
|
+
else:
|
|
240
|
+
foreign.append(cwd)
|
|
241
|
+
|
|
242
|
+
if mine:
|
|
243
|
+
keep = [s for s in sessions if s in mine or s in unknown]
|
|
244
|
+
return keep, None
|
|
245
|
+
if foreign:
|
|
246
|
+
return [], (
|
|
247
|
+
"[planning-with-files] Session catchup skipped: "
|
|
248
|
+
f"{Path(sorted(set(foreign))[0]).name} and this project share one "
|
|
249
|
+
"~/.claude/projects directory, so no transcript here belongs to "
|
|
250
|
+
f"{project_cmp}."
|
|
251
|
+
)
|
|
252
|
+
return unknown, None
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def safe_stat_mtime(path: Path) -> float:
|
|
256
|
+
try:
|
|
257
|
+
return path.stat().st_mtime
|
|
258
|
+
except OSError:
|
|
259
|
+
return 0.0
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def is_substantial_session(session: Path) -> bool:
|
|
263
|
+
try:
|
|
264
|
+
return session.stat().st_size > MIN_SESSION_BYTES
|
|
265
|
+
except OSError:
|
|
266
|
+
return False
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def read_codex_meta(session_file: Path) -> Optional[Dict[str, Any]]:
|
|
270
|
+
"""Read the first session_meta; later meta records may be copied parent context."""
|
|
271
|
+
try:
|
|
272
|
+
with open(session_file, 'r', encoding='utf-8', errors='replace') as f:
|
|
273
|
+
for line in f:
|
|
274
|
+
data = json_loads(line)
|
|
275
|
+
if not data or data.get('type') != 'session_meta':
|
|
276
|
+
continue
|
|
277
|
+
payload = data.get('payload')
|
|
278
|
+
return payload if isinstance(payload, dict) else None
|
|
279
|
+
except OSError:
|
|
280
|
+
return None
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def codex_meta_cwd(meta: Dict[str, Any]) -> Optional[str]:
|
|
285
|
+
cwd = meta.get('cwd')
|
|
286
|
+
return cwd if isinstance(cwd, str) else None
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def find_current_codex_session(sessions: List[Path]) -> Optional[Path]:
|
|
290
|
+
thread_id = os.getenv('CODEX_THREAD_ID', '').strip()
|
|
291
|
+
if not thread_id:
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
for session in sessions:
|
|
295
|
+
if thread_id in session.name:
|
|
296
|
+
return session
|
|
297
|
+
return None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def is_codex_project_session(session: Path, project_cmp: str) -> bool:
|
|
301
|
+
if not is_substantial_session(session):
|
|
302
|
+
return False
|
|
303
|
+
|
|
304
|
+
meta = read_codex_meta(session)
|
|
305
|
+
if not meta:
|
|
306
|
+
return False
|
|
307
|
+
source = meta.get('source')
|
|
308
|
+
if isinstance(source, dict) and 'subagent' in source:
|
|
309
|
+
return False
|
|
310
|
+
cwd = codex_meta_cwd(meta)
|
|
311
|
+
return bool(cwd and normalize_for_compare(cwd) == project_cmp)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def get_codex_sessions(project_path: str) -> Iterable[Path]:
|
|
315
|
+
sessions_dir = Path(os.path.expanduser(os.getenv('CODEX_SESSIONS_DIR', '~/.codex/sessions')))
|
|
316
|
+
if not sessions_dir.exists():
|
|
317
|
+
return
|
|
318
|
+
|
|
319
|
+
project_cmp = normalize_for_compare(project_path)
|
|
320
|
+
sessions = sorted(sessions_dir.rglob('rollout-*.jsonl'), key=safe_stat_mtime, reverse=True)
|
|
321
|
+
current = find_current_codex_session(sessions)
|
|
322
|
+
if current and is_codex_project_session(current, project_cmp):
|
|
323
|
+
yield current
|
|
324
|
+
|
|
325
|
+
for session in sessions:
|
|
326
|
+
if session == current:
|
|
327
|
+
continue
|
|
328
|
+
if is_codex_project_session(session, project_cmp):
|
|
329
|
+
yield session
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def get_session_candidates(project_path: str) -> Tuple[str, Iterable[Path]]:
|
|
333
|
+
script_path = Path(__file__).resolve().as_posix().lower()
|
|
334
|
+
if '/.codex/' in script_path:
|
|
335
|
+
return 'codex', get_codex_sessions(project_path)
|
|
336
|
+
if '/.opencode/' in script_path:
|
|
337
|
+
# OpenCode dispatch is handled separately via SQLite (v2.38.0+).
|
|
338
|
+
return 'opencode', []
|
|
339
|
+
|
|
340
|
+
claude_project_dir = get_claude_project_dir(project_path)
|
|
341
|
+
if claude_project_dir.exists():
|
|
342
|
+
sessions, notice = filter_sessions_by_cwd(
|
|
343
|
+
get_sessions_sorted(claude_project_dir), project_path
|
|
344
|
+
)
|
|
345
|
+
if notice:
|
|
346
|
+
print(notice)
|
|
347
|
+
return 'claude', sessions
|
|
348
|
+
return 'claude', []
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
PLANNING_LIKE_SQL = ('%task_plan.md', '%findings.md', '%progress.md')
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def get_opencode_db_path() -> Optional[Path]:
|
|
355
|
+
"""Resolve OpenCode SQLite path. Same on all OS per xdg-basedir."""
|
|
356
|
+
xdg = os.environ.get('XDG_DATA_HOME')
|
|
357
|
+
if xdg:
|
|
358
|
+
base = Path(xdg) / 'opencode'
|
|
359
|
+
elif os.environ.get('OPENCODE_DATA_DIR'):
|
|
360
|
+
base = Path(os.environ['OPENCODE_DATA_DIR'])
|
|
361
|
+
else:
|
|
362
|
+
base = Path.home() / '.local' / 'share' / 'opencode'
|
|
363
|
+
db = base / 'opencode.db'
|
|
364
|
+
return db if db.exists() else None
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
# Result excerpts are read from at most RESULT_READ_CAP chars and the emitted
|
|
368
|
+
# line keeps at most RESULT_EXCERPT_CAP chars, so annotated tool lines stay
|
|
369
|
+
# inside the existing injection bounds.
|
|
370
|
+
RESULT_READ_CAP = 200
|
|
371
|
+
RESULT_EXCERPT_CAP = 80
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def result_excerpt(content: Any) -> str:
|
|
375
|
+
"""First non-empty line of a tool result, hard-capped."""
|
|
376
|
+
text = content if isinstance(content, str) else text_content(content)
|
|
377
|
+
for line in text[:RESULT_READ_CAP].splitlines():
|
|
378
|
+
stripped = line.strip()
|
|
379
|
+
if stripped:
|
|
380
|
+
return stripped[:RESULT_EXCERPT_CAP]
|
|
381
|
+
return ''
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def result_annotation(is_error: bool, content: Any) -> str:
|
|
385
|
+
"""Outcome suffix for a tool report line: ' -> ok' on success,
|
|
386
|
+
' -> FAILED (first error line)' on failure."""
|
|
387
|
+
if not is_error:
|
|
388
|
+
return ' -> ok'
|
|
389
|
+
excerpt = result_excerpt(content)
|
|
390
|
+
return f" -> FAILED ({excerpt})" if excerpt else ' -> FAILED'
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _opencode_state_annotation(state: Any) -> str:
|
|
394
|
+
"""Outcome annotation for one OpenCode tool part.
|
|
395
|
+
|
|
396
|
+
Newer OpenCode schemas carry a terminal status plus output/error text on
|
|
397
|
+
part.state. Rows without a terminal status (older schemas, pending or
|
|
398
|
+
running states) must render exactly as before, so this returns '' then.
|
|
399
|
+
"""
|
|
400
|
+
if not isinstance(state, dict):
|
|
401
|
+
return ''
|
|
402
|
+
status = state.get('status')
|
|
403
|
+
if status == 'error':
|
|
404
|
+
source = state.get('error')
|
|
405
|
+
if not isinstance(source, str) or not source.strip():
|
|
406
|
+
source = state.get('output')
|
|
407
|
+
return result_annotation(True, source if isinstance(source, str) else '')
|
|
408
|
+
if status == 'completed':
|
|
409
|
+
return ' -> ok'
|
|
410
|
+
return ''
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _format_opencode_part(data: Dict[str, Any], session_id: str) -> Optional[Dict[str, Any]]:
|
|
414
|
+
"""Print-ready summary for one OpenCode part row."""
|
|
415
|
+
ptype = data.get('type')
|
|
416
|
+
short = session_id[:8] if session_id else '????????'
|
|
417
|
+
if ptype == 'tool':
|
|
418
|
+
tool = (data.get('tool') or '').lower()
|
|
419
|
+
state = data.get('state') or {}
|
|
420
|
+
input_ = state.get('input') if isinstance(state, dict) else None
|
|
421
|
+
input_ = input_ or {}
|
|
422
|
+
outcome = _opencode_state_annotation(state)
|
|
423
|
+
if tool in ('write', 'edit'):
|
|
424
|
+
fp = input_.get('filePath', '')
|
|
425
|
+
return {'session': short, 'summary': f"Tool {tool}: {fp}{outcome}"}
|
|
426
|
+
if tool == 'patch':
|
|
427
|
+
return {'session': short, 'summary': f"Tool patch: {input_.get('filePath', '')}{outcome}"}
|
|
428
|
+
if tool == 'bash':
|
|
429
|
+
cmd = (input_.get('command') or '')[:80]
|
|
430
|
+
return {'session': short, 'summary': f"Tool bash: {cmd}{outcome}"}
|
|
431
|
+
return {'session': short, 'summary': f"Tool {tool}{outcome}"}
|
|
432
|
+
if ptype == 'text':
|
|
433
|
+
text = (data.get('text') or '')[:300]
|
|
434
|
+
if text.strip():
|
|
435
|
+
return {'session': short, 'summary': f"text: {text}"}
|
|
436
|
+
return None
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def opencode_catchup(project_path: str) -> None:
|
|
440
|
+
"""Session catchup for OpenCode SQLite (v2.38.0+).
|
|
441
|
+
|
|
442
|
+
Schema as of sst/opencode dev @ 2026-05-14:
|
|
443
|
+
session (id, directory, time_created, ...)
|
|
444
|
+
part (id, session_id, message_id, time_created, data TEXT JSON)
|
|
445
|
+
"""
|
|
446
|
+
import sqlite3
|
|
447
|
+
|
|
448
|
+
db_path = get_opencode_db_path()
|
|
449
|
+
if not db_path:
|
|
450
|
+
return
|
|
451
|
+
|
|
452
|
+
try:
|
|
453
|
+
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
|
454
|
+
except sqlite3.OperationalError:
|
|
455
|
+
return
|
|
456
|
+
|
|
457
|
+
cur = conn.cursor()
|
|
458
|
+
try:
|
|
459
|
+
cur.execute("PRAGMA table_info(session)")
|
|
460
|
+
session_cols = {row[1] for row in cur.fetchall()}
|
|
461
|
+
cur.execute("PRAGMA table_info(part)")
|
|
462
|
+
part_cols = {row[1] for row in cur.fetchall()}
|
|
463
|
+
except sqlite3.OperationalError:
|
|
464
|
+
conn.close()
|
|
465
|
+
return
|
|
466
|
+
|
|
467
|
+
if 'directory' not in session_cols or 'data' not in part_cols:
|
|
468
|
+
conn.close()
|
|
469
|
+
return
|
|
470
|
+
|
|
471
|
+
project_abs = normalize_for_compare(project_path)
|
|
472
|
+
|
|
473
|
+
cur.execute(
|
|
474
|
+
"SELECT id, time_created FROM session WHERE directory = ? ORDER BY time_created DESC",
|
|
475
|
+
(project_abs,),
|
|
476
|
+
)
|
|
477
|
+
sessions = cur.fetchall()
|
|
478
|
+
if len(sessions) < 2:
|
|
479
|
+
conn.close()
|
|
480
|
+
return
|
|
481
|
+
|
|
482
|
+
previous_sessions = sessions[1:]
|
|
483
|
+
|
|
484
|
+
update_sid = None
|
|
485
|
+
update_time = None
|
|
486
|
+
update_idx = -1
|
|
487
|
+
for idx, (sid, _) in enumerate(previous_sessions):
|
|
488
|
+
params = (sid,) + PLANNING_LIKE_SQL
|
|
489
|
+
cur.execute(
|
|
490
|
+
"""
|
|
491
|
+
SELECT time_created FROM part
|
|
492
|
+
WHERE session_id = ?
|
|
493
|
+
AND json_extract(data, '$.type') = 'tool'
|
|
494
|
+
AND lower(json_extract(data, '$.tool')) IN ('write', 'edit', 'patch')
|
|
495
|
+
AND (
|
|
496
|
+
json_extract(data, '$.state.input.filePath') LIKE ?
|
|
497
|
+
OR json_extract(data, '$.state.input.filePath') LIKE ?
|
|
498
|
+
OR json_extract(data, '$.state.input.filePath') LIKE ?
|
|
499
|
+
)
|
|
500
|
+
ORDER BY time_created DESC
|
|
501
|
+
LIMIT 1
|
|
502
|
+
""",
|
|
503
|
+
params,
|
|
504
|
+
)
|
|
505
|
+
row = cur.fetchone()
|
|
506
|
+
if row:
|
|
507
|
+
update_sid = sid
|
|
508
|
+
update_time = row[0]
|
|
509
|
+
update_idx = idx
|
|
510
|
+
break
|
|
511
|
+
|
|
512
|
+
if not update_sid:
|
|
513
|
+
conn.close()
|
|
514
|
+
return
|
|
515
|
+
|
|
516
|
+
newer_sessions = list(reversed(previous_sessions[:update_idx]))
|
|
517
|
+
|
|
518
|
+
parts: List[Dict[str, Any]] = []
|
|
519
|
+
|
|
520
|
+
cur.execute(
|
|
521
|
+
"SELECT data FROM part WHERE session_id = ? AND time_created > ? ORDER BY time_created ASC, id ASC",
|
|
522
|
+
(update_sid, update_time),
|
|
523
|
+
)
|
|
524
|
+
for (data_str,) in cur.fetchall():
|
|
525
|
+
try:
|
|
526
|
+
data = json.loads(data_str)
|
|
527
|
+
except json.JSONDecodeError:
|
|
528
|
+
continue
|
|
529
|
+
msg = _format_opencode_part(data, update_sid)
|
|
530
|
+
if msg:
|
|
531
|
+
parts.append(msg)
|
|
532
|
+
|
|
533
|
+
for sid, _ in newer_sessions:
|
|
534
|
+
cur.execute(
|
|
535
|
+
"SELECT data FROM part WHERE session_id = ? ORDER BY time_created ASC, id ASC",
|
|
536
|
+
(sid,),
|
|
537
|
+
)
|
|
538
|
+
for (data_str,) in cur.fetchall():
|
|
539
|
+
try:
|
|
540
|
+
data = json.loads(data_str)
|
|
541
|
+
except json.JSONDecodeError:
|
|
542
|
+
continue
|
|
543
|
+
msg = _format_opencode_part(data, sid)
|
|
544
|
+
if msg:
|
|
545
|
+
parts.append(msg)
|
|
546
|
+
|
|
547
|
+
conn.close()
|
|
548
|
+
|
|
549
|
+
if not parts:
|
|
550
|
+
return
|
|
551
|
+
|
|
552
|
+
print(f"\n[planning-with-files] SESSION CATCHUP DETECTED (IDE: opencode)")
|
|
553
|
+
print(f"Last planning update in session {update_sid[:8]}...")
|
|
554
|
+
if update_idx + 1 > 1:
|
|
555
|
+
print(f"Scanning {update_idx + 1} previous sessions for unsynced context")
|
|
556
|
+
print(f"Unsynced parts: {len(parts)}")
|
|
557
|
+
print("\n--- UNSYNCED CONTEXT ---")
|
|
558
|
+
|
|
559
|
+
MAX_PARTS = 100
|
|
560
|
+
if len(parts) > MAX_PARTS:
|
|
561
|
+
print(f"(Showing last {MAX_PARTS} of {len(parts)} parts)\n")
|
|
562
|
+
to_show = parts[-MAX_PARTS:]
|
|
563
|
+
else:
|
|
564
|
+
to_show = parts
|
|
565
|
+
|
|
566
|
+
current_session = None
|
|
567
|
+
for msg in to_show:
|
|
568
|
+
if msg.get('session') != current_session:
|
|
569
|
+
current_session = msg.get('session')
|
|
570
|
+
print(f"\n[Session: {current_session}...]")
|
|
571
|
+
print(f" {msg['summary']}")
|
|
572
|
+
|
|
573
|
+
print("\n--- RECOMMENDED ---")
|
|
574
|
+
print("1. Run: git diff --stat")
|
|
575
|
+
print("2. Read: task_plan.md, progress.md, findings.md")
|
|
576
|
+
print("3. Update planning files based on above context")
|
|
577
|
+
print("4. Continue with task")
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def parse_session_messages(session_file: Path) -> List[Dict[str, Any]]:
|
|
581
|
+
"""Parse all messages from a session file, preserving order."""
|
|
582
|
+
messages = []
|
|
583
|
+
with open(session_file, 'r', encoding='utf-8', errors='replace') as f:
|
|
584
|
+
for line_num, line in enumerate(f):
|
|
585
|
+
data = json_loads(line)
|
|
586
|
+
if data is not None:
|
|
587
|
+
data['_line_num'] = line_num
|
|
588
|
+
messages.append(data)
|
|
589
|
+
return messages
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def planning_file_from_path(path_value: Any) -> Optional[str]:
|
|
593
|
+
if not isinstance(path_value, str):
|
|
594
|
+
return None
|
|
595
|
+
for pf in PLANNING_FILES:
|
|
596
|
+
if path_value.endswith(pf):
|
|
597
|
+
return pf
|
|
598
|
+
return None
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def planning_file_from_paths(paths: Iterable[Any]) -> Optional[str]:
|
|
602
|
+
matches = {pf for path in paths if (pf := planning_file_from_path(path))}
|
|
603
|
+
for pf in PLANNING_FILES:
|
|
604
|
+
if pf in matches:
|
|
605
|
+
return pf
|
|
606
|
+
return None
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def codex_planning_update(payload: Dict[str, Any]) -> Optional[str]:
|
|
610
|
+
"""Use Codex's structured apply_patch result instead of parsing tool text."""
|
|
611
|
+
if payload.get('type') != 'patch_apply_end' or payload.get('success') is not True:
|
|
612
|
+
return None
|
|
613
|
+
changes = payload.get('changes')
|
|
614
|
+
return planning_file_from_paths(changes.keys()) if isinstance(changes, dict) else None
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def find_last_planning_update(messages: List[Dict[str, Any]]) -> Tuple[int, Optional[str]]:
|
|
618
|
+
"""
|
|
619
|
+
Find the last time a planning file was written/edited.
|
|
620
|
+
Returns (line_number, filename) or (-1, None) if not found.
|
|
621
|
+
"""
|
|
622
|
+
last_update_line = -1
|
|
623
|
+
last_update_file = None
|
|
624
|
+
|
|
625
|
+
for msg in messages:
|
|
626
|
+
line_num = msg.get('_line_num')
|
|
627
|
+
if not isinstance(line_num, int):
|
|
628
|
+
continue
|
|
629
|
+
msg_type = msg.get('type')
|
|
630
|
+
|
|
631
|
+
if msg_type == 'assistant':
|
|
632
|
+
content = msg.get('message', {}).get('content', [])
|
|
633
|
+
if isinstance(content, list):
|
|
634
|
+
for item in content:
|
|
635
|
+
if item.get('type') == 'tool_use':
|
|
636
|
+
tool_name = item.get('name', '')
|
|
637
|
+
tool_input = item.get('input', {})
|
|
638
|
+
if not isinstance(tool_input, dict):
|
|
639
|
+
tool_input = {}
|
|
640
|
+
|
|
641
|
+
if tool_name in ('Write', 'Edit'):
|
|
642
|
+
planning_file = planning_file_from_path(tool_input.get('file_path', ''))
|
|
643
|
+
if planning_file:
|
|
644
|
+
last_update_line = line_num
|
|
645
|
+
last_update_file = planning_file
|
|
646
|
+
|
|
647
|
+
elif msg_type == 'event_msg':
|
|
648
|
+
payload = msg.get('payload')
|
|
649
|
+
if isinstance(payload, dict):
|
|
650
|
+
planning_file = codex_planning_update(payload)
|
|
651
|
+
if planning_file:
|
|
652
|
+
last_update_line = line_num
|
|
653
|
+
last_update_file = planning_file
|
|
654
|
+
|
|
655
|
+
return last_update_line, last_update_file
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def text_content(content: Any) -> str:
|
|
659
|
+
if isinstance(content, str):
|
|
660
|
+
return content
|
|
661
|
+
if not isinstance(content, list):
|
|
662
|
+
return ''
|
|
663
|
+
return '\n'.join(
|
|
664
|
+
item.get('text', '')
|
|
665
|
+
for item in content
|
|
666
|
+
if isinstance(item, dict) and isinstance(item.get('text'), str)
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def parse_codex_tool_args(payload: Dict[str, Any]) -> Tuple[Dict[str, Any], str]:
|
|
671
|
+
raw_args = payload.get('arguments', payload.get('input', ''))
|
|
672
|
+
if isinstance(raw_args, dict):
|
|
673
|
+
return raw_args, json.dumps(raw_args, ensure_ascii=True)
|
|
674
|
+
if not isinstance(raw_args, str):
|
|
675
|
+
return {}, ''
|
|
676
|
+
decoded = json_loads(raw_args)
|
|
677
|
+
return (decoded, raw_args) if isinstance(decoded, dict) else ({}, raw_args)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def summarize_codex_tool(payload: Dict[str, Any]) -> str:
|
|
681
|
+
tool_name = payload.get('name', 'tool')
|
|
682
|
+
tool_args, raw_args = parse_codex_tool_args(payload)
|
|
683
|
+
if tool_name == 'exec_command':
|
|
684
|
+
command = tool_args.get('cmd', raw_args)
|
|
685
|
+
if isinstance(command, str):
|
|
686
|
+
return f"exec_command: {command[:80]}"
|
|
687
|
+
return str(tool_name)
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
def collect_claude_tool_results(messages: List[Dict[str, Any]]) -> Dict[str, str]:
|
|
691
|
+
"""Map tool_use id -> outcome annotation from user-side tool_result entries.
|
|
692
|
+
|
|
693
|
+
Claude Code records tool results as user messages whose content list holds
|
|
694
|
+
tool_result items. Sessions without such entries yield an empty map, which
|
|
695
|
+
keeps legacy transcripts byte-identical in the report.
|
|
696
|
+
"""
|
|
697
|
+
results: Dict[str, str] = {}
|
|
698
|
+
for msg in messages:
|
|
699
|
+
if msg.get('type') != 'user':
|
|
700
|
+
continue
|
|
701
|
+
message = msg.get('message')
|
|
702
|
+
if not isinstance(message, dict):
|
|
703
|
+
continue
|
|
704
|
+
content = message.get('content')
|
|
705
|
+
if not isinstance(content, list):
|
|
706
|
+
continue
|
|
707
|
+
for item in content:
|
|
708
|
+
if not isinstance(item, dict) or item.get('type') != 'tool_result':
|
|
709
|
+
continue
|
|
710
|
+
use_id = item.get('tool_use_id')
|
|
711
|
+
if not isinstance(use_id, str) or not use_id:
|
|
712
|
+
continue
|
|
713
|
+
results[use_id] = result_annotation(
|
|
714
|
+
item.get('is_error') is True, item.get('content'))
|
|
715
|
+
return results
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def extract_messages_after(messages: List[Dict[str, Any]], after_line: int) -> List[Dict[str, Any]]:
|
|
719
|
+
"""Extract conversation messages after a certain line number."""
|
|
720
|
+
tool_results = collect_claude_tool_results(messages)
|
|
721
|
+
result = []
|
|
722
|
+
for msg in messages:
|
|
723
|
+
line_num = msg.get('_line_num')
|
|
724
|
+
if not isinstance(line_num, int) or line_num <= after_line:
|
|
725
|
+
continue
|
|
726
|
+
|
|
727
|
+
msg_type = msg.get('type')
|
|
728
|
+
is_meta = msg.get('isMeta', False)
|
|
729
|
+
|
|
730
|
+
if msg_type == 'user' and not is_meta:
|
|
731
|
+
content = text_content(msg.get('message', {}).get('content', ''))
|
|
732
|
+
|
|
733
|
+
if content:
|
|
734
|
+
if content.startswith(('<local-command', '<command-', '<task-notification')):
|
|
735
|
+
continue
|
|
736
|
+
if len(content) > 20:
|
|
737
|
+
result.append({'role': 'user', 'content': content, 'line': line_num})
|
|
738
|
+
|
|
739
|
+
elif msg_type == 'assistant':
|
|
740
|
+
msg_content = msg.get('message', {}).get('content', '')
|
|
741
|
+
text = text_content(msg_content)
|
|
742
|
+
tool_uses = []
|
|
743
|
+
|
|
744
|
+
if isinstance(msg_content, list):
|
|
745
|
+
for item in msg_content:
|
|
746
|
+
if isinstance(item, dict) and item.get('type') == 'tool_use':
|
|
747
|
+
tool_name = item.get('name', '')
|
|
748
|
+
tool_input = item.get('input', {})
|
|
749
|
+
if not isinstance(tool_input, dict):
|
|
750
|
+
tool_input = {}
|
|
751
|
+
use_id = item.get('id')
|
|
752
|
+
# Empty when no tool_result matched: legacy transcripts
|
|
753
|
+
# keep byte-identical lines.
|
|
754
|
+
outcome = (tool_results.get(use_id, '')
|
|
755
|
+
if isinstance(use_id, str) else '')
|
|
756
|
+
if tool_name == 'Edit':
|
|
757
|
+
tool_uses.append(f"Edit: {tool_input.get('file_path', 'unknown')}{outcome}")
|
|
758
|
+
elif tool_name == 'Write':
|
|
759
|
+
tool_uses.append(f"Write: {tool_input.get('file_path', 'unknown')}{outcome}")
|
|
760
|
+
elif tool_name == 'Bash':
|
|
761
|
+
cmd = tool_input.get('command', '')[:80]
|
|
762
|
+
tool_uses.append(f"Bash: {cmd}{outcome}")
|
|
763
|
+
else:
|
|
764
|
+
tool_uses.append(f"{tool_name}{outcome}")
|
|
765
|
+
|
|
766
|
+
if text or tool_uses:
|
|
767
|
+
result.append({
|
|
768
|
+
'role': 'assistant',
|
|
769
|
+
'content': text[:600] if text else '',
|
|
770
|
+
'tools': tool_uses,
|
|
771
|
+
'line': line_num
|
|
772
|
+
})
|
|
773
|
+
|
|
774
|
+
elif msg_type == 'response_item':
|
|
775
|
+
payload = msg.get('payload')
|
|
776
|
+
if not isinstance(payload, dict):
|
|
777
|
+
continue
|
|
778
|
+
|
|
779
|
+
payload_type = payload.get('type')
|
|
780
|
+
if payload_type == 'message':
|
|
781
|
+
role = payload.get('role')
|
|
782
|
+
if role not in ('user', 'assistant'):
|
|
783
|
+
continue
|
|
784
|
+
content = text_content(payload.get('content'))
|
|
785
|
+
if role == 'user':
|
|
786
|
+
if content.startswith(('<local-command', '<command-', '<task-notification')):
|
|
787
|
+
continue
|
|
788
|
+
if len(content) > 20:
|
|
789
|
+
result.append({'role': 'user', 'content': content, 'line': line_num})
|
|
790
|
+
elif content:
|
|
791
|
+
result.append({
|
|
792
|
+
'role': 'assistant',
|
|
793
|
+
'content': content[:600],
|
|
794
|
+
'tools': [],
|
|
795
|
+
'line': line_num
|
|
796
|
+
})
|
|
797
|
+
elif payload_type in ('function_call', 'custom_tool_call'):
|
|
798
|
+
result.append({
|
|
799
|
+
'role': 'assistant',
|
|
800
|
+
'content': '',
|
|
801
|
+
'tools': [summarize_codex_tool(payload)],
|
|
802
|
+
'line': line_num
|
|
803
|
+
})
|
|
804
|
+
|
|
805
|
+
return result
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def main():
|
|
809
|
+
project_path = sys.argv[1] if len(sys.argv) > 1 else os.getcwd()
|
|
810
|
+
|
|
811
|
+
# Check if planning files exist (indicates active task)
|
|
812
|
+
has_planning_files = any(
|
|
813
|
+
Path(project_path, f).exists() for f in PLANNING_FILES
|
|
814
|
+
)
|
|
815
|
+
if not has_planning_files:
|
|
816
|
+
# No planning files in this project; skip catchup to avoid noise.
|
|
817
|
+
return
|
|
818
|
+
|
|
819
|
+
runtime_name, sessions = get_session_candidates(project_path)
|
|
820
|
+
|
|
821
|
+
if runtime_name == 'opencode':
|
|
822
|
+
opencode_catchup(project_path)
|
|
823
|
+
return
|
|
824
|
+
|
|
825
|
+
# Find a substantial previous session
|
|
826
|
+
target_session = None
|
|
827
|
+
for session in sessions:
|
|
828
|
+
if runtime_name == 'claude' and not is_substantial_session(session):
|
|
829
|
+
continue
|
|
830
|
+
target_session = session
|
|
831
|
+
break
|
|
832
|
+
|
|
833
|
+
if not target_session:
|
|
834
|
+
return
|
|
835
|
+
|
|
836
|
+
messages = parse_session_messages(target_session)
|
|
837
|
+
last_update_line, last_update_file = find_last_planning_update(messages)
|
|
838
|
+
|
|
839
|
+
# No planning updates in the target session; skip catchup output.
|
|
840
|
+
if last_update_line < 0:
|
|
841
|
+
return
|
|
842
|
+
|
|
843
|
+
# Only output if there's unsynced content
|
|
844
|
+
messages_after = extract_messages_after(messages, last_update_line)
|
|
845
|
+
|
|
846
|
+
if not messages_after:
|
|
847
|
+
return
|
|
848
|
+
|
|
849
|
+
# Output catchup report
|
|
850
|
+
print("\n[planning-with-files] SESSION CATCHUP DETECTED")
|
|
851
|
+
print(f"Previous session: {target_session.stem}")
|
|
852
|
+
print(f"Runtime: {runtime_name}")
|
|
853
|
+
|
|
854
|
+
print(f"Last planning update: {last_update_file} at message #{last_update_line}")
|
|
855
|
+
print(f"Unsynced messages: {len(messages_after)}")
|
|
856
|
+
|
|
857
|
+
print("\n--- UNSYNCED CONTEXT ---")
|
|
858
|
+
assistant_label = 'CODEX' if runtime_name == 'codex' else 'CLAUDE'
|
|
859
|
+
for msg in messages_after[-15:]: # Last 15 messages
|
|
860
|
+
if msg['role'] == 'user':
|
|
861
|
+
print(f"USER: {msg['content'][:300]}")
|
|
862
|
+
else:
|
|
863
|
+
if msg.get('content'):
|
|
864
|
+
print(f"{assistant_label}: {msg['content'][:300]}")
|
|
865
|
+
if msg.get('tools'):
|
|
866
|
+
print(f" Tools: {', '.join(msg['tools'][:4])}")
|
|
867
|
+
|
|
868
|
+
print("\n--- RECOMMENDED ---")
|
|
869
|
+
print("1. Run: git diff --stat")
|
|
870
|
+
print("2. Read: task_plan.md, progress.md, findings.md")
|
|
871
|
+
print("3. Update planning files based on above context")
|
|
872
|
+
print("4. Continue with task")
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
if __name__ == '__main__':
|
|
876
|
+
main()
|