Coverage for little_loops / config / features.py: 75%
178 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-11 00:29 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-11 00:29 -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
12# Required categories that must always exist (cannot be removed by user config)
13REQUIRED_CATEGORIES: dict[str, dict[str, str]] = {
14 "bugs": {"prefix": "BUG", "dir": "bugs", "action": "fix"},
15 "features": {"prefix": "FEAT", "dir": "features", "action": "implement"},
16 "enhancements": {"prefix": "ENH", "dir": "enhancements", "action": "improve"},
17 "epics": {"prefix": "EPIC", "dir": "epics", "action": "coordinate"},
18}
20# Default categories (same as required by default, could include optional defaults)
21DEFAULT_CATEGORIES: dict[str, dict[str, str]] = {
22 **REQUIRED_CATEGORIES,
23}
26@dataclass
27class CategoryConfig:
28 """Configuration for an issue category."""
30 prefix: str
31 dir: str
32 action: str = "fix"
34 @classmethod
35 def from_dict(cls, key: str, data: dict[str, Any]) -> CategoryConfig:
36 """Create CategoryConfig from dictionary."""
37 return cls(
38 prefix=data.get("prefix", key.upper()[:3]),
39 dir=data.get("dir", key),
40 action=data.get("action", "fix"),
41 )
44@dataclass
45class DuplicateDetectionConfig:
46 """Thresholds for duplicate issue detection."""
48 exact_threshold: float = 0.8
49 similar_threshold: float = 0.5
51 @classmethod
52 def from_dict(cls, data: dict[str, Any]) -> DuplicateDetectionConfig:
53 """Create DuplicateDetectionConfig from dictionary."""
54 return cls(
55 exact_threshold=data.get("exact_threshold", 0.8),
56 similar_threshold=data.get("similar_threshold", 0.5),
57 )
60VALID_NEXT_ISSUE_STRATEGIES: frozenset[str] = frozenset({"confidence_first", "priority_first"})
61VALID_NEXT_ISSUE_SORT_KEYS: frozenset[str] = frozenset(
62 {
63 "priority",
64 "outcome_confidence",
65 "confidence_score",
66 "effort",
67 "impact",
68 "score_complexity",
69 "score_test_coverage",
70 "score_ambiguity",
71 "score_change_surface",
72 }
73)
74VALID_NEXT_ISSUE_SORT_DIRECTIONS: frozenset[str] = frozenset({"asc", "desc"})
77@dataclass
78class NextIssueSortKey:
79 """A single (key, direction) pair for custom next-issue sort orderings."""
81 key: str
82 direction: str = "asc"
84 @classmethod
85 def from_dict(cls, data: dict[str, Any]) -> NextIssueSortKey:
86 """Create NextIssueSortKey from dictionary, validating key/direction."""
87 key = data.get("key")
88 if key not in VALID_NEXT_ISSUE_SORT_KEYS:
89 raise ValueError(f"Unknown sort key: {key!r}")
90 direction = data.get("direction", "asc")
91 if direction not in VALID_NEXT_ISSUE_SORT_DIRECTIONS:
92 raise ValueError(f"Unknown sort direction: {direction!r}")
93 return cls(key=key, direction=direction)
96@dataclass
97class NextIssueConfig:
98 """Selection behavior for ll-issues next-issue / next-issues commands.
100 Strategy presets:
101 - "confidence_first" (default): sort by (-outcome_confidence, -confidence_score, priority_int)
102 - "priority_first": sort by (priority_int, -outcome_confidence, -confidence_score)
104 If sort_keys is provided, it overrides strategy.
105 """
107 strategy: str = "confidence_first"
108 sort_keys: list[NextIssueSortKey] | None = None
110 @classmethod
111 def from_dict(cls, data: dict[str, Any]) -> NextIssueConfig:
112 """Create NextIssueConfig from dictionary, validating strategy and sort_keys."""
113 strategy = data.get("strategy", "confidence_first")
114 if strategy not in VALID_NEXT_ISSUE_STRATEGIES:
115 raise ValueError(f"Unknown strategy: {strategy!r}")
116 sort_keys_data = data.get("sort_keys")
117 sort_keys: list[NextIssueSortKey] | None
118 if sort_keys_data is None:
119 sort_keys = None
120 else:
121 sort_keys = [NextIssueSortKey.from_dict(entry) for entry in sort_keys_data]
122 return cls(strategy=strategy, sort_keys=sort_keys)
125@dataclass
126class IssuesConfig:
127 """Issue management configuration."""
129 base_dir: str = ".issues"
130 categories: dict[str, CategoryConfig] = field(default_factory=dict)
131 completed_dir: str = "completed" # DEPRECATED: use IssueInfo.status instead
132 deferred_dir: str = "deferred" # DEPRECATED: use IssueInfo.status instead
133 priorities: list[str] = field(default_factory=lambda: ["P0", "P1", "P2", "P3", "P4", "P5"])
134 templates_dir: str | None = None
135 capture_template: str = "full"
136 duplicate_detection: DuplicateDetectionConfig = field(default_factory=DuplicateDetectionConfig)
137 next_issue: NextIssueConfig = field(default_factory=NextIssueConfig)
139 @classmethod
140 def from_dict(cls, data: dict[str, Any]) -> IssuesConfig:
141 """Create IssuesConfig from dictionary.
143 Required categories (bugs, features, enhancements) are automatically
144 included if not specified in user config.
145 """
146 # Start with user categories or empty dict
147 categories_data = dict(data.get("categories", {}))
149 # Ensure required categories exist (merge with defaults)
150 for key, defaults in REQUIRED_CATEGORIES.items():
151 if key not in categories_data:
152 categories_data[key] = defaults
154 categories = {
155 key: CategoryConfig.from_dict(key, value) for key, value in categories_data.items()
156 }
157 return cls(
158 base_dir=data.get("base_dir", ".issues"),
159 categories=categories,
160 completed_dir=data.get(
161 "completed_dir", "completed"
162 ), # deprecated: kept for backward compat
163 deferred_dir=data.get(
164 "deferred_dir", "deferred"
165 ), # deprecated: kept for backward compat
166 priorities=data.get("priorities", ["P0", "P1", "P2", "P3", "P4", "P5"]),
167 templates_dir=data.get("templates_dir"),
168 capture_template=data.get("capture_template", "full"),
169 duplicate_detection=DuplicateDetectionConfig.from_dict(
170 data.get("duplicate_detection", {})
171 ),
172 next_issue=NextIssueConfig.from_dict(data.get("next_issue", {})),
173 )
175 def get_category_by_prefix(self, prefix: str) -> CategoryConfig | None:
176 """Get category config by prefix (e.g., 'BUG', 'FEAT').
178 Args:
179 prefix: Issue type prefix to look up
181 Returns:
182 CategoryConfig if found, None otherwise
183 """
184 for category in self.categories.values():
185 if category.prefix == prefix:
186 return category
187 return None
189 def get_category_by_dir(self, dir_name: str) -> CategoryConfig | None:
190 """Get category config by directory name.
192 Args:
193 dir_name: Directory name to look up
195 Returns:
196 CategoryConfig if found, None otherwise
197 """
198 for category in self.categories.values():
199 if category.dir == dir_name:
200 return category
201 return None
203 def get_all_prefixes(self) -> list[str]:
204 """Get all configured issue type prefixes.
206 Returns:
207 List of prefixes (e.g., ['BUG', 'FEAT', 'ENH'])
208 """
209 return [cat.prefix for cat in self.categories.values()]
211 def get_all_dirs(self) -> list[str]:
212 """Get all configured issue directory names.
214 Returns:
215 List of directory names (e.g., ['bugs', 'features', 'enhancements'])
216 """
217 return [cat.dir for cat in self.categories.values()]
220@dataclass
221class ScanConfig:
222 """Codebase scanning configuration."""
224 focus_dirs: list[str] = field(default_factory=lambda: ["src/", "tests/"])
225 exclude_patterns: list[str] = field(
226 default_factory=lambda: ["**/node_modules/**", "**/__pycache__/**", "**/.git/**"]
227 )
228 custom_agents: list[str] = field(default_factory=list)
230 @classmethod
231 def from_dict(cls, data: dict[str, Any]) -> ScanConfig:
232 """Create ScanConfig from dictionary."""
233 return cls(
234 focus_dirs=data.get("focus_dirs", ["src/", "tests/"]),
235 exclude_patterns=data.get(
236 "exclude_patterns",
237 ["**/node_modules/**", "**/__pycache__/**", "**/.git/**"],
238 ),
239 custom_agents=data.get("custom_agents", []),
240 )
243@dataclass
244class SprintsConfig:
245 """Sprint management configuration."""
247 sprints_dir: str = ".sprints"
248 default_timeout: int = 3600
249 default_max_workers: int = 2
251 @classmethod
252 def from_dict(cls, data: dict[str, Any]) -> SprintsConfig:
253 """Create SprintsConfig from dictionary."""
254 return cls(
255 sprints_dir=data.get("sprints_dir", ".sprints"),
256 default_timeout=data.get("default_timeout", 3600),
257 default_max_workers=data.get("default_max_workers", 2),
258 )
261@dataclass
262class LearningTestsConfig:
263 """Learning test registry configuration."""
265 stale_after_days: int = 30
267 @classmethod
268 def from_dict(cls, data: dict[str, Any]) -> LearningTestsConfig:
269 """Create LearningTestsConfig from dictionary."""
270 return cls(
271 stale_after_days=data.get("stale_after_days", 30),
272 )
275@dataclass
276class LoopsGlyphsConfig:
277 """Unicode badge/glyph overrides for FSM box diagram state badges."""
279 prompt: str = "\u2726" # ✦
280 slash_command: str = "/\u2501\u25ba" # /━►
281 shell: str = "\u276f_" # ❯_
282 mcp_tool: str = "\u26a1" # ⚡
283 sub_loop: str = "\u21b3\u27f3" # ↳⟳
284 route: str = "\u2443" # ⑃
285 parallel: str = "\u2225" # ∥
287 @classmethod
288 def from_dict(cls, data: dict[str, Any]) -> LoopsGlyphsConfig:
289 """Create LoopsGlyphsConfig from dictionary."""
290 return cls(
291 prompt=data.get("prompt", "\u2726"),
292 slash_command=data.get("slash_command", "/\u2501\u25ba"),
293 shell=data.get("shell", "\u276f_"),
294 mcp_tool=data.get("mcp_tool", "\u26a1"),
295 sub_loop=data.get("sub_loop", "\u21b3\u27f3"),
296 route=data.get("route", "\u2443"),
297 parallel=data.get("parallel", "\u2225"),
298 )
300 def to_dict(self) -> dict[str, str]:
301 """Convert to a glyph-key→string dict for use by _get_state_badge."""
302 return {
303 "prompt": self.prompt,
304 "slash_command": self.slash_command,
305 "shell": self.shell,
306 "mcp_tool": self.mcp_tool,
307 "sub_loop": self.sub_loop,
308 "route": self.route,
309 "parallel": self.parallel,
310 }
313@dataclass
314class LoopsConfig:
315 """FSM loop configuration."""
317 loops_dir: str = ".loops"
318 queue_wait_timeout_seconds: int = 86400
319 glyphs: LoopsGlyphsConfig = field(default_factory=LoopsGlyphsConfig)
321 @classmethod
322 def from_dict(cls, data: dict[str, Any]) -> LoopsConfig:
323 """Create LoopsConfig from dictionary."""
324 return cls(
325 loops_dir=data.get("loops_dir", ".loops"),
326 queue_wait_timeout_seconds=data.get("queue_wait_timeout_seconds", 86400),
327 glyphs=LoopsGlyphsConfig.from_dict(data.get("glyphs", {})),
328 )
331@dataclass
332class GitHubSyncConfig:
333 """GitHub-specific sync configuration."""
335 repo: str | None = None
336 label_mapping: dict[str, str] = field(
337 default_factory=lambda: {
338 "BUG": "bug",
339 "FEAT": "enhancement",
340 "ENH": "enhancement",
341 "EPIC": "epic",
342 }
343 )
344 priority_labels: bool = True
345 sync_completed: bool = False
346 state_file: str = ".ll/ll-sync-state.json"
347 pull_template: str = "minimal"
348 pull_limit: int = 500
350 @classmethod
351 def from_dict(cls, data: dict[str, Any]) -> GitHubSyncConfig:
352 """Create GitHubSyncConfig from dictionary."""
353 return cls(
354 repo=data.get("repo"),
355 label_mapping=data.get(
356 "label_mapping",
357 {"BUG": "bug", "FEAT": "enhancement", "ENH": "enhancement", "EPIC": "epic"},
358 ),
359 priority_labels=data.get("priority_labels", True),
360 sync_completed=data.get("sync_completed", False),
361 state_file=data.get("state_file", ".ll/ll-sync-state.json"),
362 pull_template=data.get("pull_template", "minimal"),
363 pull_limit=data.get("pull_limit", 500),
364 )
367@dataclass
368class SyncConfig:
369 """Issue sync configuration."""
371 enabled: bool = False
372 provider: str = "github"
373 github: GitHubSyncConfig = field(default_factory=GitHubSyncConfig)
375 @classmethod
376 def from_dict(cls, data: dict[str, Any]) -> SyncConfig:
377 """Create SyncConfig from dictionary."""
378 return cls(
379 enabled=data.get("enabled", False),
380 provider=data.get("provider", "github"),
381 github=GitHubSyncConfig.from_dict(data.get("github", {})),
382 )
385@dataclass
386class SocketEventsConfig:
387 """UnixSocketTransport configuration."""
389 path: str = ".ll/events.sock"
390 max_clients: int = 8
392 @classmethod
393 def from_dict(cls, data: dict[str, Any]) -> SocketEventsConfig:
394 """Create SocketEventsConfig from dictionary."""
395 return cls(
396 path=data.get("path", ".ll/events.sock"),
397 max_clients=data.get("max_clients", 8),
398 )
401@dataclass
402class OTelEventsConfig:
403 """OTelTransport configuration."""
405 endpoint: str = "http://localhost:4317"
406 service_name: str = "little-loops"
408 @classmethod
409 def from_dict(cls, data: dict[str, Any]) -> OTelEventsConfig:
410 """Create OTelEventsConfig from dictionary."""
411 return cls(
412 endpoint=data.get("endpoint", "http://localhost:4317"),
413 service_name=data.get("service_name", "little-loops"),
414 )
417@dataclass
418class WebhookEventsConfig:
419 """WebhookTransport configuration."""
421 url: str | None = None
422 batch_ms: int = 1000
423 headers: dict[str, str] = field(default_factory=dict)
425 @classmethod
426 def from_dict(cls, data: dict[str, Any]) -> WebhookEventsConfig:
427 """Create WebhookEventsConfig from dictionary."""
428 return cls(
429 url=data.get("url", None),
430 batch_ms=data.get("batch_ms", 1000),
431 headers=data.get("headers", {}),
432 )
435@dataclass
436class EventsConfig:
437 """Event transport configuration.
439 Lists the transports to wire onto the EventBus at runtime. Names are
440 resolved against the registry in `little_loops.transport.wire_transports`;
441 unknown names are skipped with a warning.
442 """
444 transports: list[str] = field(default_factory=list)
445 socket: SocketEventsConfig = field(default_factory=SocketEventsConfig)
446 otel: OTelEventsConfig = field(default_factory=OTelEventsConfig)
447 webhook: WebhookEventsConfig = field(default_factory=WebhookEventsConfig)
449 @classmethod
450 def from_dict(cls, data: dict[str, Any]) -> EventsConfig:
451 """Create EventsConfig from dictionary."""
452 return cls(
453 transports=data.get("transports", []),
454 socket=SocketEventsConfig.from_dict(data.get("socket", {})),
455 otel=OTelEventsConfig.from_dict(data.get("otel", {})),
456 webhook=WebhookEventsConfig.from_dict(data.get("webhook", {})),
457 )