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

6 An 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

9 When ``.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

11 errors 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

14 The 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

18 from __future__ import annotations

19

20 import json

21 import subprocess

22 from datetime import UTC, datetime

23 from pathlib import Path

24 from typing import Any

25

26 from little_loops.file_utils import acquire_lock, atomic_write

27 from little_loops.hooks.types import LLHookEvent, LLHookResult

28

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

30

31

32 def _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

45 def _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

103 def _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

152 def 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)