Coverage for little_loops / hooks / pre_compact.py: 83%

48 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-05-22 16:19 -0500

1"""PreCompact hook handler: preserve task state before context compaction. 

2 

3Python port of ``hooks/scripts/precompact-state.sh`` (FEAT-1449). The 

4``handle`` function is invoked by the dispatcher in 

5``little_loops.hooks.__init__::main_hooks`` after the Claude Code adapter 

6(``hooks/adapters/claude-code/precompact.sh``) parses the host's stdin 

7payload into an :class:`LLHookEvent`. 

8 

9The wire-visible output is ``.ll/ll-precompact-state.json``; its shape is 

10read by ``hooks/scripts/context-monitor.sh::check_compaction`` (only the 

11``compacted_at`` key) and by resume-prompt logic (the optional keys). 

12""" 

13 

14from __future__ import annotations 

15 

16import json 

17import time 

18from datetime import UTC, datetime 

19from pathlib import Path 

20from typing import Any 

21 

22from little_loops.file_utils import acquire_lock, atomic_write_json 

23from little_loops.hooks.types import LLHookEvent, LLHookResult 

24 

25_FEEDBACK = ( 

26 "[ll] Task state preserved before context compaction. " 

27 "Check .ll/ll-precompact-state.json if resuming work." 

28) 

29 

30 

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

32 """Preserve task state before context compaction. 

33 

34 Writes ``.ll/ll-precompact-state.json`` atomically (with a 3s advisory 

35 lock + best-effort fallback) so post-compact resume logic can find: 

36 

37 - ``compacted_at`` — UTC ISO 8601 timestamp (consumed by 

38 ``context-monitor.sh::check_compaction``) 

39 - ``transcript_path`` — from the host payload, or ``""`` 

40 - ``preserved: true`` 

41 - ``context_state_at_compact`` — merged from ``.ll/ll-context-state.json`` 

42 when that file exists 

43 - ``recent_plan_files`` — up to 5 paths under ``thoughts/shared/plans/`` 

44 modified in the last 24h (filesystem-iteration order, matching the 

45 shell ``find ... | head -5`` semantics) 

46 - ``continue_prompt_exists: true`` — key present **only** when 

47 ``.ll/ll-continue-prompt.md`` exists (key omitted otherwise, matching 

48 the shell ``jq '. + {continue_prompt_exists: true}'`` branch) 

49 

50 Returns ``LLHookResult(exit_code=2, ...)`` so Claude Code surfaces the 

51 feedback string to the user via stderr. 

52 """ 

53 try: 

54 payload = event.payload or {} 

55 transcript_path = payload.get("transcript_path") or "" 

56 

57 state_dir = Path(".ll") 

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

59 state_lock = state_dir / "ll-precompact-state.json.lock" 

60 context_state_file = state_dir / "ll-context-state.json" 

61 plans_dir = Path("thoughts/shared/plans") 

62 continue_prompt = state_dir / "ll-continue-prompt.md" 

63 

64 state: dict[str, Any] = { 

65 "compacted_at": datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ"), 

66 "transcript_path": transcript_path, 

67 "preserved": True, 

68 } 

69 

70 if context_state_file.is_file(): 

71 try: 

72 state["context_state_at_compact"] = json.loads( 

73 context_state_file.read_text(encoding="utf-8") 

74 ) 

75 except (OSError, json.JSONDecodeError): 

76 pass 

77 

78 if plans_dir.is_dir(): 

79 cutoff = time.time() - 86400 

80 recent: list[str] = [] 

81 for p in plans_dir.glob("*.md"): 

82 try: 

83 if p.stat().st_mtime > cutoff: 

84 recent.append(str(p)) 

85 except OSError: 

86 continue 

87 if len(recent) >= 5: 

88 break 

89 state["recent_plan_files"] = recent 

90 else: 

91 state["recent_plan_files"] = [] 

92 

93 if continue_prompt.exists(): 

94 state["continue_prompt_exists"] = True 

95 

96 try: 

97 with acquire_lock(state_lock, timeout=3.0): 

98 atomic_write_json(state_file, state) 

99 except TimeoutError: 

100 atomic_write_json(state_file, state) 

101 except Exception: 

102 return LLHookResult(exit_code=0) 

103 

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