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

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 

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.). 

8 

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

13 

14from __future__ import annotations 

15 

16from dataclasses import dataclass, field 

17from typing import Any 

18 

19 

20@dataclass 

21class 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 

85class 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 )