1"""Hook intent types: host-agnostic dataclasses for hook events and results.

2

3`LLHookEvent` and `LLHookResult` are the wire format for the hook-intent

4 abstraction layer (FEAT-1116). Per-host adapters parse the host's native

5 hook payload into `LLHookEvent`, invoke a core intent handler, and translate

6 the returned `LLHookResult` back into the host's expected response (exit

7 code + stderr for Claude Code; structured object for OpenCode; etc.).

8

9 Sibling 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,

11 so they live on a separate dataclass with its own ``to_dict``/``from_dict``.

12"""

13

14 from __future__ import annotations

15

16 from dataclasses import dataclass, field

17 from typing import Any

18

19

20@dataclass

21 class LLHookEvent:

22 """Host-agnostic hook event payload.

23

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 """

39

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

46

47 def to_dict(self) -> dict[str, Any]:

48 """Serialize to a flat dict for JSON transport.

49

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

65

66 @classmethod

67 def from_dict(cls, data: dict[str, Any]) -> LLHookEvent:

68 """Reconstruct from a flat dict (JSON deserialization).

69

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 )

82

83

84@dataclass

85 class LLHookResult:

86 """Host-agnostic hook handler response.

87

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.

91

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 """

111

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

117

118 def to_dict(self) -> dict[str, Any]:

119 """Serialize to a flat dict for JSON transport.

120

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

134

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 )