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
« 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.
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`.
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"""
14from __future__ import annotations
16import json
17import time
18from datetime import UTC, datetime
19from pathlib import Path
20from typing import Any
22from little_loops.file_utils import acquire_lock, atomic_write_json
23from little_loops.hooks.types import LLHookEvent, LLHookResult
25_FEEDBACK = (
26 "[ll] Task state preserved before context compaction. "
27 "Check .ll/ll-precompact-state.json if resuming work."
28)
31def handle(event: LLHookEvent) -> LLHookResult:
32 """Preserve task state before context compaction.
34 Writes ``.ll/ll-precompact-state.json`` atomically (with a 3s advisory
35 lock + best-effort fallback) so post-compact resume logic can find:
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)
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 ""
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"
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 }
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
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"] = []
93 if continue_prompt.exists():
94 state["continue_prompt_exists"] = True
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)
104 return LLHookResult(exit_code=2, feedback=_FEEDBACK)