Coverage for little_loops / config / features.py: 98%
192 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-22 16:19 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-22 16:19 -0500
1"""Feature-related configuration dataclasses.
3Covers issue tracking, scanning, sprint management, loop management,
4and sync configuration.
5"""
7from __future__ import annotations
9from dataclasses import dataclass, field
10from typing import Any
13def feature_enabled(config_data: dict[str, Any], dot_path: str) -> bool:
14 """Return whether the boolean flag at *dot_path* is enabled in *config_data*.
16 Python port of ``hooks/scripts/lib/common.sh:ll_feature_enabled``. Operates
17 on an already-parsed config dict (the bash version uses ``jq`` on the file).
18 Mirrors jq's ``// false`` default: missing keys, non-dict intermediates, or
19 non-truthy terminal values all yield ``False``.
21 Examples:
22 >>> feature_enabled({"context_monitor": {"enabled": True}}, "context_monitor.enabled")
23 True
24 >>> feature_enabled({"context_monitor": {"enabled": False}}, "context_monitor.enabled")
25 False
26 >>> feature_enabled({}, "sync.enabled")
27 False
28 """
29 value: Any = config_data
30 for part in dot_path.split("."):
31 if not isinstance(value, dict) or part not in value:
32 return False
33 value = value[part]
34 return bool(value)
37# Required categories that must always exist (cannot be removed by user config)
38REQUIRED_CATEGORIES: dict[str, dict[str, str]] = {
39 "bugs": {"prefix": "BUG", "dir": "bugs", "action": "fix"},
40 "features": {"prefix": "FEAT", "dir": "features", "action": "implement"},
41 "enhancements": {"prefix": "ENH", "dir": "enhancements", "action": "improve"},
42 "epics": {"prefix": "EPIC", "dir": "epics", "action": "coordinate"},
43}
45# Default categories (same as required by default, could include optional defaults)
46DEFAULT_CATEGORIES: dict[str, dict[str, str]] = {
47 **REQUIRED_CATEGORIES,
48}
51@dataclass
52class CategoryConfig:
53 """Configuration for an issue category."""
55 prefix: str
56 dir: str
57 action: str = "fix"
59 @classmethod
60 def from_dict(cls, key: str, data: dict[str, Any]) -> CategoryConfig:
61 """Create CategoryConfig from dictionary."""
62 return cls(
63 prefix=data.get("prefix", key.upper()[:3]),
64 dir=data.get("dir", key),
65 action=data.get("action", "fix"),
66 )
69@dataclass
70class DuplicateDetectionConfig:
71 """Thresholds for duplicate issue detection."""
73 exact_threshold: float = 0.8
74 similar_threshold: float = 0.5
76 @classmethod
77 def from_dict(cls, data: dict[str, Any]) -> DuplicateDetectionConfig:
78 """Create DuplicateDetectionConfig from dictionary."""
79 return cls(
80 exact_threshold=data.get("exact_threshold", 0.8),
81 similar_threshold=data.get("similar_threshold", 0.5),
82 )
85VALID_NEXT_ISSUE_STRATEGIES: frozenset[str] = frozenset({"confidence_first", "priority_first"})
86VALID_NEXT_ISSUE_SORT_KEYS: frozenset[str] = frozenset(
87 {
88 "priority",
89 "outcome_confidence",
90 "confidence_score",
91 "effort",
92 "impact",
93 "score_complexity",
94 "score_test_coverage",
95 "score_ambiguity",
96 "score_change_surface",
97 }
98)
99VALID_NEXT_ISSUE_SORT_DIRECTIONS: frozenset[str] = frozenset({"asc", "desc"})
102@dataclass
103class NextIssueSortKey:
104 """A single (key, direction) pair for custom next-issue sort orderings."""
106 key: str
107 direction: str = "asc"
109 @classmethod
110 def from_dict(cls, data: dict[str, Any]) -> NextIssueSortKey:
111 """Create NextIssueSortKey from dictionary, validating key/direction."""
112 key = data.get("key")
113 if key not in VALID_NEXT_ISSUE_SORT_KEYS:
114 raise ValueError(f"Unknown sort key: {key!r}")
115 direction = data.get("direction", "asc")
116 if direction not in VALID_NEXT_ISSUE_SORT_DIRECTIONS:
117 raise ValueError(f"Unknown sort direction: {direction!r}")
118 return cls(key=key, direction=direction)
121@dataclass
122class NextIssueConfig:
123 """Selection behavior for ll-issues next-issue / next-issues commands.
125 Strategy presets:
126 - "confidence_first" (default): sort by (-outcome_confidence, -confidence_score, priority_int)
127 - "priority_first": sort by (priority_int, -outcome_confidence, -confidence_score)
129 If sort_keys is provided, it overrides strategy.
130 """
132 strategy: str = "confidence_first"
133 sort_keys: list[NextIssueSortKey] | None = None
135 @classmethod
136 def from_dict(cls, data: dict[str, Any]) -> NextIssueConfig:
137 """Create NextIssueConfig from dictionary, validating strategy and sort_keys."""
138 strategy = data.get("strategy", "confidence_first")
139 if strategy not in VALID_NEXT_ISSUE_STRATEGIES:
140 raise ValueError(f"Unknown strategy: {strategy!r}")
141 sort_keys_data = data.get("sort_keys")
142 sort_keys: list[NextIssueSortKey] | None
143 if sort_keys_data is None:
144 sort_keys = None
145 else:
146 sort_keys = [NextIssueSortKey.from_dict(entry) for entry in sort_keys_data]
147 return cls(strategy=strategy, sort_keys=sort_keys)
150@dataclass
151class IssuesConfig:
152 """Issue management configuration."""
154 base_dir: str = ".issues"
155 categories: dict[str, CategoryConfig] = field(default_factory=dict)
156 completed_dir: str = "completed" # DEPRECATED: use IssueInfo.status instead
157 deferred_dir: str = "deferred" # DEPRECATED: use IssueInfo.status instead
158 priorities: list[str] = field(default_factory=lambda: ["P0", "P1", "P2", "P3", "P4", "P5"])
159 templates_dir: str | None = None
160 capture_template: str = "full"
161 duplicate_detection: DuplicateDetectionConfig = field(default_factory=DuplicateDetectionConfig)
162 next_issue: NextIssueConfig = field(default_factory=NextIssueConfig)
164 @classmethod
165 def from_dict(cls, data: dict[str, Any]) -> IssuesConfig:
166 """Create IssuesConfig from dictionary.
168 Required categories (bugs, features, enhancements) are automatically
169 included if not specified in user config.
170 """
171 # Start with user categories or empty dict
172 categories_data = dict(data.get("categories", {}))
174 # Ensure required categories exist (merge with defaults)
175 for key, defaults in REQUIRED_CATEGORIES.items():
176 if key not in categories_data:
177 categories_data[key] = defaults
179 categories = {
180 key: CategoryConfig.from_dict(key, value) for key, value in categories_data.items()
181 }
182 return cls(
183 base_dir=data.get("base_dir", ".issues"),
184 categories=categories,
185 completed_dir=data.get(
186 "completed_dir", "completed"
187 ), # deprecated: kept for backward compat
188 deferred_dir=data.get(
189 "deferred_dir", "deferred"
190 ), # deprecated: kept for backward compat
191 priorities=data.get("priorities", ["P0", "P1", "P2", "P3", "P4", "P5"]),
192 templates_dir=data.get("templates_dir"),
193 capture_template=data.get("capture_template", "full"),
194 duplicate_detection=DuplicateDetectionConfig.from_dict(
195 data.get("duplicate_detection", {})
196 ),
197 next_issue=NextIssueConfig.from_dict(data.get("next_issue", {})),
198 )
200 def get_category_by_prefix(self, prefix: str) -> CategoryConfig | None:
201 """Get category config by prefix (e.g., 'BUG', 'FEAT').
203 Args:
204 prefix: Issue type prefix to look up
206 Returns:
207 CategoryConfig if found, None otherwise
208 """
209 for category in self.categories.values():
210 if category.prefix == prefix:
211 return category
212 return None
214 def get_category_by_dir(self, dir_name: str) -> CategoryConfig | None:
215 """Get category config by directory name.
217 Args:
218 dir_name: Directory name to look up
220 Returns:
221 CategoryConfig if found, None otherwise
222 """
223 for category in self.categories.values():
224 if category.dir == dir_name:
225 return category
226 return None
228 def get_all_prefixes(self) -> list[str]:
229 """Get all configured issue type prefixes.
231 Returns:
232 List of prefixes (e.g., ['BUG', 'FEAT', 'ENH'])
233 """
234 return [cat.prefix for cat in self.categories.values()]
236 def get_all_dirs(self) -> list[str]:
237 """Get all configured issue directory names.
239 Returns:
240 List of directory names (e.g., ['bugs', 'features', 'enhancements'])
241 """
242 return [cat.dir for cat in self.categories.values()]
245@dataclass
246class ScanConfig:
247 """Codebase scanning configuration."""
249 focus_dirs: list[str] = field(default_factory=lambda: ["src/", "tests/"])
250 exclude_patterns: list[str] = field(
251 default_factory=lambda: ["**/node_modules/**", "**/__pycache__/**", "**/.git/**"]
252 )
253 custom_agents: list[str] = field(default_factory=list)
255 @classmethod
256 def from_dict(cls, data: dict[str, Any]) -> ScanConfig:
257 """Create ScanConfig from dictionary."""
258 return cls(
259 focus_dirs=data.get("focus_dirs", ["src/", "tests/"]),
260 exclude_patterns=data.get(
261 "exclude_patterns",
262 ["**/node_modules/**", "**/__pycache__/**", "**/.git/**"],
263 ),
264 custom_agents=data.get("custom_agents", []),
265 )
268@dataclass
269class SprintsConfig:
270 """Sprint management configuration."""
272 sprints_dir: str = ".sprints"
273 default_timeout: int = 3600
274 default_max_workers: int = 2
276 @classmethod
277 def from_dict(cls, data: dict[str, Any]) -> SprintsConfig:
278 """Create SprintsConfig from dictionary."""
279 return cls(
280 sprints_dir=data.get("sprints_dir", ".sprints"),
281 default_timeout=data.get("default_timeout", 3600),
282 default_max_workers=data.get("default_max_workers", 2),
283 )
286@dataclass
287class LearningTestsConfig:
288 """Learning test registry configuration."""
290 stale_after_days: int = 30
292 @classmethod
293 def from_dict(cls, data: dict[str, Any]) -> LearningTestsConfig:
294 """Create LearningTestsConfig from dictionary."""
295 return cls(
296 stale_after_days=data.get("stale_after_days", 30),
297 )
300@dataclass
301class LoopsGlyphsConfig:
302 """Unicode badge/glyph overrides for FSM box diagram state badges."""
304 prompt: str = "\u2726" # ✦
305 slash_command: str = "/\u2501\u25ba" # /━►
306 shell: str = "\u276f_" # ❯_
307 mcp_tool: str = "\u26a1" # ⚡
308 sub_loop: str = "\u21b3\u27f3" # ↳⟳
309 route: str = "\u2443" # ⑃
310 parallel: str = "\u2225" # ∥
312 @classmethod
313 def from_dict(cls, data: dict[str, Any]) -> LoopsGlyphsConfig:
314 """Create LoopsGlyphsConfig from dictionary."""
315 return cls(
316 prompt=data.get("prompt", "\u2726"),
317 slash_command=data.get("slash_command", "/\u2501\u25ba"),
318 shell=data.get("shell", "\u276f_"),
319 mcp_tool=data.get("mcp_tool", "\u26a1"),
320 sub_loop=data.get("sub_loop", "\u21b3\u27f3"),
321 route=data.get("route", "\u2443"),
322 parallel=data.get("parallel", "\u2225"),
323 )
325 def to_dict(self) -> dict[str, str]:
326 """Convert to a glyph-key→string dict for use by _get_state_badge."""
327 return {
328 "prompt": self.prompt,
329 "slash_command": self.slash_command,
330 "shell": self.shell,
331 "mcp_tool": self.mcp_tool,
332 "sub_loop": self.sub_loop,
333 "route": self.route,
334 "parallel": self.parallel,
335 }
338@dataclass
339class LoopsConfig:
340 """FSM loop configuration."""
342 loops_dir: str = ".loops"
343 queue_wait_timeout_seconds: int = 86400
344 glyphs: LoopsGlyphsConfig = field(default_factory=LoopsGlyphsConfig)
346 @classmethod
347 def from_dict(cls, data: dict[str, Any]) -> LoopsConfig:
348 """Create LoopsConfig from dictionary."""
349 return cls(
350 loops_dir=data.get("loops_dir", ".loops"),
351 queue_wait_timeout_seconds=data.get("queue_wait_timeout_seconds", 86400),
352 glyphs=LoopsGlyphsConfig.from_dict(data.get("glyphs", {})),
353 )
356@dataclass
357class GitHubSyncConfig:
358 """GitHub-specific sync configuration."""
360 repo: str | None = None
361 label_mapping: dict[str, str] = field(
362 default_factory=lambda: {
363 "BUG": "bug",
364 "FEAT": "enhancement",
365 "ENH": "enhancement",
366 "EPIC": "epic",
367 }
368 )
369 priority_labels: bool = True
370 sync_completed: bool = False
371 state_file: str = ".ll/ll-sync-state.json"
372 pull_template: str = "minimal"
373 pull_limit: int = 500
375 @classmethod
376 def from_dict(cls, data: dict[str, Any]) -> GitHubSyncConfig:
377 """Create GitHubSyncConfig from dictionary."""
378 return cls(
379 repo=data.get("repo"),
380 label_mapping=data.get(
381 "label_mapping",
382 {"BUG": "bug", "FEAT": "enhancement", "ENH": "enhancement", "EPIC": "epic"},
383 ),
384 priority_labels=data.get("priority_labels", True),
385 sync_completed=data.get("sync_completed", False),
386 state_file=data.get("state_file", ".ll/ll-sync-state.json"),
387 pull_template=data.get("pull_template", "minimal"),
388 pull_limit=data.get("pull_limit", 500),
389 )
392@dataclass
393class SyncConfig:
394 """Issue sync configuration."""
396 enabled: bool = False
397 provider: str = "github"
398 github: GitHubSyncConfig = field(default_factory=GitHubSyncConfig)
400 @classmethod
401 def from_dict(cls, data: dict[str, Any]) -> SyncConfig:
402 """Create SyncConfig from dictionary."""
403 return cls(
404 enabled=data.get("enabled", False),
405 provider=data.get("provider", "github"),
406 github=GitHubSyncConfig.from_dict(data.get("github", {})),
407 )
410@dataclass
411class SocketEventsConfig:
412 """UnixSocketTransport configuration."""
414 path: str = ".ll/events.sock"
415 max_clients: int = 8
417 @classmethod
418 def from_dict(cls, data: dict[str, Any]) -> SocketEventsConfig:
419 """Create SocketEventsConfig from dictionary."""
420 return cls(
421 path=data.get("path", ".ll/events.sock"),
422 max_clients=data.get("max_clients", 8),
423 )
426@dataclass
427class OTelEventsConfig:
428 """OTelTransport configuration."""
430 endpoint: str = "http://localhost:4317"
431 service_name: str = "little-loops"
433 @classmethod
434 def from_dict(cls, data: dict[str, Any]) -> OTelEventsConfig:
435 """Create OTelEventsConfig from dictionary."""
436 return cls(
437 endpoint=data.get("endpoint", "http://localhost:4317"),
438 service_name=data.get("service_name", "little-loops"),
439 )
442@dataclass
443class WebhookEventsConfig:
444 """WebhookTransport configuration."""
446 url: str | None = None
447 batch_ms: int = 1000
448 headers: dict[str, str] = field(default_factory=dict)
450 @classmethod
451 def from_dict(cls, data: dict[str, Any]) -> WebhookEventsConfig:
452 """Create WebhookEventsConfig from dictionary."""
453 return cls(
454 url=data.get("url", None),
455 batch_ms=data.get("batch_ms", 1000),
456 headers=data.get("headers", {}),
457 )
460@dataclass
461class SqliteEventsConfig:
462 """SQLiteTransport configuration (unified session store, FEAT-1112)."""
464 path: str = ".ll/session.db"
466 @classmethod
467 def from_dict(cls, data: dict[str, Any]) -> SqliteEventsConfig:
468 """Create SqliteEventsConfig from dictionary."""
469 return cls(
470 path=data.get("path", ".ll/session.db"),
471 )
474@dataclass
475class EventsConfig:
476 """Event transport configuration.
478 Lists the transports to wire onto the EventBus at runtime. Names are
479 resolved against the registry in `little_loops.transport.wire_transports`;
480 unknown names are skipped with a warning.
481 """
483 transports: list[str] = field(default_factory=list)
484 socket: SocketEventsConfig = field(default_factory=SocketEventsConfig)
485 otel: OTelEventsConfig = field(default_factory=OTelEventsConfig)
486 webhook: WebhookEventsConfig = field(default_factory=WebhookEventsConfig)
487 sqlite: SqliteEventsConfig = field(default_factory=SqliteEventsConfig)
489 @classmethod
490 def from_dict(cls, data: dict[str, Any]) -> EventsConfig:
491 """Create EventsConfig from dictionary."""
492 return cls(
493 transports=data.get("transports", []),
494 socket=SocketEventsConfig.from_dict(data.get("socket", {})),
495 otel=OTelEventsConfig.from_dict(data.get("otel", {})),
496 webhook=WebhookEventsConfig.from_dict(data.get("webhook", {})),
497 sqlite=SqliteEventsConfig.from_dict(data.get("sqlite", {})),
498 )