Coverage for little_loops / hooks / pre_compact_handoff.py: 0%

113 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-26 17:38 -0500

1"""PreCompact handoff hook handler: write a session continuation prompt before context compaction. 

2 

3``handle`` fires on every PreCompact event and writes ``.ll/ll-continue-prompt.md`` 

4(≤2KB, priority-tiered) so ``/ll:resume`` can restore session state after compaction. 

5 

6An idempotency guard skips the write when the prompt is already fresher than the 

7``compacted_at`` timestamp written by the preceding ``pre_compact`` handler. 

8 

9When ``.ll/ll-session-events.jsonl`` is present (written by FEAT-1262's session-capture.sh), 

10``_build_from_event_log()`` is used: file edits are deduplicated by subject, only unresolved 

11errors are included, and task state reflects the last event per subject. Otherwise 

12``_build_fallback()`` reconstructs state from git diff and loop run snapshots. 

13 

14The wire-visible output is ``.ll/ll-continue-prompt.md``; its schema (``## Intent`` + 

15``## Next Steps`` headings) is detected by ``commands/resume.md:56–68``. 

16""" 

17 

18from __future__ import annotations 

19 

20import json 

21import subprocess 

22from datetime import UTC, datetime 

23from pathlib import Path 

24from typing import Any 

25 

26from little_loops.file_utils import acquire_lock, atomic_write 

27from little_loops.hooks.types import LLHookEvent, LLHookResult 

28 

29_FEEDBACK = "[ll] Session handoff snapshot written." 

30 

31 

32def _build_content(sections: list[str], max_bytes: int = 2048) -> str: 

33 """Join *sections* with double newlines, dropping trailing entries LIFO until ≤ *max_bytes*. 

34 

35 Mutates *sections* in place (callers pass a freshly-built list each time). 

36 Returns the joined string; returns empty string when *sections* is empty. 

37 """ 

38 while len("\n\n".join(sections).encode()) > max_bytes: 

39 if len(sections) <= 1: 

40 break 

41 sections.pop() 

42 return "\n\n".join(sections) 

43 

44 

45def _build_from_event_log(log_path: Path) -> list[str]: 

46 """Build priority-tiered markdown sections from structured JSONL session events. 

47 

48 Returns sections in the same format as ``_build_fallback()`` so the existing 

49 ``_build_content()`` size-capping logic applies unchanged. 

50 

51 Event schema (written by session-capture.sh, FEAT-1262): 

52 {"ts": "ISO8601", "type": "file|task|git|error", "op": "...", "subject": "...", "status": ""} 

53 """ 

54 from little_loops.events import EventBus 

55 

56 events = EventBus.read_events(log_path) 

57 

58 # File edits: last event per subject wins (deduplication; reverts naturally excluded) 

59 file_edits: dict[str, str] = {} 

60 for ev in events: 

61 if ev.type == "file": 

62 subject = ev.payload.get("subject", "") 

63 if subject: 

64 file_edits[subject] = subject 

65 

66 # Error resolution heuristic (canonical definition in FEAT-1262 § Event Semantics): 

67 # track the last event for each subject across ALL event types. If the last event 

68 # for a given subject still has type=error, the error is unresolved. 

69 last_by_subject: dict[str, Any] = {} 

70 for ev in events: 

71 subject = ev.payload.get("subject", "") 

72 if subject: 

73 last_by_subject[subject] = ev 

74 unresolved_errors = [ev for ev in last_by_subject.values() if ev.type == "error"] 

75 

76 # Tasks: net state — last event per subject wins 

77 task_state: dict[str, Any] = {} 

78 for ev in events: 

79 if ev.type == "task": 

80 subject = ev.payload.get("subject", "") 

81 if subject: 

82 task_state[subject] = ev 

83 

84 files_md = "\n".join(f"- {s}" for s in file_edits) or "(none)" 

85 errors_md = ( 

86 "\n".join(f"- {ev.payload.get('subject', '?')}" for ev in unresolved_errors) or "(none)" 

87 ) 

88 tasks_md = ( 

89 "\n".join( 

90 f"- {ev.payload.get('subject', '?')} [{ev.payload.get('status', '')}]" 

91 for ev in task_state.values() 

92 ) 

93 or "(none)" 

94 ) 

95 

96 return [ 

97 f"## File Modifications\n{files_md}", 

98 f"## Unresolved Errors\n{errors_md}", 

99 f"## Task State\n{tasks_md}", 

100 ] 

101 

102 

103def _build_fallback() -> list[str]: 

104 """Build sections from git diff and loop state when no event log is available (FEAT-1156 path).""" 

105 try: 

106 r = subprocess.run( 

107 ["git", "diff", "--name-only", "HEAD"], 

108 capture_output=True, 

109 text=True, 

110 timeout=5, 

111 ) 

112 files_text = r.stdout.strip() if r.returncode == 0 else "" 

113 except (FileNotFoundError, subprocess.TimeoutExpired): 

114 files_text = "" 

115 

116 loop_state_lines: list[str] = [] 

117 try: 

118 runs_dir = Path(".loops/runs") 

119 if runs_dir.is_dir(): 

120 run_dirs = sorted( 

121 (d for d in runs_dir.glob("*/") if d.is_dir()), 

122 key=lambda p: p.stat().st_mtime, 

123 reverse=True, 

124 ) 

125 for rd in run_dirs[:3]: 

126 for jf in list(rd.glob("*.json"))[:1]: 

127 try: 

128 data = json.loads(jf.read_text(encoding="utf-8")) 

129 keys = list(data.keys())[:3] 

130 loop_state_lines.append(f"- {rd.name}: {keys}") 

131 except (OSError, json.JSONDecodeError): 

132 continue 

133 except OSError: 

134 pass 

135 loop_state_text = "\n".join(loop_state_lines) 

136 

137 sections = [] 

138 files_body = files_text if files_text else "No uncommitted file changes." 

139 sections.append(f"## File Modifications\n{files_body}") 

140 

141 decisions_parts: list[str] = [] 

142 if loop_state_text: 

143 decisions_parts.append(f"Loop state:\n{loop_state_text}") 

144 decisions_body = ( 

145 "\n".join(decisions_parts) if decisions_parts else "No open decisions recorded." 

146 ) 

147 sections.append(f"## Decisions Made\n{decisions_body}") 

148 

149 return sections 

150 

151 

152def handle(event: LLHookEvent) -> LLHookResult: 

153 """Write .ll/ll-continue-prompt.md before context compaction. 

154 

155 Returns ``LLHookResult(exit_code=2, feedback=...)`` on successful write, 

156 ``LLHookResult(exit_code=0)`` on idempotency skip or any error. 

157 """ 

158 try: 

159 state_dir = Path(".ll") 

160 prompt_path = state_dir / "ll-continue-prompt.md" 

161 lock_path = prompt_path.with_suffix(".md.lock") 

162 state_file = state_dir / "ll-precompact-state.json" 

163 event_log = state_dir / "ll-session-events.jsonl" 

164 

165 # Idempotency guard: skip write if prompt is already fresher than compacted_at. 

166 # Any parse error means we cannot verify freshness → proceed with write. 

167 try: 

168 compacted_at_str = json.loads(state_file.read_text(encoding="utf-8"))["compacted_at"] 

169 compacted_epoch = datetime.fromisoformat( 

170 compacted_at_str.replace("Z", "+00:00") 

171 ).timestamp() 

172 if prompt_path.stat().st_mtime > compacted_epoch: 

173 return LLHookResult(exit_code=0) 

174 except (OSError, json.JSONDecodeError, KeyError, ValueError): 

175 pass 

176 

177 # --- Header data (common to both paths) --- 

178 

179 # Active in-progress issues 

180 try: 

181 r = subprocess.run( 

182 ["ll-issues", "list", "--status", "in_progress"], 

183 capture_output=True, 

184 text=True, 

185 timeout=5, 

186 ) 

187 active_issues_text = r.stdout.strip() if r.returncode == 0 else "" 

188 except (FileNotFoundError, subprocess.TimeoutExpired): 

189 active_issues_text = "" 

190 

191 # Current branch name 

192 try: 

193 r = subprocess.run( 

194 ["git", "rev-parse", "--abbrev-ref", "HEAD"], 

195 capture_output=True, 

196 text=True, 

197 timeout=5, 

198 ) 

199 session_branch = r.stdout.strip() if r.returncode == 0 else "unknown" 

200 except (FileNotFoundError, subprocess.TimeoutExpired): 

201 session_branch = "unknown" 

202 

203 # --- Build header (Section 0: always kept) --- 

204 

205 session_date = datetime.now(UTC).strftime("%Y-%m-%d") 

206 issues_summary = active_issues_text[:200] if active_issues_text else "none" 

207 intent_body = active_issues_text if active_issues_text else "No in-progress issues." 

208 

209 header = ( 

210 f"---\n" 

211 f"session_date: {session_date}\n" 

212 f"session_branch: {session_branch}\n" 

213 f"issues_in_progress: {issues_summary}\n" 

214 f"---\n\n" 

215 f"## Intent\n{intent_body}\n\n" 

216 f"## Next Steps\nResume active work on the issues above." 

217 ) 

218 

219 sections = [header] 

220 

221 # --- Primary path: structured event log (FEAT-1262) --- 

222 # Fallback path: git diff + loop state (FEAT-1156) 

223 if event_log.is_file(): 

224 sections.extend(_build_from_event_log(event_log)) 

225 else: 

226 sections.extend(_build_fallback()) 

227 

228 # Apply LIFO 2KB cap and write 

229 state_dir.mkdir(parents=True, exist_ok=True) 

230 content = _build_content(sections, max_bytes=2048) 

231 

232 try: 

233 with acquire_lock(lock_path, timeout=3.0): 

234 atomic_write(prompt_path, content) 

235 except TimeoutError: 

236 atomic_write(prompt_path, content) 

237 

238 except Exception: 

239 return LLHookResult(exit_code=0) 

240 

241 return LLHookResult(exit_code=2, feedback=_FEEDBACK)