Coverage for little_loops / hooks / types.py: 100%
42 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"""Hook intent types: host-agnostic dataclasses for hook events and results.
3`LLHookEvent` and `LLHookResult` are the wire format for the hook-intent
4abstraction layer (FEAT-1116). Per-host adapters parse the host's native
5hook payload into `LLHookEvent`, invoke a core intent handler, and translate
6the returned `LLHookResult` back into the host's expected response (exit
7code + stderr for Claude Code; structured object for OpenCode; etc.).
9Sibling type to ``little_loops.events.LLEvent`` — see ``docs/reference/EVENT-SCHEMA.md``.
10``LLEvent`` is pub/sub fire-and-forget; hook intents are request/response,
11so they live on a separate dataclass with its own ``to_dict``/``from_dict``.
12"""
14from __future__ import annotations
16from dataclasses import dataclass, field
17from typing import Any
20@dataclass
21class LLHookEvent:
22 """Host-agnostic hook event payload.
24 Attributes:
25 host: Host agent identifier (e.g. ``"claude-code"``, ``"opencode"``).
26 Required — adapters set this to identify the source host so that
27 core handlers can branch on host-specific quirks if needed.
28 intent: Hook intent name (e.g. ``"pre_compact"``, ``"session_start"``,
29 ``"pre_tool_use"``). Matches the module under
30 ``scripts/little_loops/hooks/`` that handles this intent.
31 timestamp: ISO 8601 timestamp string (UTC) for when the host emitted
32 the hook event.
33 payload: Host-supplied event data (tool name, args, session_id, cwd,
34 etc.). Schema is intent-specific and documented per-intent.
35 session_id: Host session identifier when available. Optional because
36 not every hook intent carries a session.
37 cwd: Working directory the host was operating in. Optional.
38 """
40 host: str
41 intent: str = ""
42 timestamp: str = ""
43 payload: dict[str, Any] = field(default_factory=dict)
44 session_id: str | None = None
45 cwd: str | None = None
47 def to_dict(self) -> dict[str, Any]:
48 """Serialize to a flat dict for JSON transport.
50 Skips ``None``-valued optional fields so the wire format stays
51 compact and unambiguous (a missing key means "not provided" rather
52 than "explicitly null").
53 """
54 out: dict[str, Any] = {
55 "host": self.host,
56 "intent": self.intent,
57 "ts": self.timestamp,
58 "payload": self.payload,
59 }
60 if self.session_id is not None:
61 out["session_id"] = self.session_id
62 if self.cwd is not None:
63 out["cwd"] = self.cwd
64 return out
66 @classmethod
67 def from_dict(cls, data: dict[str, Any]) -> LLHookEvent:
68 """Reconstruct from a flat dict (JSON deserialization).
70 Accepts both wire-format keys (``ts``) and field-name aliases
71 (``timestamp``) for robustness, matching the precedent set by
72 ``LLEvent.from_dict``.
73 """
74 return cls(
75 host=data.get("host", ""),
76 intent=data.get("intent", ""),
77 timestamp=data.get("ts", data.get("timestamp", "")),
78 payload=data.get("payload", {}) or {},
79 session_id=data.get("session_id"),
80 cwd=data.get("cwd"),
81 )
84@dataclass
85class LLHookResult:
86 """Host-agnostic hook handler response.
88 Adapters translate this into the host's expected reply shape — exit
89 code + stderr for Claude Code shell hooks, a structured object for
90 OpenCode TS plugins, etc.
92 Attributes:
93 exit_code: Numeric exit code semantics borrowed from Claude Code:
94 ``0`` means pass, ``2`` means "block and inject ``feedback`` into
95 the model's context". Non-Claude hosts map this to their own
96 permit/deny semantics.
97 feedback: Optional human-readable message. For Claude Code, this is
98 written to stderr when ``exit_code == 2``. For permission-style
99 intents (PreToolUse), this is the rationale for the decision.
100 decision: Optional permission decision for permission-checking
101 intents (e.g. ``"allow"``, ``"deny"``, ``"ask"``). ``None`` for
102 intents that don't make permission decisions.
103 data: Additional structured data the handler wants returned to the
104 host (e.g. stdout JSON for Claude Code's structured-response
105 intents). Default empty dict.
106 stdout: Optional raw payload to be written to the host's stdout stream
107 (e.g. SessionStart's merged config JSON, which Claude Code ingests
108 as session context). ``None`` means "write nothing to stdout".
109 Adapters that don't model a stdout channel may ignore this field.
110 """
112 exit_code: int = 0
113 feedback: str | None = None
114 decision: str | None = None
115 data: dict[str, Any] = field(default_factory=dict)
116 stdout: str | None = None
118 def to_dict(self) -> dict[str, Any]:
119 """Serialize to a flat dict for JSON transport.
121 Skips ``None``-valued optional fields and the empty ``data`` dict
122 so the wire format stays compact.
123 """
124 out: dict[str, Any] = {"exit_code": self.exit_code}
125 if self.feedback is not None:
126 out["feedback"] = self.feedback
127 if self.decision is not None:
128 out["decision"] = self.decision
129 if self.data:
130 out["data"] = self.data
131 if self.stdout is not None:
132 out["stdout"] = self.stdout
133 return out
135 @classmethod
136 def from_dict(cls, data: dict[str, Any]) -> LLHookResult:
137 """Reconstruct from a flat dict (JSON deserialization)."""
138 return cls(
139 exit_code=data.get("exit_code", 0),
140 feedback=data.get("feedback"),
141 decision=data.get("decision"),
142 data=data.get("data", {}) or {},
143 stdout=data.get("stdout"),
144 )