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

2

3 Python 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

7 payload into an :class:`LLHookEvent`.

8

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

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

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

12"""

13

14 from __future__ import annotations

15

16 import json

17 import time

18 from datetime import UTC, datetime

19 from pathlib import Path

20 from typing import Any

21

22 from little_loops.file_utils import acquire_lock, atomic_write_json

23 from 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

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