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

2

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

4 function 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

7 payload.

8

9 Observable side-effects preserved from the bash version:

10

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

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

13 3. 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.

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

17 ingests it as session context.

18 5. 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

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

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

26 decision).

27"""

28

29 from __future__ import annotations

30

31 import contextlib

32 import json

33 import logging

34 import subprocess

35 import sys

36 from pathlib import Path

37 from typing import Any

38

39 import yaml

40

41 from little_loops.config.core import deep_merge, resolve_config_path

42 from little_loops.hooks.types import LLHookEvent, LLHookResult

43

44 logger = 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

51 def _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

75 def 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

225 def _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