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

125 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-29 00:54 -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 

33import logging 

34import subprocess 

35import sys 

36from pathlib import Path 

37from typing import Any 

38 

39import yaml 

40 

41from little_loops.config.core import deep_merge, resolve_config_path 

42from little_loops.hooks.types import LLHookEvent, LLHookResult 

43 

44logger = logging.getLogger(__name__) 

45 

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

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

48_LARGE_CONFIG_THRESHOLD = 5000 

49 

50 

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

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

53 

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

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

56 explicit nulls survive. Not interchangeable with 

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

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

59 """ 

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

61 return {} 

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

63 if len(parts) < 3: 

64 return {} 

65 text = parts[1].strip() 

66 if not text: 

67 return {} 

68 try: 

69 loaded = yaml.safe_load(text) 

70 except yaml.YAMLError: 

71 return {} 

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

73 

74 

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

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

77 

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

79 """ 

80 # ENH-1945: _run_backfill() consumes transcript_path from the payload for 

81 # non-Claude-Code hosts (Codex, OpenCode). The event object must be preserved 

82 # so the backfill daemon thread can read it. 

83 

84 cwd = Path.cwd() 

85 feedback_lines: list[str] = [] 

86 

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

88 with contextlib.suppress(OSError): 

89 _PRIOR_SESSION_STATE.unlink() 

90 

91 # 2. Resolve base config. 

92 config_path = resolve_config_path(cwd) 

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

94 if config_path is not None: 

95 try: 

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

97 except (OSError, json.JSONDecodeError): 

98 base_config = {} 

99 

100 # 3. Apply local overrides if present. 

101 local_file = cwd / _LOCAL_OVERRIDE_FILE 

102 overrides_applied = False 

103 merged_config: dict[str, Any] = base_config 

104 if local_file.is_file(): 

105 try: 

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

107 except OSError: 

108 override_text = "" 

109 local_overrides = _parse_frontmatter(override_text) 

110 if local_overrides: 

111 merged_config = deep_merge(base_config, local_overrides) 

112 overrides_applied = True 

113 

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

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

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

117 _project_context_block = "" 

118 if config_path is not None: 

119 with contextlib.suppress(Exception): 

120 from little_loops.session_store import ensure_db, resolve_history_db 

121 

122 ensure_db(resolve_history_db(cwd / ".ll" / "history.db")) 

123 

124 # ENH-1830 / BUG-1882: trigger incremental JSONL backfill in a detached 

125 # subprocess so it outlives the short-lived hook process. A daemon thread 

126 # is killed when the hook subprocess exits (typically in <0.5s), so it 

127 # never commits. Path discovery runs synchronously here (fast) to produce 

128 # the concrete path arg passed to the worker. 

129 import os as _os 

130 

131 _db_path = ( 

132 Path(_os.environ["LL_HISTORY_DB"]) 

133 if _os.environ.get("LL_HISTORY_DB") 

134 else cwd / ".ll" / "history.db" 

135 ) 

136 

137 with contextlib.suppress(Exception): 

138 from little_loops.user_messages import get_project_folder 

139 

140 # ENH-1945: consume transcript_path from hook payload when available. 

141 payload = event.payload or {} 

142 _transcript = (payload.get("transcript_path") or "").strip() 

143 if _transcript and Path(_transcript).is_file(): 

144 _backfill_path: str | None = _transcript 

145 else: 

146 _pf = get_project_folder(cwd) 

147 _backfill_path = str(_pf) if _pf is not None else None 

148 

149 if _backfill_path is not None and not _os.environ.get("LL_NON_INTERACTIVE"): 

150 subprocess.Popen( 

151 [ 

152 sys.executable, 

153 "-m", 

154 "little_loops.cli.backfill_worker", 

155 str(_db_path), 

156 _backfill_path, 

157 ], 

158 start_new_session=True, 

159 stdin=subprocess.DEVNULL, 

160 stdout=subprocess.DEVNULL, 

161 stderr=subprocess.DEVNULL, 

162 cwd=str(cwd), 

163 ) 

164 

165 # ENH-1907: Inject project-context digest (best-effort, opt-in). 

166 # Runs after the backfill thread launches so the digest reflects only 

167 # already-persisted rows from prior sessions, not this session's backfill. 

168 # ENH-2040: Gate reads the dataclass attribute so the default (True) is 

169 # respected when no history block is present in the merged config dict. 

170 with contextlib.suppress(Exception): 

171 from little_loops.config.features import HistoryConfig 

172 

173 _hist = HistoryConfig.from_dict(merged_config.get("history", {})) 

174 _sd = _hist.session_digest 

175 if _sd.enabled: 

176 from little_loops.history_reader import project_digest, render_project_context 

177 

178 # Convert empty sections list to None so the default config renders 

179 # all providers; a non-empty list restricts/orders the output. 

180 _sections = _sd.sections if _sd.sections else None 

181 _digest = project_digest(_db_path, days=_sd.days, sections=_sections) 

182 _project_context_block = render_project_context( 

183 _digest, char_cap=_sd.char_cap, days=_sd.days 

184 ) 

185 

186 # 4. Compose the rendered stdout payload. 

187 if config_path is not None and not overrides_applied: 

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

189 try: 

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

191 except OSError: 

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

193 elif config_path is not None or overrides_applied: 

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

195 else: 

196 stdout_payload = None 

197 

198 # Append project-context block (ENH-1907) if populated. 

199 if _project_context_block: 

200 if stdout_payload is not None: 

201 stdout_payload = stdout_payload + "\n\n" + _project_context_block 

202 else: 

203 stdout_payload = _project_context_block 

204 

205 # 5. Compose feedback (stderr). 

206 if config_path is not None: 

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

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

209 if overrides_applied: 

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

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

212 feedback_lines.append( 

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

214 ) 

215 else: 

216 feedback_lines.append("[little-loops] Warning: No config found. Run ll-init to create one.") 

217 

218 # 6. Feature-flag validation warnings. 

219 feedback_lines.extend(_validate_features(merged_config)) 

220 

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

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

223 

224 

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

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

227 

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

229 

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

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

232 

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

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

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

236 """ 

237 warnings_out: list[str] = [] 

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

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

240 github = sync.get("github") 

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

242 warnings_out.append( 

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

244 ) 

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

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

247 categories = documents.get("categories") 

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

249 warnings_out.append( 

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

251 " configured" 

252 ) 

253 design_tokens = ( 

254 config.get("design_tokens") if isinstance(config.get("design_tokens"), dict) else {} 

255 ) 

256 if isinstance(design_tokens, dict) and design_tokens.get("enabled") is True: 

257 from pathlib import Path 

258 

259 token_path = Path(design_tokens.get("path", ".ll/design-tokens")) 

260 if not token_path.exists(): 

261 warnings_out.append( 

262 "[little-loops] Warning: design_tokens.enabled is true but path" 

263 f" '{token_path}' does not exist" 

264 ) 

265 else: 

266 # ENH-1768: warn when active profile is configured but missing. 

267 profiles_dir = design_tokens.get("profiles_dir") or "profiles" 

268 active = design_tokens.get("active", "default") 

269 profiles_root = token_path / profiles_dir 

270 if profiles_root.is_dir() and not (profiles_root / active).is_dir(): 

271 warnings_out.append( 

272 "[little-loops] Warning: design_tokens.active=" 

273 f"'{active}' but '{profiles_root / active}' does not exist" 

274 ) 

275 return warnings_out