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
« 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.
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.
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.
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.
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"""
18from __future__ import annotations
20import json
21import subprocess
22from datetime import UTC, datetime
23from pathlib import Path
24from typing import Any
26from little_loops.file_utils import acquire_lock, atomic_write
27from little_loops.hooks.types import LLHookEvent, LLHookResult
29_FEEDBACK = "[ll] Session handoff snapshot written."
32def _build_content(sections: list[str], max_bytes: int = 2048) -> str:
33 """Join *sections* with double newlines, dropping trailing entries LIFO until ≤ *max_bytes*.
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)
45def _build_from_event_log(log_path: Path) -> list[str]:
46 """Build priority-tiered markdown sections from structured JSONL session events.
48 Returns sections in the same format as ``_build_fallback()`` so the existing
49 ``_build_content()`` size-capping logic applies unchanged.
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
56 events = EventBus.read_events(log_path)
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
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"]
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
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 )
96 return [
97 f"## File Modifications\n{files_md}",
98 f"## Unresolved Errors\n{errors_md}",
99 f"## Task State\n{tasks_md}",
100 ]
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 = ""
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)
137 sections = []
138 files_body = files_text if files_text else "No uncommitted file changes."
139 sections.append(f"## File Modifications\n{files_body}")
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}")
149 return sections
152def handle(event: LLHookEvent) -> LLHookResult:
153 """Write .ll/ll-continue-prompt.md before context compaction.
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"
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
177 # --- Header data (common to both paths) ---
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 = ""
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"
203 # --- Build header (Section 0: always kept) ---
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."
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 )
219 sections = [header]
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())
228 # Apply LIFO 2KB cap and write
229 state_dir.mkdir(parents=True, exist_ok=True)
230 content = _build_content(sections, max_bytes=2048)
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)
238 except Exception:
239 return LLHookResult(exit_code=0)
241 return LLHookResult(exit_code=2, feedback=_FEEDBACK)