Coverage for little_loops / config / features.py: 88%
328 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-16 13:12 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-16 13:12 -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
355 max_issue_wall_clock_time: int = 2700
357 @classmethod
358 def from_dict(cls, data: dict[str, Any]) -> SprintsConfig:
359 """Create SprintsConfig from dictionary."""
360 return cls(
361 sprints_dir=data.get("sprints_dir", ".sprints"),
362 default_timeout=data.get("default_timeout", 3600),
363 default_max_workers=data.get("default_max_workers", 2),
364 max_issue_wall_clock_time=data.get("max_issue_wall_clock_time", 2700),
365 )
368@dataclass
369class DiscoverabilityConfig:
370 """Controls how learning-test gaps are surfaced during implementation."""
372 mode: str = "warn"
373 skip_packages: list[str] = field(default_factory=lambda: ["std", "typing", "os", "sys"])
375 @classmethod
376 def from_dict(cls, data: dict[str, Any]) -> DiscoverabilityConfig:
377 """Create DiscoverabilityConfig from dictionary."""
378 return cls(
379 mode=data.get("mode", "warn"),
380 skip_packages=data.get("skip_packages", ["std", "typing", "os", "sys"]),
381 )
383 def to_dict(self) -> dict[str, Any]:
384 """Serialize DiscoverabilityConfig to dictionary."""
385 return {
386 "mode": self.mode,
387 "skip_packages": list(self.skip_packages),
388 }
391@dataclass
392class LearningTestsConfig:
393 """Learning test registry configuration."""
395 enabled: bool = False
396 stale_after_days: int = 30
397 discoverability: DiscoverabilityConfig = field(default_factory=DiscoverabilityConfig)
399 @classmethod
400 def from_dict(cls, data: dict[str, Any]) -> LearningTestsConfig:
401 """Create LearningTestsConfig from dictionary."""
402 return cls(
403 enabled=data.get("enabled", False),
404 stale_after_days=data.get("stale_after_days", 30),
405 discoverability=DiscoverabilityConfig.from_dict(data.get("discoverability", {})),
406 )
409@dataclass
410class DecisionsConfig:
411 """Decisions and rules log configuration."""
413 enabled: bool = False
414 log_path: str = ".ll/decisions.yaml"
415 auto_generate: list[str] = field(default_factory=list)
417 @classmethod
418 def from_dict(cls, data: dict[str, Any]) -> DecisionsConfig:
419 """Create DecisionsConfig from dictionary."""
420 return cls(
421 enabled=data.get("enabled", False),
422 log_path=data.get("log_path", ".ll/decisions.yaml"),
423 auto_generate=data.get("auto_generate", []),
424 )
427@dataclass
428class AnalyticsCaptureConfig:
429 """Configuration for analytics capture gating (ENH-1840).
431 Controls which skills, CLI commands, and event types are captured into the
432 unified session store. Used by ENH-1841 write-path gating via
433 ``feature_enabled_for()``.
434 """
436 skills: list[str] = field(default_factory=lambda: ["*"])
437 cli_commands: list[str] = field(default_factory=lambda: ["*"])
438 corrections: bool = True
439 file_events: bool = True
440 correction_patterns: list[str] = field(default_factory=list)
442 @classmethod
443 def from_dict(cls, data: dict[str, Any]) -> AnalyticsCaptureConfig:
444 """Create AnalyticsCaptureConfig from dictionary."""
445 raw = data.get("correction_patterns", [])
446 correction_patterns = (
447 [p for p in raw if isinstance(p, str)] if isinstance(raw, list) else []
448 )
449 return cls(
450 skills=data.get("skills", ["*"]),
451 cli_commands=data.get("cli_commands", ["*"]),
452 corrections=data.get("corrections", True),
453 file_events=data.get("file_events", True),
454 correction_patterns=correction_patterns,
455 )
458@dataclass
459class LoopsGlyphsConfig:
460 """Unicode badge/glyph overrides for FSM box diagram state badges."""
462 prompt: str = "\u2726" # ✦
463 slash_command: str = "/\u2501\u25ba" # /━►
464 shell: str = "\u276f_" # ❯_
465 mcp_tool: str = "\u26a1" # ⚡
466 sub_loop: str = "\u21b3\u27f3" # ↳⟳
467 route: str = "\u2443" # ⑃
468 parallel: str = "\u2225" # ∥
470 @classmethod
471 def from_dict(cls, data: dict[str, Any]) -> LoopsGlyphsConfig:
472 """Create LoopsGlyphsConfig from dictionary."""
473 return cls(
474 prompt=data.get("prompt", "\u2726"),
475 slash_command=data.get("slash_command", "/\u2501\u25ba"),
476 shell=data.get("shell", "\u276f_"),
477 mcp_tool=data.get("mcp_tool", "\u26a1"),
478 sub_loop=data.get("sub_loop", "\u21b3\u27f3"),
479 route=data.get("route", "\u2443"),
480 parallel=data.get("parallel", "\u2225"),
481 )
483 def to_dict(self) -> dict[str, str]:
484 """Convert to a glyph-key→string dict for use by _get_state_badge."""
485 return {
486 "prompt": self.prompt,
487 "slash_command": self.slash_command,
488 "shell": self.shell,
489 "mcp_tool": self.mcp_tool,
490 "sub_loop": self.sub_loop,
491 "route": self.route,
492 "parallel": self.parallel,
493 }
496_VALID_SHOW_DIAGRAMS: frozenset[str] = frozenset(
497 {
498 "layered",
499 "neighborhood",
500 "inline", # topologies
501 "detailed",
502 "summary",
503 "clean",
504 "local", # presets
505 "slim",
506 "oneline", # presets (continued)
507 "default", # bare --show-diagrams sentinel
508 }
509)
512@dataclass
513class LoopRunDefaults:
514 """Persistent CLI defaults for ``ll-loop run``."""
516 clear: bool = False
517 show_diagrams: str | None = None
518 mode: str | None = None
520 @classmethod
521 def from_dict(cls, data: dict[str, Any]) -> LoopRunDefaults:
522 """Create LoopRunDefaults from dictionary, validating show_diagrams."""
523 show_diagrams = data.get("show_diagrams", None)
524 if show_diagrams is not None and show_diagrams not in _VALID_SHOW_DIAGRAMS:
525 raise ValueError(
526 f"loops.run_defaults.show_diagrams: {show_diagrams!r} is not valid. "
527 f"Valid values: {sorted(_VALID_SHOW_DIAGRAMS)}"
528 )
529 return cls(
530 clear=data.get("clear", False),
531 show_diagrams=show_diagrams,
532 mode=data.get("mode", None),
533 )
536@dataclass
537class LoopsConfig:
538 """FSM loop configuration."""
540 loops_dir: str = ".loops"
541 queue_wait_timeout_seconds: int = 86400
542 glyphs: LoopsGlyphsConfig = field(default_factory=LoopsGlyphsConfig)
543 run_defaults: LoopRunDefaults = field(default_factory=LoopRunDefaults)
545 @classmethod
546 def from_dict(cls, data: dict[str, Any]) -> LoopsConfig:
547 """Create LoopsConfig from dictionary."""
548 return cls(
549 loops_dir=data.get("loops_dir", ".loops"),
550 queue_wait_timeout_seconds=data.get("queue_wait_timeout_seconds", 86400),
551 glyphs=LoopsGlyphsConfig.from_dict(data.get("glyphs", {})),
552 run_defaults=LoopRunDefaults.from_dict(data.get("run_defaults", {})),
553 )
556@dataclass
557class GitHubSyncConfig:
558 """GitHub-specific sync configuration."""
560 repo: str | None = None
561 label_mapping: dict[str, str] = field(
562 default_factory=lambda: {
563 "BUG": "bug",
564 "FEAT": "enhancement",
565 "ENH": "enhancement",
566 "EPIC": "epic",
567 }
568 )
569 priority_labels: bool = True
570 sync_completed: bool = False
571 state_file: str = ".ll/ll-sync-state.json"
572 pull_template: str = "minimal"
573 pull_limit: int = 500
575 @classmethod
576 def from_dict(cls, data: dict[str, Any]) -> GitHubSyncConfig:
577 """Create GitHubSyncConfig from dictionary."""
578 return cls(
579 repo=data.get("repo"),
580 label_mapping=data.get(
581 "label_mapping",
582 {"BUG": "bug", "FEAT": "enhancement", "ENH": "enhancement", "EPIC": "epic"},
583 ),
584 priority_labels=data.get("priority_labels", True),
585 sync_completed=data.get("sync_completed", False),
586 state_file=data.get("state_file", ".ll/ll-sync-state.json"),
587 pull_template=data.get("pull_template", "minimal"),
588 pull_limit=data.get("pull_limit", 500),
589 )
592@dataclass
593class SyncConfig:
594 """Issue sync configuration."""
596 enabled: bool = False
597 provider: str = "github"
598 github: GitHubSyncConfig = field(default_factory=GitHubSyncConfig)
600 @classmethod
601 def from_dict(cls, data: dict[str, Any]) -> SyncConfig:
602 """Create SyncConfig from dictionary."""
603 return cls(
604 enabled=data.get("enabled", False),
605 provider=data.get("provider", "github"),
606 github=GitHubSyncConfig.from_dict(data.get("github", {})),
607 )
610@dataclass
611class SocketEventsConfig:
612 """UnixSocketTransport configuration."""
614 path: str = ".ll/events.sock"
615 max_clients: int = 32
617 @classmethod
618 def from_dict(cls, data: dict[str, Any]) -> SocketEventsConfig:
619 """Create SocketEventsConfig from dictionary."""
620 return cls(
621 path=data.get("path", ".ll/events.sock"),
622 max_clients=data.get("max_clients", 32),
623 )
626@dataclass
627class OTelEventsConfig:
628 """OTelTransport configuration."""
630 endpoint: str = "http://localhost:4317"
631 service_name: str = "little-loops"
633 @classmethod
634 def from_dict(cls, data: dict[str, Any]) -> OTelEventsConfig:
635 """Create OTelEventsConfig from dictionary."""
636 return cls(
637 endpoint=data.get("endpoint", "http://localhost:4317"),
638 service_name=data.get("service_name", "little-loops"),
639 )
642@dataclass
643class WebhookEventsConfig:
644 """WebhookTransport configuration."""
646 url: str | None = None
647 batch_ms: int = 1000
648 headers: dict[str, str] = field(default_factory=dict)
650 @classmethod
651 def from_dict(cls, data: dict[str, Any]) -> WebhookEventsConfig:
652 """Create WebhookEventsConfig from dictionary."""
653 return cls(
654 url=data.get("url", None),
655 batch_ms=data.get("batch_ms", 1000),
656 headers=data.get("headers", {}),
657 )
660@dataclass
661class SqliteEventsConfig:
662 """SQLiteTransport configuration (unified session store, FEAT-1112)."""
664 path: str = ".ll/history.db"
666 @classmethod
667 def from_dict(cls, data: dict[str, Any]) -> SqliteEventsConfig:
668 """Create SqliteEventsConfig from dictionary."""
669 return cls(
670 path=data.get("path", ".ll/history.db"),
671 )
674@dataclass
675class EventsConfig:
676 """Event transport configuration.
678 Lists the transports to wire onto the EventBus at runtime. Names are
679 resolved against the registry in `little_loops.transport.wire_transports`;
680 unknown names are skipped with a warning.
681 """
683 transports: list[str] = field(default_factory=list)
684 socket: SocketEventsConfig = field(default_factory=SocketEventsConfig)
685 otel: OTelEventsConfig = field(default_factory=OTelEventsConfig)
686 webhook: WebhookEventsConfig = field(default_factory=WebhookEventsConfig)
687 sqlite: SqliteEventsConfig = field(default_factory=SqliteEventsConfig)
689 @classmethod
690 def from_dict(cls, data: dict[str, Any]) -> EventsConfig:
691 """Create EventsConfig from dictionary."""
692 return cls(
693 transports=data.get("transports", []),
694 socket=SocketEventsConfig.from_dict(data.get("socket", {})),
695 otel=OTelEventsConfig.from_dict(data.get("otel", {})),
696 webhook=WebhookEventsConfig.from_dict(data.get("webhook", {})),
697 sqlite=SqliteEventsConfig.from_dict(data.get("sqlite", {})),
698 )
701@dataclass
702class SessionDigestConfig:
703 """Session digest injection configuration (ENH-1907)."""
705 enabled: bool = True
706 days: int = 7
707 char_cap: int = 800
708 sections: list[str] = field(default_factory=list)
710 @classmethod
711 def from_dict(cls, data: dict[str, Any]) -> SessionDigestConfig:
712 """Create SessionDigestConfig from dictionary."""
713 return cls(
714 enabled=data.get("enabled", True),
715 days=data.get("days", 7),
716 char_cap=data.get("char_cap", 800),
717 sections=data.get("sections", []),
718 )
721@dataclass
722class EvolutionConfig:
723 """Feedback evolution configuration (ENH-1911)."""
725 feedback_min_recurrence: int = 2
726 bypass_min_count: int = 2
728 @classmethod
729 def from_dict(cls, data: dict[str, Any]) -> EvolutionConfig:
730 """Create EvolutionConfig from dictionary."""
731 return cls(
732 feedback_min_recurrence=data.get("feedback_min_recurrence", 2),
733 bypass_min_count=data.get("bypass_min_count", 2),
734 )
737@dataclass
738class GoNoGoConfig:
739 """Go/no-go history configuration (ENH-1914)."""
741 correction_penalty: float = -0.2
743 @classmethod
744 def from_dict(cls, data: dict[str, Any]) -> GoNoGoConfig:
745 """Create GoNoGoConfig from dictionary."""
746 return cls(
747 correction_penalty=data.get("correction_penalty", -0.2),
748 )
751@dataclass
752class CaptureIssueConfig:
753 """Capture-issue history configuration (ENH-1914)."""
755 dup_overlap_threshold: float = 0.7
757 @classmethod
758 def from_dict(cls, data: dict[str, Any]) -> CaptureIssueConfig:
759 """Create CaptureIssueConfig from dictionary."""
760 return cls(
761 dup_overlap_threshold=data.get("dup_overlap_threshold", 0.7),
762 )
765@dataclass
766class CompactionConfig:
767 """LCM-style compaction configuration for summary_nodes (FEAT-1712).
769 Controls whether backfill() generates LLM summaries over message_events blocks
770 via three-level LCM Algorithm 3 escalation (normal → aggressive bullet-point →
771 deterministic truncation). Disabled by default to avoid background LLM calls
772 without user opt-in.
774 Cross-session condensation (ENH-1954): when ``cross_session_enabled`` is True
775 (default), the compaction pass recurses over existing condensed nodes level by
776 level, grouping by token budget and summarising each group until exactly one
777 project-root summary node remains (``session_id IS NULL``, ``level = max``).
778 """
780 enabled: bool = False
781 budget_tokens: int = 4096
782 model: str | None = None
783 timeout: int = 60
784 cross_session_enabled: bool = True
785 max_level: int | None = None
787 @classmethod
788 def from_dict(cls, data: dict[str, Any]) -> CompactionConfig:
789 """Create CompactionConfig from dictionary. Lenient: ignores unknown keys."""
790 return cls(
791 enabled=data.get("enabled", False),
792 budget_tokens=data.get("budget_tokens", 4096),
793 model=data.get("model", None),
794 timeout=data.get("timeout", 60),
795 cross_session_enabled=data.get("cross_session_enabled", True),
796 max_level=data.get("max_level", None),
797 )
800@dataclass
801class RetentionConfig:
802 """Retention policy for history.db raw event tables (ENH-1906).
804 Pruning is dual-gated: both ``min_project_age_days`` and ``min_db_size_mb``
805 must be exceeded before any rows are deleted. Default thresholds are generous
806 so fresh or small projects are never affected.
808 Only high-volume tables are pruned (tool_events, cli_events, file_events,
809 message_events). High-value tables (issue_events, user_corrections) are
810 never pruned.
811 """
813 min_project_age_days: int = 365
814 min_db_size_mb: int = 800
815 raw_event_max_age_days: int | None = 90
817 @classmethod
818 def from_dict(cls, data: dict[str, Any]) -> RetentionConfig:
819 """Create RetentionConfig from dictionary. Lenient: ignores unknown keys."""
820 return cls(
821 min_project_age_days=data.get("min_project_age_days", 365),
822 min_db_size_mb=data.get("min_db_size_mb", 800),
823 raw_event_max_age_days=data.get("raw_event_max_age_days", 90),
824 )
827@dataclass
828class HistoryConfig:
829 """History read/consume configuration (ENH-1913).
831 Single, consistent foundation for .ll/history.db read/consume configurability.
832 Owns the complete history.* namespace; consumers wire runtime + CLI only.
833 """
835 velocity_window: int = 10
836 effort_fields: list[str] = field(default_factory=lambda: ["session_count", "cycle_time_days"])
837 max_age_days: int | None = None
838 planning_skills: list[str] = field(
839 default_factory=lambda: ["create-sprint", "scope-epic", "manage-issue", "review-epic"]
840 )
841 session_digest: SessionDigestConfig = field(default_factory=SessionDigestConfig)
842 evolution: EvolutionConfig = field(default_factory=EvolutionConfig)
843 go_no_go: GoNoGoConfig = field(default_factory=GoNoGoConfig)
844 capture_issue: CaptureIssueConfig = field(default_factory=CaptureIssueConfig)
845 compaction: CompactionConfig = field(default_factory=CompactionConfig)
847 @classmethod
848 def from_dict(cls, data: dict[str, Any]) -> HistoryConfig:
849 """Create HistoryConfig from dictionary. Lenient: ignores unknown keys, never raises."""
850 return cls(
851 velocity_window=data.get("velocity_window", 10),
852 effort_fields=data.get("effort_fields", ["session_count", "cycle_time_days"]),
853 max_age_days=data.get("max_age_days", None),
854 planning_skills=data.get(
855 "planning_skills",
856 ["create-sprint", "scope-epic", "manage-issue", "review-epic"],
857 ),
858 session_digest=SessionDigestConfig.from_dict(data.get("session_digest", {})),
859 evolution=EvolutionConfig.from_dict(data.get("evolution", {})),
860 go_no_go=GoNoGoConfig.from_dict(data.get("go_no_go", {})),
861 capture_issue=CaptureIssueConfig.from_dict(data.get("capture_issue", {})),
862 compaction=CompactionConfig.from_dict(data.get("compaction", {})),
863 )