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
« 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.
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
33from pathlib import Path
34from typing import Any
36import yaml
38from little_loops.config.core import deep_merge, resolve_config_path
39from little_loops.hooks.types import LLHookEvent, LLHookResult
41_LOCAL_OVERRIDE_FILE = Path(".ll/ll.local.md")
42_PRIOR_SESSION_STATE = Path(".ll/ll-context-state.json")
43_LARGE_CONFIG_THRESHOLD = 5000
46def _parse_frontmatter(content: str) -> dict[str, Any]:
47 """Extract YAML frontmatter (arbitrary nested shapes) from a markdown doc.
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 {}
70def handle(event: LLHookEvent) -> LLHookResult:
71 """Build the merged session-start config and validate feature flags.
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()).
77 cwd = Path.cwd()
78 feedback_lines: list[str] = []
80 # 1. Clean up prior-session state (best-effort, suppress all errors).
81 with contextlib.suppress(OSError):
82 _PRIOR_SESSION_STATE.unlink()
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 = {}
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
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
114 ensure_db(cwd / ".ll" / "session.db")
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
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 )
143 # 6. Feature-flag validation warnings.
144 feedback_lines.extend(_validate_features(merged_config))
146 feedback = "\n".join(feedback_lines) if feedback_lines else None
147 return LLHookResult(exit_code=0, feedback=feedback, stdout=stdout_payload)
150def _validate_features(config: dict[str, Any]) -> list[str]:
151 """Return stderr warning lines for misconfigured enabled features.
153 Mirrors ``validate_enabled_features`` in the bash version exactly:
155 - ``sync.enabled: true`` with empty ``sync.github`` → warn.
156 - ``documents.enabled: true`` with empty ``documents.categories`` → warn.
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