Coverage for little_loops / hooks / session_start.py: 88%

85 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-05-22 16:16 -0500

1"""SessionStart hook handler: load config + apply ``.ll/ll.local.md`` overrides. 

2 

3Python port of ``hooks/scripts/session-start.sh`` (FEAT-1450). The ``handle`` 

4function is invoked by the dispatcher in 

5``little_loops.hooks.__init__::main_hooks`` after the Claude Code adapter 

6(``hooks/adapters/claude-code/session-start.sh``) reads the host's stdin 

7payload. 

8 

9Observable side-effects preserved from the bash version: 

10 

111. Removes ``.ll/ll-context-state.json`` from the prior session (best-effort). 

122. Resolves the project config via :func:`little_loops.config.core.resolve_config_path`. 

133. If ``.ll/ll.local.md`` exists, parses its YAML frontmatter with 

14 ``yaml.safe_load`` and deep-merges it into the base config — arrays 

15 replace, explicit ``None`` removes keys. 

164. Emits the rendered config JSON via ``LLHookResult.stdout`` so Claude Code 

17 ingests it as session context. 

185. Composes stderr feedback covering the ``Config loaded`` line, the optional 

19 ``Local overrides applied`` line, a ``Warning: Large config`` line when the 

20 rendered config exceeds 5000 chars, the ``Warning: No config found`` line 

21 when neither candidate exists, and feature-flag validation warnings for 

22 ``sync.enabled`` / ``documents.enabled``. 

23 

24The bash version had a stdout/stderr inconsistency for the no-config warning; 

25this port chooses stderr in both branches (per the issue's Step-resolved 

26decision). 

27""" 

28 

29from __future__ import annotations 

30 

31import contextlib 

32import json 

33from pathlib import Path 

34from typing import Any 

35 

36import yaml 

37 

38from little_loops.config.core import deep_merge, resolve_config_path 

39from little_loops.hooks.types import LLHookEvent, LLHookResult 

40 

41_LOCAL_OVERRIDE_FILE = Path(".ll/ll.local.md") 

42_PRIOR_SESSION_STATE = Path(".ll/ll-context-state.json") 

43_LARGE_CONFIG_THRESHOLD = 5000 

44 

45 

46def _parse_frontmatter(content: str) -> dict[str, Any]: 

47 """Extract YAML frontmatter (arbitrary nested shapes) from a markdown doc. 

48 

49 Mirrors the bash version's behaviour: returns ``{}`` for any malformed or 

50 missing frontmatter, and uses ``yaml.safe_load`` so nested dicts / lists / 

51 explicit nulls survive. Not interchangeable with 

52 ``little_loops.frontmatter.parse_frontmatter`` (which is a key:value subset 

53 parser) — local-override frontmatter needs full YAML. 

54 """ 

55 if not content or not content.startswith("---"): 

56 return {} 

57 parts = content.split("---", 2) 

58 if len(parts) < 3: 

59 return {} 

60 text = parts[1].strip() 

61 if not text: 

62 return {} 

63 try: 

64 loaded = yaml.safe_load(text) 

65 except yaml.YAMLError: 

66 return {} 

67 return loaded if isinstance(loaded, dict) else {} 

68 

69 

70def handle(event: LLHookEvent) -> LLHookResult: 

71 """Build the merged session-start config and validate feature flags. 

72 

73 Returns ``LLHookResult(exit_code=0, feedback=<stderr>, stdout=<config-json>)``. 

74 """ 

75 del event # SessionStart consumes no payload fields today; cwd is implicit (os.getcwd()). 

76 

77 cwd = Path.cwd() 

78 feedback_lines: list[str] = [] 

79 

80 # 1. Clean up prior-session state (best-effort, suppress all errors). 

81 with contextlib.suppress(OSError): 

82 _PRIOR_SESSION_STATE.unlink() 

83 

84 # 2. Resolve base config. 

85 config_path = resolve_config_path(cwd) 

86 base_config: dict[str, Any] = {} 

87 if config_path is not None: 

88 try: 

89 base_config = json.loads(config_path.read_text(encoding="utf-8")) 

90 except (OSError, json.JSONDecodeError): 

91 base_config = {} 

92 

93 # 3. Apply local overrides if present. 

94 local_file = cwd / _LOCAL_OVERRIDE_FILE 

95 overrides_applied = False 

96 merged_config: dict[str, Any] = base_config 

97 if local_file.is_file(): 

98 try: 

99 override_text = local_file.read_text(encoding="utf-8") 

100 except OSError: 

101 override_text = "" 

102 local_overrides = _parse_frontmatter(override_text) 

103 if local_overrides: 

104 merged_config = deep_merge(base_config, local_overrides) 

105 overrides_applied = True 

106 

107 # 3b. Bootstrap the unified session store (FEAT-1112). Best-effort and only 

108 # for initialized projects — uninitialized projects (no config) are a no-op 

109 # so the hook never creates a stray .ll/ directory. 

110 if config_path is not None: 

111 with contextlib.suppress(Exception): 

112 from little_loops.session_store import ensure_db 

113 

114 ensure_db(cwd / ".ll" / "session.db") 

115 

116 # 4. Compose the rendered stdout payload. 

117 if config_path is not None and not overrides_applied: 

118 # Match bash: preserve original on-disk formatting when no overrides. 

119 try: 

120 stdout_payload: str | None = config_path.read_text(encoding="utf-8") 

121 except OSError: 

122 stdout_payload = json.dumps(merged_config, indent=2) 

123 elif config_path is not None or overrides_applied: 

124 stdout_payload = json.dumps(merged_config, indent=2) 

125 else: 

126 stdout_payload = None 

127 

128 # 5. Compose feedback (stderr). 

129 if config_path is not None: 

130 # Match bash output format: print() uses a space separator, not a colon-space. 

131 feedback_lines.append(f"[little-loops] Config loaded: {config_path}") 

132 if overrides_applied: 

133 feedback_lines.append(f"[little-loops] Local overrides applied from: {local_file}") 

134 if stdout_payload is not None and len(stdout_payload) > _LARGE_CONFIG_THRESHOLD: 

135 feedback_lines.append( 

136 f"[little-loops] Warning: Large config ({len(stdout_payload)} chars)" 

137 ) 

138 else: 

139 feedback_lines.append( 

140 "[little-loops] Warning: No config found. Run /ll:init to create one." 

141 ) 

142 

143 # 6. Feature-flag validation warnings. 

144 feedback_lines.extend(_validate_features(merged_config)) 

145 

146 feedback = "\n".join(feedback_lines) if feedback_lines else None 

147 return LLHookResult(exit_code=0, feedback=feedback, stdout=stdout_payload) 

148 

149 

150def _validate_features(config: dict[str, Any]) -> list[str]: 

151 """Return stderr warning lines for misconfigured enabled features. 

152 

153 Mirrors ``validate_enabled_features`` in the bash version exactly: 

154 

155 - ``sync.enabled: true`` with empty ``sync.github`` → warn. 

156 - ``documents.enabled: true`` with empty ``documents.categories`` → warn. 

157 

158 Other ``*.enabled`` flags (e.g. ``product.enabled``) are intentionally not 

159 validated — the existing ``TestSessionStartValidation`` test fixture 

160 enables ``product`` and asserts no ``Warning:`` substring appears. 

161 """ 

162 warnings_out: list[str] = [] 

163 sync = config.get("sync") if isinstance(config.get("sync"), dict) else {} 

164 if isinstance(sync, dict) and sync.get("enabled") is True: 

165 github = sync.get("github") 

166 if not isinstance(github, dict) or not github: 

167 warnings_out.append( 

168 "[little-loops] Warning: sync.enabled is true but sync.github is not configured" 

169 ) 

170 documents = config.get("documents") if isinstance(config.get("documents"), dict) else {} 

171 if isinstance(documents, dict) and documents.get("enabled") is True: 

172 categories = documents.get("categories") 

173 if not isinstance(categories, dict) or not categories: 

174 warnings_out.append( 

175 "[little-loops] Warning: documents.enabled is true but no document categories" 

176 " configured" 

177 ) 

178 return warnings_out