Coverage for little_loops / config / features.py: 75%
314 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-08 15:34 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-08 15:34 -0500
1"""Feature-related configuration dataclasses.
3Covers issue tracking, scanning, sprint management, loop management,
4and sync configuration.
5"""
7from __future__ import annotations
9import fnmatch
10from dataclasses import dataclass, field
11from typing import Any
14def feature_enabled(config_data: dict[str, Any], dot_path: str) -> bool:
15 """Return whether the boolean flag at *dot_path* is enabled in *config_data*.
17 Python port of ``hooks/scripts/lib/common.sh:ll_feature_enabled``. Operates
18 on an already-parsed config dict (the bash version uses ``jq`` on the file).
19 Mirrors jq's ``// false`` default: missing keys, non-dict intermediates, or
20 non-truthy terminal values all yield ``False``.
22 Examples:
23 >>> feature_enabled({"context_monitor": {"enabled": True}}, "context_monitor.enabled")
24 True
25 >>> feature_enabled({"context_monitor": {"enabled": False}}, "context_monitor.enabled")
26 False
27 >>> feature_enabled({}, "sync.enabled")
28 False
29 """
30 value: Any = config_data
31 for part in dot_path.split("."):
32 if not isinstance(value, dict) or part not in value:
33 return False
34 value = value[part]
35 return bool(value)
38def feature_enabled_for(
39 config_data: dict[str, Any], dot_path: str, subject: str, default: bool = True
40) -> bool:
41 """Return whether *subject* matches the glob-pattern list at *dot_path* in *config_data*.
43 Operates on a raw config dict (same as ``feature_enabled``). Resolves the value at
44 *dot_path*; if absent or the intermediate path doesn't exist, returns *default*.
45 The resolved value is normalised to a ``list[str]`` (bare string wrapped in a list,
46 ``None`` treated as ``["*"]`` — match all) then tested with ``fnmatch.fnmatch``.
48 Examples:
49 >>> feature_enabled_for({"analytics": {"capture": {"skills": ["*"]}}},
50 ... "analytics.capture.skills", "my-skill")
51 True
52 >>> feature_enabled_for({"analytics": {"capture": {"skills": ["Read"]}}},
53 ... "analytics.capture.skills", "Write")
54 False
55 >>> feature_enabled_for({}, "analytics.capture.skills", "any")
56 True
57 """
58 value: Any = config_data
59 for part in dot_path.split("."):
60 if not isinstance(value, dict) or part not in value:
61 return default
62 value = value[part]
64 # Normalise to list[str]: None → match-all, str → [str], list used as-is
65 if value is None:
66 patterns: list[str] = ["*"]
67 elif isinstance(value, str):
68 patterns = [value]
69 else:
70 patterns = list(value)
72 if not patterns:
73 return default
75 return any(fnmatch.fnmatch(subject, p) for p in patterns)
78# Required categories that must always exist (cannot be removed by user config)
79REQUIRED_CATEGORIES: dict[str, dict[str, str]] = {
80 "bugs": {"prefix": "BUG", "dir": "bugs", "action": "fix"},
81 "features": {"prefix": "FEAT", "dir": "features", "action": "implement"},
82 "enhancements": {"prefix": "ENH", "dir": "enhancements", "action": "improve"},
83 "epics": {"prefix": "EPIC", "dir": "epics", "action": "coordinate"},
84}
86# Default categories (same as required by default, could include optional defaults)
87DEFAULT_CATEGORIES: dict[str, dict[str, str]] = {
88 **REQUIRED_CATEGORIES,
89}
92@dataclass
93class CategoryConfig:
94 """Configuration for an issue category."""
96 prefix: str
97 dir: str
98 action: str = "fix"
100 @classmethod
101 def from_dict(cls, key: str, data: dict[str, Any]) -> CategoryConfig:
102 """Create CategoryConfig from dictionary."""
103 return cls(
104 prefix=data.get("prefix", key.upper()[:3]),
105 dir=data.get("dir", key),
106 action=data.get("action", "fix"),
107 )
110@dataclass
111class DuplicateDetectionConfig:
112 """Thresholds for duplicate issue detection."""
114 exact_threshold: float = 0.8
115 similar_threshold: float = 0.5
117 @classmethod
118 def from_dict(cls, data: dict[str, Any]) -> DuplicateDetectionConfig:
119 """Create DuplicateDetectionConfig from dictionary."""
120 return cls(
121 exact_threshold=data.get("exact_threshold", 0.8),
122 similar_threshold=data.get("similar_threshold", 0.5),
123 )
126VALID_NEXT_ISSUE_STRATEGIES: frozenset[str] = frozenset({"confidence_first", "priority_first"})
127VALID_NEXT_ISSUE_SORT_KEYS: frozenset[str] = frozenset(
128 {
129 "priority",
130 "outcome_confidence",
131 "confidence_score",
132 "effort",
133 "impact",
134 "score_complexity",
135 "score_test_coverage",
136 "score_ambiguity",
137 "score_change_surface",
138 }
139)
140VALID_NEXT_ISSUE_SORT_DIRECTIONS: frozenset[str] = frozenset({"asc", "desc"})
143@dataclass
144class NextIssueSortKey:
145 """A single (key, direction) pair for custom next-issue sort orderings."""
147 key: str
148 direction: str = "asc"
150 @classmethod
151 def from_dict(cls, data: dict[str, Any]) -> NextIssueSortKey:
152 """Create NextIssueSortKey from dictionary, validating key/direction."""
153 key = data.get("key")
154 if key not in VALID_NEXT_ISSUE_SORT_KEYS:
155 raise ValueError(f"Unknown sort key: {key!r}")
156 direction = data.get("direction", "asc")
157 if direction not in VALID_NEXT_ISSUE_SORT_DIRECTIONS:
158 raise ValueError(f"Unknown sort direction: {direction!r}")
159 return cls(key=key, direction=direction)
162@dataclass
163class NextIssueConfig:
164 """Selection behavior for ll-issues next-issue / next-issues commands.
166 Strategy presets:
167 - "confidence_first" (default): sort by (-outcome_confidence, -confidence_score, priority_int)
168 - "priority_first": sort by (priority_int, -outcome_confidence, -confidence_score)
170 If sort_keys is provided, it overrides strategy.
171 """
173 strategy: str = "confidence_first"
174 sort_keys: list[NextIssueSortKey] | None = None
176 @classmethod
177 def from_dict(cls, data: dict[str, Any]) -> NextIssueConfig:
178 """Create NextIssueConfig from dictionary, validating strategy and sort_keys."""
179 strategy = data.get("strategy", "confidence_first")
180 if strategy not in VALID_NEXT_ISSUE_STRATEGIES:
181 raise ValueError(f"Unknown strategy: {strategy!r}")
182 sort_keys_data = data.get("sort_keys")
183 sort_keys: list[NextIssueSortKey] | None
184 if sort_keys_data is None:
185 sort_keys = None
186 else:
187 sort_keys = [NextIssueSortKey.from_dict(entry) for entry in sort_keys_data]
188 return cls(strategy=strategy, sort_keys=sort_keys)
191@dataclass
192class IssuesConfig:
193 """Issue management configuration."""
195 base_dir: str = ".issues"
196 categories: dict[str, CategoryConfig] = field(default_factory=dict)
197 completed_dir: str = "completed" # DEPRECATED: use IssueInfo.status instead
198 deferred_dir: str = "deferred" # DEPRECATED: use IssueInfo.status instead
199 priorities: list[str] = field(default_factory=lambda: ["P0", "P1", "P2", "P3", "P4", "P5"])
200 templates_dir: str | None = None
201 capture_template: str = "full"
202 duplicate_detection: DuplicateDetectionConfig = field(default_factory=DuplicateDetectionConfig)
203 next_issue: NextIssueConfig = field(default_factory=NextIssueConfig)
204 auto_commit: bool = False
205 auto_commit_prefix: str = "chore(issues)"
207 @classmethod
208 def from_dict(cls, data: dict[str, Any]) -> IssuesConfig:
209 """Create IssuesConfig from dictionary.
211 Required categories (bugs, features, enhancements) are automatically
212 included if not specified in user config.
213 """
214 # Start with user categories or empty dict
215 categories_data = dict(data.get("categories", {}))
217 # Ensure required categories exist (merge with defaults)
218 for key, defaults in REQUIRED_CATEGORIES.items():
219 if key not in categories_data:
220 categories_data[key] = defaults
222 categories = {
223 key: CategoryConfig.from_dict(key, value) for key, value in categories_data.items()
224 }
225 return cls(
226 base_dir=data.get("base_dir", ".issues"),
227 categories=categories,
228 completed_dir=data.get(
229 "completed_dir", "completed"
230 ), # deprecated: kept for backward compat
231 deferred_dir=data.get(
232 "deferred_dir", "deferred"
233 ), # deprecated: kept for backward compat
234 priorities=data.get("priorities", ["P0", "P1", "P2", "P3", "P4", "P5"]),
235 templates_dir=data.get("templates_dir"),
236 capture_template=data.get("capture_template", "full"),
237 duplicate_detection=DuplicateDetectionConfig.from_dict(
238 data.get("duplicate_detection", {})
239 ),
240 next_issue=NextIssueConfig.from_dict(data.get("next_issue", {})),
241 auto_commit=data.get("auto_commit", False),
242 auto_commit_prefix=data.get("auto_commit_prefix", "chore(issues)"),
243 )
245 def get_category_by_prefix(self, prefix: str) -> CategoryConfig | None:
246 """Get category config by prefix (e.g., 'BUG', 'FEAT').
248 Args:
249 prefix: Issue type prefix to look up
251 Returns:
252 CategoryConfig if found, None otherwise
253 """
254 for category in self.categories.values():
255 if category.prefix == prefix:
256 return category
257 return None
259 def get_category_by_dir(self, dir_name: str) -> CategoryConfig | None:
260 """Get category config by directory name.
262 Args:
263 dir_name: Directory name to look up
265 Returns:
266 CategoryConfig if found, None otherwise
267 """
268 for category in self.categories.values():
269 if category.dir == dir_name:
270 return category
271 return None
273 def get_all_prefixes(self) -> list[str]:
274 """Get all configured issue type prefixes.
276 Returns:
277 List of prefixes (e.g., ['BUG', 'FEAT', 'ENH'])
278 """
279 return [cat.prefix for cat in self.categories.values()]
281 def get_all_dirs(self) -> list[str]:
282 """Get all configured issue directory names.
284 Returns:
285 List of directory names (e.g., ['bugs', 'features', 'enhancements'])
286 """
287 return [cat.dir for cat in self.categories.values()]
290@dataclass
291class ScanConfig:
292 """Codebase scanning configuration."""
294 focus_dirs: list[str] = field(default_factory=lambda: ["src/", "tests/"])
295 exclude_patterns: list[str] = field(
296 default_factory=lambda: ["**/node_modules/**", "**/__pycache__/**", "**/.git/**"]
297 )
298 custom_agents: list[str] = field(default_factory=list)
300 @classmethod
301 def from_dict(cls, data: dict[str, Any]) -> ScanConfig:
302 """Create ScanConfig from dictionary."""
303 return cls(
304 focus_dirs=data.get("focus_dirs", ["src/", "tests/"]),
305 exclude_patterns=data.get(
306 "exclude_patterns",
307 ["**/node_modules/**", "**/__pycache__/**", "**/.git/**"],
308 ),
309 custom_agents=data.get("custom_agents", []),
310 )
313@dataclass
314class DesignTokensConfig:
315 """Design system token configuration.
317 `active` and `profiles_dir` (ENH-1768) select one of several bundled
318 profiles. The loader resolves token files from
319 `<path>/<profiles_dir or "profiles">/<active>/`, with a fallback to the
320 legacy flat layout (`<path>/primitives.json`, ...) for pre-ENH-1768
321 projects that haven't been re-initialized.
322 """
324 enabled: bool = True
325 path: str = ".ll/design-tokens"
326 primitives_file: str = "primitives.json"
327 semantic_file: str = "semantic.json"
328 themes_dir: str = "themes"
329 active_theme: str = "dark"
330 active: str = "default"
331 profiles_dir: str | None = None
333 @classmethod
334 def from_dict(cls, data: dict[str, Any]) -> DesignTokensConfig:
335 """Create DesignTokensConfig from dictionary."""
336 return cls(
337 enabled=data.get("enabled", True),
338 path=data.get("path", ".ll/design-tokens"),
339 primitives_file=data.get("primitives_file", "primitives.json"),
340 semantic_file=data.get("semantic_file", "semantic.json"),
341 themes_dir=data.get("themes_dir", "themes"),
342 active_theme=data.get("active_theme", "dark"),
343 active=data.get("active", "default"),
344 profiles_dir=data.get("profiles_dir"),
345 )
348@dataclass
349class SprintsConfig:
350 """Sprint management configuration."""
352 sprints_dir: str = ".sprints"
353 default_timeout: int = 3600
354 default_max_workers: int = 2
356 @classmethod
357 def from_dict(cls, data: dict[str, Any]) -> SprintsConfig:
358 """Create SprintsConfig from dictionary."""
359 return cls(
360 sprints_dir=data.get("sprints_dir", ".sprints"),
361 default_timeout=data.get("default_timeout", 3600),
362 default_max_workers=data.get("default_max_workers", 2),
363 )
366@dataclass
367class DiscoverabilityConfig:
368 """Controls how learning-test gaps are surfaced during implementation."""
370 mode: str = "warn"
371 skip_packages: list[str] = field(default_factory=lambda: ["std", "typing", "os", "sys"])
373 @classmethod
374 def from_dict(cls, data: dict[str, Any]) -> DiscoverabilityConfig:
375 """Create DiscoverabilityConfig from dictionary."""
376 return cls(
377 mode=data.get("mode", "warn"),
378 skip_packages=data.get("skip_packages", ["std", "typing", "os", "sys"]),
379 )
381 def to_dict(self) -> dict[str, Any]:
382 """Serialize DiscoverabilityConfig to dictionary."""
383 return {
384 "mode": self.mode,
385 "skip_packages": list(self.skip_packages),
386 }
389@dataclass
390class LearningTestsConfig:
391 """Learning test registry configuration."""
393 enabled: bool = False
394 stale_after_days: int = 30
395 discoverability: DiscoverabilityConfig = field(default_factory=DiscoverabilityConfig)
397 @classmethod
398 def from_dict(cls, data: dict[str, Any]) -> LearningTestsConfig:
399 """Create LearningTestsConfig from dictionary."""
400 return cls(
401 enabled=data.get("enabled", False),
402 stale_after_days=data.get("stale_after_days", 30),
403 discoverability=DiscoverabilityConfig.from_dict(data.get("discoverability", {})),
404 )
407@dataclass
408class DecisionsConfig:
409 """Decisions and rules log configuration."""
411 enabled: bool = False
412 log_path: str = ".ll/decisions.yaml"
413 auto_generate: list[str] = field(default_factory=list)
415 @classmethod
416 def from_dict(cls, data: dict[str, Any]) -> DecisionsConfig:
417 """Create DecisionsConfig from dictionary."""
418 return cls(
419 enabled=data.get("enabled", False),
420 log_path=data.get("log_path", ".ll/decisions.yaml"),
421 auto_generate=data.get("auto_generate", []),
422 )
425@dataclass
426class AnalyticsCaptureConfig:
427 """Configuration for analytics capture gating (ENH-1840).
429 Controls which skills, CLI commands, and event types are captured into the
430 unified session store. Used by ENH-1841 write-path gating via
431 ``feature_enabled_for()``.
432 """
434 skills: list[str] = field(default_factory=lambda: ["*"])
435 cli_commands: list[str] = field(default_factory=lambda: ["*"])
436 corrections: bool = True
437 file_events: bool = True
438 correction_patterns: list[str] = field(default_factory=list)
440 @classmethod
441 def from_dict(cls, data: dict[str, Any]) -> AnalyticsCaptureConfig:
442 """Create AnalyticsCaptureConfig from dictionary."""
443 raw = data.get("correction_patterns", [])
444 correction_patterns = (
445 [p for p in raw if isinstance(p, str)] if isinstance(raw, list) else []
446 )
447 return cls(
448 skills=data.get("skills", ["*"]),
449 cli_commands=data.get("cli_commands", ["*"]),
450 corrections=data.get("corrections", True),
451 file_events=data.get("file_events", True),
452 correction_patterns=correction_patterns,
453 )
456@dataclass
457class LoopsGlyphsConfig:
458 """Unicode badge/glyph overrides for FSM box diagram state badges."""
460 prompt: str = "\u2726" # ✦
461 slash_command: str = "/\u2501\u25ba" # /━►
462 shell: str = "\u276f_" # ❯_
463 mcp_tool: str = "\u26a1" # ⚡
464 sub_loop: str = "\u21b3\u27f3" # ↳⟳
465 route: str = "\u2443" # ⑃
466 parallel: str = "\u2225" # ∥
468 @classmethod
469 def from_dict(cls, data: dict[str, Any]) -> LoopsGlyphsConfig:
470 """Create LoopsGlyphsConfig from dictionary."""
471 return cls(
472 prompt=data.get("prompt", "\u2726"),
473 slash_command=data.get("slash_command", "/\u2501\u25ba"),
474 shell=data.get("shell", "\u276f_"),
475 mcp_tool=data.get("mcp_tool", "\u26a1"),
476 sub_loop=data.get("sub_loop", "\u21b3\u27f3"),
477 route=data.get("route", "\u2443"),
478 parallel=data.get("parallel", "\u2225"),
479 )
481 def to_dict(self) -> dict[str, str]:
482 """Convert to a glyph-key→string dict for use by _get_state_badge."""
483 return {
484 "prompt": self.prompt,
485 "slash_command": self.slash_command,
486 "shell": self.shell,
487 "mcp_tool": self.mcp_tool,
488 "sub_loop": self.sub_loop,
489 "route": self.route,
490 "parallel": self.parallel,
491 }
494@dataclass
495class LoopsConfig:
496 """FSM loop configuration."""
498 loops_dir: str = ".loops"
499 queue_wait_timeout_seconds: int = 86400
500 glyphs: LoopsGlyphsConfig = field(default_factory=LoopsGlyphsConfig)
502 @classmethod
503 def from_dict(cls, data: dict[str, Any]) -> LoopsConfig:
504 """Create LoopsConfig from dictionary."""
505 return cls(
506 loops_dir=data.get("loops_dir", ".loops"),
507 queue_wait_timeout_seconds=data.get("queue_wait_timeout_seconds", 86400),
508 glyphs=LoopsGlyphsConfig.from_dict(data.get("glyphs", {})),
509 )
512@dataclass
513class GitHubSyncConfig:
514 """GitHub-specific sync configuration."""
516 repo: str | None = None
517 label_mapping: dict[str, str] = field(
518 default_factory=lambda: {
519 "BUG": "bug",
520 "FEAT": "enhancement",
521 "ENH": "enhancement",
522 "EPIC": "epic",
523 }
524 )
525 priority_labels: bool = True
526 sync_completed: bool = False
527 state_file: str = ".ll/ll-sync-state.json"
528 pull_template: str = "minimal"
529 pull_limit: int = 500
531 @classmethod
532 def from_dict(cls, data: dict[str, Any]) -> GitHubSyncConfig:
533 """Create GitHubSyncConfig from dictionary."""
534 return cls(
535 repo=data.get("repo"),
536 label_mapping=data.get(
537 "label_mapping",
538 {"BUG": "bug", "FEAT": "enhancement", "ENH": "enhancement", "EPIC": "epic"},
539 ),
540 priority_labels=data.get("priority_labels", True),
541 sync_completed=data.get("sync_completed", False),
542 state_file=data.get("state_file", ".ll/ll-sync-state.json"),
543 pull_template=data.get("pull_template", "minimal"),
544 pull_limit=data.get("pull_limit", 500),
545 )
548@dataclass
549class SyncConfig:
550 """Issue sync configuration."""
552 enabled: bool = False
553 provider: str = "github"
554 github: GitHubSyncConfig = field(default_factory=GitHubSyncConfig)
556 @classmethod
557 def from_dict(cls, data: dict[str, Any]) -> SyncConfig:
558 """Create SyncConfig from dictionary."""
559 return cls(
560 enabled=data.get("enabled", False),
561 provider=data.get("provider", "github"),
562 github=GitHubSyncConfig.from_dict(data.get("github", {})),
563 )
566@dataclass
567class SocketEventsConfig:
568 """UnixSocketTransport configuration."""
570 path: str = ".ll/events.sock"
571 max_clients: int = 32
573 @classmethod
574 def from_dict(cls, data: dict[str, Any]) -> SocketEventsConfig:
575 """Create SocketEventsConfig from dictionary."""
576 return cls(
577 path=data.get("path", ".ll/events.sock"),
578 max_clients=data.get("max_clients", 32),
579 )
582@dataclass
583class OTelEventsConfig:
584 """OTelTransport configuration."""
586 endpoint: str = "http://localhost:4317"
587 service_name: str = "little-loops"
589 @classmethod
590 def from_dict(cls, data: dict[str, Any]) -> OTelEventsConfig:
591 """Create OTelEventsConfig from dictionary."""
592 return cls(
593 endpoint=data.get("endpoint", "http://localhost:4317"),
594 service_name=data.get("service_name", "little-loops"),
595 )
598@dataclass
599class WebhookEventsConfig:
600 """WebhookTransport configuration."""
602 url: str | None = None
603 batch_ms: int = 1000
604 headers: dict[str, str] = field(default_factory=dict)
606 @classmethod
607 def from_dict(cls, data: dict[str, Any]) -> WebhookEventsConfig:
608 """Create WebhookEventsConfig from dictionary."""
609 return cls(
610 url=data.get("url", None),
611 batch_ms=data.get("batch_ms", 1000),
612 headers=data.get("headers", {}),
613 )
616@dataclass
617class SqliteEventsConfig:
618 """SQLiteTransport configuration (unified session store, FEAT-1112)."""
620 path: str = ".ll/history.db"
622 @classmethod
623 def from_dict(cls, data: dict[str, Any]) -> SqliteEventsConfig:
624 """Create SqliteEventsConfig from dictionary."""
625 return cls(
626 path=data.get("path", ".ll/history.db"),
627 )
630@dataclass
631class EventsConfig:
632 """Event transport configuration.
634 Lists the transports to wire onto the EventBus at runtime. Names are
635 resolved against the registry in `little_loops.transport.wire_transports`;
636 unknown names are skipped with a warning.
637 """
639 transports: list[str] = field(default_factory=list)
640 socket: SocketEventsConfig = field(default_factory=SocketEventsConfig)
641 otel: OTelEventsConfig = field(default_factory=OTelEventsConfig)
642 webhook: WebhookEventsConfig = field(default_factory=WebhookEventsConfig)
643 sqlite: SqliteEventsConfig = field(default_factory=SqliteEventsConfig)
645 @classmethod
646 def from_dict(cls, data: dict[str, Any]) -> EventsConfig:
647 """Create EventsConfig from dictionary."""
648 return cls(
649 transports=data.get("transports", []),
650 socket=SocketEventsConfig.from_dict(data.get("socket", {})),
651 otel=OTelEventsConfig.from_dict(data.get("otel", {})),
652 webhook=WebhookEventsConfig.from_dict(data.get("webhook", {})),
653 sqlite=SqliteEventsConfig.from_dict(data.get("sqlite", {})),
654 )
657@dataclass
658class SessionDigestConfig:
659 """Session digest injection configuration (ENH-1907)."""
661 enabled: bool = False
662 days: int = 7
663 char_cap: int = 1200
664 sections: list[str] = field(default_factory=list)
666 @classmethod
667 def from_dict(cls, data: dict[str, Any]) -> SessionDigestConfig:
668 """Create SessionDigestConfig from dictionary."""
669 return cls(
670 enabled=data.get("enabled", False),
671 days=data.get("days", 7),
672 char_cap=data.get("char_cap", 1200),
673 sections=data.get("sections", []),
674 )
677@dataclass
678class EvolutionConfig:
679 """Feedback evolution configuration (ENH-1911)."""
681 feedback_min_recurrence: int = 2
682 bypass_min_count: int = 2
684 @classmethod
685 def from_dict(cls, data: dict[str, Any]) -> EvolutionConfig:
686 """Create EvolutionConfig from dictionary."""
687 return cls(
688 feedback_min_recurrence=data.get("feedback_min_recurrence", 2),
689 bypass_min_count=data.get("bypass_min_count", 2),
690 )
693@dataclass
694class GoNoGoConfig:
695 """Go/no-go history configuration (ENH-1914)."""
697 correction_penalty: float = -0.2
699 @classmethod
700 def from_dict(cls, data: dict[str, Any]) -> GoNoGoConfig:
701 """Create GoNoGoConfig from dictionary."""
702 return cls(
703 correction_penalty=data.get("correction_penalty", -0.2),
704 )
707@dataclass
708class CaptureIssueConfig:
709 """Capture-issue history configuration (ENH-1914)."""
711 dup_overlap_threshold: float = 0.7
713 @classmethod
714 def from_dict(cls, data: dict[str, Any]) -> CaptureIssueConfig:
715 """Create CaptureIssueConfig from dictionary."""
716 return cls(
717 dup_overlap_threshold=data.get("dup_overlap_threshold", 0.7),
718 )
721@dataclass
722class CompactionConfig:
723 """LCM-style compaction configuration for summary_nodes (FEAT-1712).
725 Controls whether backfill() generates LLM summaries over message_events blocks
726 via three-level LCM Algorithm 3 escalation (normal → aggressive bullet-point →
727 deterministic truncation). Disabled by default to avoid background LLM calls
728 without user opt-in.
730 Cross-session condensation (ENH-1954): when ``cross_session_enabled`` is True
731 (default), the compaction pass recurses over existing condensed nodes level by
732 level, grouping by token budget and summarising each group until exactly one
733 project-root summary node remains (``session_id IS NULL``, ``level = max``).
734 """
736 enabled: bool = False
737 budget_tokens: int = 4096
738 model: str | None = None
739 timeout: int = 60
740 cross_session_enabled: bool = True
741 max_level: int | None = None
743 @classmethod
744 def from_dict(cls, data: dict[str, Any]) -> CompactionConfig:
745 """Create CompactionConfig from dictionary. Lenient: ignores unknown keys."""
746 return cls(
747 enabled=data.get("enabled", False),
748 budget_tokens=data.get("budget_tokens", 4096),
749 model=data.get("model", None),
750 timeout=data.get("timeout", 60),
751 cross_session_enabled=data.get("cross_session_enabled", True),
752 max_level=data.get("max_level", None),
753 )
756@dataclass
757class RetentionConfig:
758 """Retention policy for history.db raw event tables (ENH-1906).
760 Pruning is dual-gated: both ``min_project_age_days`` and ``min_db_size_mb``
761 must be exceeded before any rows are deleted. Default thresholds are generous
762 so fresh or small projects are never affected.
764 Only high-volume tables are pruned (tool_events, cli_events, file_events,
765 message_events). High-value tables (issue_events, user_corrections) are
766 never pruned.
767 """
769 min_project_age_days: int = 365
770 min_db_size_mb: int = 800
771 raw_event_max_age_days: int | None = 90
773 @classmethod
774 def from_dict(cls, data: dict[str, Any]) -> RetentionConfig:
775 """Create RetentionConfig from dictionary. Lenient: ignores unknown keys."""
776 return cls(
777 min_project_age_days=data.get("min_project_age_days", 365),
778 min_db_size_mb=data.get("min_db_size_mb", 800),
779 raw_event_max_age_days=data.get("raw_event_max_age_days", 90),
780 )
783@dataclass
784class HistoryConfig:
785 """History read/consume configuration (ENH-1913).
787 Single, consistent foundation for .ll/history.db read/consume configurability.
788 Owns the complete history.* namespace; consumers wire runtime + CLI only.
789 """
791 velocity_window: int = 10
792 effort_fields: list[str] = field(default_factory=lambda: ["session_count", "cycle_time_days"])
793 max_age_days: int | None = None
794 planning_skills: list[str] = field(
795 default_factory=lambda: ["create-sprint", "scope-epic", "manage-issue", "review-epic"]
796 )
797 session_digest: SessionDigestConfig = field(default_factory=SessionDigestConfig)
798 evolution: EvolutionConfig = field(default_factory=EvolutionConfig)
799 go_no_go: GoNoGoConfig = field(default_factory=GoNoGoConfig)
800 capture_issue: CaptureIssueConfig = field(default_factory=CaptureIssueConfig)
801 compaction: CompactionConfig = field(default_factory=CompactionConfig)
803 @classmethod
804 def from_dict(cls, data: dict[str, Any]) -> HistoryConfig:
805 """Create HistoryConfig from dictionary. Lenient: ignores unknown keys, never raises."""
806 return cls(
807 velocity_window=data.get("velocity_window", 10),
808 effort_fields=data.get("effort_fields", ["session_count", "cycle_time_days"]),
809 max_age_days=data.get("max_age_days", None),
810 planning_skills=data.get(
811 "planning_skills",
812 ["create-sprint", "scope-epic", "manage-issue", "review-epic"],
813 ),
814 session_digest=SessionDigestConfig.from_dict(data.get("session_digest", {})),
815 evolution=EvolutionConfig.from_dict(data.get("evolution", {})),
816 go_no_go=GoNoGoConfig.from_dict(data.get("go_no_go", {})),
817 capture_issue=CaptureIssueConfig.from_dict(data.get("capture_issue", {})),
818 compaction=CompactionConfig.from_dict(data.get("compaction", {})),
819 )