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
« 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.
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.
9Observable side-effects preserved from the bash version:
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``.
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"""
29from __future__ import annotations
31import contextlib
32import json
33import logging
34import subprocess
35import sys
36from pathlib import Path
37from typing import Any
39import yaml
41from little_loops.config.core import deep_merge, resolve_config_path
42from little_loops.hooks.types import LLHookEvent, LLHookResult
44logger = logging.getLogger(__name__)
46_LOCAL_OVERRIDE_FILE = Path(".ll/ll.local.md")
47_PRIOR_SESSION_STATE = Path(".ll/ll-context-state.json")
48_LARGE_CONFIG_THRESHOLD = 5000
51def _parse_frontmatter(content: str) -> dict[str, Any]:
52 """Extract YAML frontmatter (arbitrary nested shapes) from a markdown doc.
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 {}
75def handle(event: LLHookEvent) -> LLHookResult:
76 """Build the merged session-start config and validate feature flags.
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.
84 cwd = Path.cwd()
85 feedback_lines: list[str] = []
87 # 1. Clean up prior-session state (best-effort, suppress all errors).
88 with contextlib.suppress(OSError):
89 _PRIOR_SESSION_STATE.unlink()
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 = {}
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
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
122 ensure_db(resolve_history_db(cwd / ".ll" / "history.db"))
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
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 )
137 with contextlib.suppress(Exception):
138 from little_loops.user_messages import get_project_folder
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
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 )
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
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
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 )
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
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
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.")
218 # 6. Feature-flag validation warnings.
219 feedback_lines.extend(_validate_features(merged_config))
221 feedback = "\n".join(feedback_lines) if feedback_lines else None
222 return LLHookResult(exit_code=0, feedback=feedback, stdout=stdout_payload)
225def _validate_features(config: dict[str, Any]) -> list[str]:
226 """Return stderr warning lines for misconfigured enabled features.
228 Mirrors ``validate_enabled_features`` in the bash version exactly:
230 - ``sync.enabled: true`` with empty ``sync.github`` → warn.
231 - ``documents.enabled: true`` with empty ``documents.categories`` → warn.
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
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