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.
Files changed (32) hide show
  1. package/README.md +131 -0
  2. package/SKILL.md +262 -0
  3. package/examples.md +202 -0
  4. package/extensions/planning-with-files/README.md +35 -0
  5. package/extensions/planning-with-files/__tests__/attestation.test.ts +79 -0
  6. package/extensions/planning-with-files/__tests__/plan-anchor.test.ts +228 -0
  7. package/extensions/planning-with-files/__tests__/runtime.test.ts +688 -0
  8. package/extensions/planning-with-files/attestation.ts +55 -0
  9. package/extensions/planning-with-files/constants.ts +31 -0
  10. package/extensions/planning-with-files/index.ts +6 -0
  11. package/extensions/planning-with-files/package.json +17 -0
  12. package/extensions/planning-with-files/plan.ts +263 -0
  13. package/extensions/planning-with-files/runtime.ts +788 -0
  14. package/package.json +46 -0
  15. package/reference.md +218 -0
  16. package/scripts/attest-plan.ps1 +137 -0
  17. package/scripts/attest-plan.sh +206 -0
  18. package/scripts/check-complete.ps1 +253 -0
  19. package/scripts/check-complete.sh +253 -0
  20. package/scripts/init-session.ps1 +230 -0
  21. package/scripts/init-session.sh +370 -0
  22. package/scripts/plan-doctor.sh +148 -0
  23. package/scripts/resolve-plan-dir.ps1 +106 -0
  24. package/scripts/resolve-plan-dir.sh +263 -0
  25. package/scripts/session-catchup.py +876 -0
  26. package/scripts/set-active-plan.ps1 +51 -0
  27. package/scripts/set-active-plan.sh +50 -0
  28. package/templates/analytics_findings.md +85 -0
  29. package/templates/analytics_task_plan.md +106 -0
  30. package/templates/findings.md +95 -0
  31. package/templates/progress.md +114 -0
  32. 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()