Coverage for little_loops / config / core.py: 48%
202 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-29 00:54 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-29 00:54 -0500
1"""Core configuration dataclasses and the root BRConfig aggregator.
3ProjectConfig holds project-level settings. BRConfig is the single entry
4point that loads ll-config.json and exposes all domain configs via properties.
5"""
7from __future__ import annotations
9import json
10import os
11import warnings
12from dataclasses import dataclass
13from pathlib import Path
14from typing import Any, cast
16from little_loops.config.automation import (
17 AutomationConfig,
18 CommandsConfig,
19 DependencyMappingConfig,
20 ParallelAutomationConfig,
21)
22from little_loops.config.cli import CliConfig, RefineStatusConfig
23from little_loops.config.features import (
24 AnalyticsCaptureConfig,
25 DecisionsConfig,
26 DesignTokensConfig,
27 EventsConfig,
28 HistoryConfig,
29 IssuesConfig,
30 LearningTestsConfig,
31 LoopsConfig,
32 ScanConfig,
33 SprintsConfig,
34 SyncConfig,
35)
36from little_loops.config.orchestration import OrchestrationConfig
37from little_loops.parallel.types import ParallelConfig
39CONFIG_FILENAME = "ll-config.json"
40CONFIG_DIR = ".ll"
41CODEX_CONFIG_DIR = ".codex"
44def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
45 """Deep-merge *override* into *base* for config-style overlays.
47 Python port of the inline bash version at ``hooks/scripts/session-start.sh:30-43``
48 used to apply ``.ll/ll.local.md`` frontmatter overrides on top of the base
49 ``ll-config.json``. Semantics:
51 - Nested dicts are merged recursively at every level.
52 - All other value types (strings, ints, bools, lists) **replace** the base
53 value — arrays do not append.
54 - An explicit ``None`` in *override* **removes** the key from the result.
56 Differs from ``little_loops.fsm.fragments._deep_merge`` only in the
57 null-removal semantic — fragments-merge passes ``None`` through as a value,
58 while config-merge treats it as a key-removal sentinel. The config variant
59 is required for ``ll.local.md`` to be able to unset base keys.
61 Returns a new dict; neither input is mutated.
62 """
63 result = dict(base)
64 for key, value in override.items():
65 if value is None:
66 result.pop(key, None)
67 elif isinstance(value, dict) and isinstance(result.get(key), dict):
68 result[key] = deep_merge(result[key], value)
69 else:
70 result[key] = value
71 return result
74def _config_candidates(
75 project_root: Path, *, host: str | None, state_dir: str | None
76) -> list[Path]:
77 """Return the ordered list of ``ll-config.json`` candidate paths to probe.
79 Default order (no host env vars set): ``.ll/ll-config.json`` then
80 root-level ``ll-config.json`` — preserves the historical behavior of
81 :func:`resolve_config_path` before host-aware probing was added.
83 When ``host == "codex"`` or ``state_dir == ".codex"`` (FEAT-957),
84 ``.codex/ll-config.json`` is prepended so Codex CLI projects pick up
85 their host-specific config before falling through to the default
86 candidates. Other host values pass through unchanged.
88 Future hosts (e.g. FEAT-992 Pi) add a new branch here rather than a
89 new code path elsewhere.
90 """
91 candidates: list[Path] = []
92 if host == "codex" or state_dir == CODEX_CONFIG_DIR:
93 candidates.append(project_root / CODEX_CONFIG_DIR / CONFIG_FILENAME)
94 candidates.append(project_root / CONFIG_DIR / CONFIG_FILENAME)
95 candidates.append(project_root / CONFIG_FILENAME)
96 return candidates
99def resolve_config_path(project_root: Path) -> Path | None:
100 """Return the path to ``ll-config.json`` for *project_root* if found.
102 Iterates an ordered candidate list (see :func:`_config_candidates`) and
103 returns the first existing file. The default order is
104 ``<root>/.ll/ll-config.json`` then ``<root>/ll-config.json`` (parity
105 with the legacy bash ``ll_resolve_config``); when ``LL_HOOK_HOST=codex``
106 or ``LL_STATE_DIR=.codex`` is set on the environment,
107 ``<root>/.codex/ll-config.json`` is probed first (FEAT-957).
109 Pure lookup — does not create directories or mutate global state (the
110 bash version's ``mkdir -p .ll`` side effect is intentionally dropped;
111 callers that need ``.ll/`` to exist must create it themselves).
113 Returns ``None`` if no candidate exists.
114 """
115 host = os.environ.get("LL_HOOK_HOST")
116 state_dir = os.environ.get("LL_STATE_DIR")
117 for candidate in _config_candidates(project_root, host=host, state_dir=state_dir):
118 if candidate.is_file():
119 return candidate
120 return None
123@dataclass
124class ProjectConfig:
125 """Project-level configuration."""
127 name: str = ""
128 src_dir: str = "src/"
129 test_dir: str = "tests"
130 test_cmd: str = "pytest"
131 lint_cmd: str = "ruff check ."
132 type_cmd: str | None = "mypy"
133 format_cmd: str | None = "ruff format ."
134 build_cmd: str | None = None
135 run_cmd: str | None = None
137 @classmethod
138 def from_dict(cls, data: dict[str, Any]) -> ProjectConfig:
139 """Create ProjectConfig from dictionary."""
140 return cls(
141 name=data.get("name", ""),
142 src_dir=data.get("src_dir", "src/"),
143 test_dir=data.get("test_dir", "tests"),
144 test_cmd=data.get("test_cmd", "pytest"),
145 lint_cmd=data.get("lint_cmd", "ruff check ."),
146 type_cmd=data.get("type_cmd", "mypy"),
147 format_cmd=data.get("format_cmd", "ruff format ."),
148 build_cmd=data.get("build_cmd"),
149 run_cmd=data.get("run_cmd"),
150 )
153class BRConfig:
154 """Main configuration class for little-loops.
156 Loads configuration from .ll/ll-config.json and merges with defaults.
157 Provides convenient property access to all configuration values.
159 Example:
160 config = BRConfig(Path.cwd())
161 print(config.project.src_dir) # "src/"
162 print(config.issues.base_dir) # ".issues"
163 print(config.get_issue_dir("bugs")) # Path(".issues/bugs")
164 """
166 CONFIG_FILENAME = CONFIG_FILENAME
167 CONFIG_DIR = CONFIG_DIR
169 def __init__(self, project_root: Path) -> None:
170 """Initialize configuration from project root.
172 Args:
173 project_root: Path to the project root directory
174 """
175 self.project_root = project_root.resolve()
176 self._raw_config = self._load_config()
177 self._parse_config()
179 def _load_config(self) -> dict[str, Any]:
180 """Load configuration from file.
182 Uses :func:`resolve_config_path` which checks ``.ll/ll-config.json``
183 first then falls back to a root-level ``ll-config.json`` (parity with
184 ``hooks/scripts/lib/common.sh:ll_resolve_config``).
185 """
186 config_path = resolve_config_path(self.project_root)
187 if config_path is not None:
188 with open(config_path, encoding="utf-8") as f:
189 return cast(dict[str, Any], json.load(f))
190 return {}
192 def _parse_config(self) -> None:
193 """Parse raw config into typed dataclasses."""
194 self._project = ProjectConfig.from_dict(self._raw_config.get("project", {}))
195 if not self._project.name:
196 self._project.name = self.project_root.name
198 self._issues = IssuesConfig.from_dict(self._raw_config.get("issues", {}))
199 self._automation = AutomationConfig.from_dict(self._raw_config.get("automation", {}))
200 self._parallel = ParallelAutomationConfig.from_dict(self._raw_config.get("parallel", {}))
201 self._commands = CommandsConfig.from_dict(self._raw_config.get("commands", {}))
202 self._scan = ScanConfig.from_dict(self._raw_config.get("scan", {}))
203 self._sprints = SprintsConfig.from_dict(self._raw_config.get("sprints", {}))
204 self._loops = LoopsConfig.from_dict(self._raw_config.get("loops", {}))
205 self._learning_tests = LearningTestsConfig.from_dict(
206 self._raw_config.get("learning_tests", {})
207 )
208 self._decisions = DecisionsConfig.from_dict(self._raw_config.get("decisions", {}))
209 self._sync = SyncConfig.from_dict(self._raw_config.get("sync", {}))
210 self._dependency_mapping = DependencyMappingConfig.from_dict(
211 self._raw_config.get("dependency_mapping", {})
212 )
213 self._cli = CliConfig.from_dict(self._raw_config.get("cli", {}))
214 self._refine_status = RefineStatusConfig.from_dict(
215 self._raw_config.get("refine_status", {})
216 )
217 self._events = EventsConfig.from_dict(self._raw_config.get("events", {}))
218 self._orchestration = OrchestrationConfig.from_dict(
219 self._raw_config.get("orchestration", {})
220 )
221 self._design_tokens = DesignTokensConfig.from_dict(
222 self._raw_config.get("design_tokens", {})
223 )
224 self._analytics_capture = AnalyticsCaptureConfig.from_dict(
225 self._raw_config.get("analytics", {}).get("capture", {})
226 )
227 self._history = HistoryConfig.from_dict(self._raw_config.get("history", {}))
229 @property
230 def project(self) -> ProjectConfig:
231 """Get project configuration."""
232 return self._project
234 @property
235 def issues(self) -> IssuesConfig:
236 """Get issues configuration."""
237 return self._issues
239 @property
240 def automation(self) -> AutomationConfig:
241 """Get automation configuration."""
242 return self._automation
244 @property
245 def parallel(self) -> ParallelAutomationConfig:
246 """Get parallel automation configuration."""
247 return self._parallel
249 @property
250 def commands(self) -> CommandsConfig:
251 """Get commands configuration."""
252 return self._commands
254 @property
255 def scan(self) -> ScanConfig:
256 """Get scan configuration."""
257 return self._scan
259 @property
260 def sprints(self) -> SprintsConfig:
261 """Get sprints configuration."""
262 return self._sprints
264 @property
265 def loops(self) -> LoopsConfig:
266 """Get loops configuration."""
267 return self._loops
269 @property
270 def learning_tests(self) -> LearningTestsConfig:
271 """Get learning tests configuration."""
272 return self._learning_tests
274 @property
275 def decisions(self) -> DecisionsConfig:
276 """Get decisions log configuration."""
277 return self._decisions
279 @property
280 def sync(self) -> SyncConfig:
281 """Get sync configuration."""
282 return self._sync
284 @property
285 def dependency_mapping(self) -> DependencyMappingConfig:
286 """Get dependency mapping configuration."""
287 return self._dependency_mapping
289 @property
290 def cli(self) -> CliConfig:
291 """Get CLI output configuration."""
292 return self._cli
294 @property
295 def refine_status(self) -> RefineStatusConfig:
296 """Get refine-status display configuration."""
297 return self._refine_status
299 @property
300 def events(self) -> EventsConfig:
301 """Get events configuration."""
302 return self._events
304 @property
305 def orchestration(self) -> OrchestrationConfig:
306 """Get orchestration configuration."""
307 return self._orchestration
309 @property
310 def design_tokens(self) -> DesignTokensConfig:
311 """Get design tokens configuration."""
312 return self._design_tokens
314 @property
315 def analytics_capture(self) -> AnalyticsCaptureConfig:
316 """Get analytics capture configuration."""
317 return self._analytics_capture
319 @property
320 def history(self) -> HistoryConfig:
321 """Get history read/consume configuration."""
322 return self._history
324 @property
325 def extensions(self) -> list[str]:
326 """Get extension config paths (e.g. ``["module:Class", ...]``)."""
327 return self._raw_config.get("extensions", [])
329 @property
330 def repo_path(self) -> Path:
331 """Get the repository root path."""
332 return self.project_root
334 # Convenience methods for common operations
336 def get_issue_dir(self, category: str) -> Path:
337 """Get the directory path for an issue category.
339 Args:
340 category: Category key (e.g., "bugs", "features")
342 Returns:
343 Path to the issue category directory
344 """
345 if category in self._issues.categories:
346 dir_name = self._issues.categories[category].dir
347 else:
348 dir_name = category
349 return self.project_root / self._issues.base_dir / dir_name
351 def get_completed_dir(self) -> Path:
352 """Get the path to the completed issues directory."""
353 warnings.warn(
354 "BRConfig.get_completed_dir() is deprecated; use IssueInfo.status instead",
355 DeprecationWarning,
356 stacklevel=2,
357 )
358 return self.project_root / self._issues.base_dir / self._issues.completed_dir
360 def get_deferred_dir(self) -> Path:
361 """Get the path to the deferred issues directory."""
362 warnings.warn(
363 "BRConfig.get_deferred_dir() is deprecated; use IssueInfo.status instead",
364 DeprecationWarning,
365 stacklevel=2,
366 )
367 return self.project_root / self._issues.base_dir / self._issues.deferred_dir
369 def get_issue_prefix(self, category: str) -> str:
370 """Get the issue ID prefix for a category.
372 Args:
373 category: Category key (e.g., "bugs", "features")
375 Returns:
376 Issue prefix (e.g., "BUG", "FEAT")
377 """
378 if category in self._issues.categories:
379 return self._issues.categories[category].prefix
380 return category.upper()[:3]
382 def get_category_action(self, category: str) -> str:
383 """Get the default action for a category.
385 Args:
386 category: Category key (e.g., "bugs", "features")
388 Returns:
389 Action verb (e.g., "fix", "implement")
390 """
391 if category in self._issues.categories:
392 return self._issues.categories[category].action
393 return "fix"
395 def get_loops_dir(self) -> Path:
396 """Get the loops directory path."""
397 return self.project_root / self._loops.loops_dir
399 def get_src_path(self) -> Path:
400 """Get the source directory path."""
401 return self.project_root / self._project.src_dir
403 def get_worktree_base(self) -> Path:
404 """Get the worktree base directory path."""
405 return self.project_root / self._automation.worktree_base
407 def get_state_file(self) -> Path:
408 """Get the state file path."""
409 return self.project_root / self._automation.state_file
411 def get_parallel_state_file(self) -> Path:
412 """Get the parallel state file path."""
413 return self.project_root / self._parallel.base.state_file
415 def create_parallel_config(
416 self,
417 *,
418 max_workers: int | None = None,
419 priority_filter: list[str] | None = None,
420 max_issues: int = 0,
421 dry_run: bool = False,
422 timeout_seconds: int | None = None,
423 idle_timeout_per_issue: int | None = None,
424 stream_output: bool | None = None,
425 show_model: bool | None = None,
426 only_ids: set[str] | None = None,
427 skip_ids: set[str] | None = None,
428 type_prefixes: set[str] | None = None,
429 label_filter: set[str] | None = None,
430 merge_pending: bool = False,
431 clean_start: bool = False,
432 ignore_pending: bool = False,
433 overlap_detection: bool = False,
434 serialize_overlapping: bool = True,
435 base_branch: str | None = None,
436 remote_name: str | None = None,
437 use_feature_branches: bool | None = None,
438 skip_learning_gate: bool = False,
439 ) -> ParallelConfig:
440 """Create a ParallelConfig from BRConfig settings with optional overrides.
442 Args:
443 max_workers: Override max_workers (default: from config)
444 priority_filter: Override priority filter (default: from issues config)
445 max_issues: Maximum issues to process (default: 0 = unlimited)
446 dry_run: Preview mode (default: False)
447 timeout_seconds: Per-issue timeout (default: from config)
448 idle_timeout_per_issue: Kill worker if no output for N seconds (0 to disable, default: 0)
449 stream_output: Stream output (default: from config)
450 show_model: Make API call to verify model (default: False)
451 only_ids: If provided, only process these issue IDs
452 skip_ids: Issue IDs to skip (in addition to completed/failed)
453 merge_pending: Attempt to merge pending worktrees (default: False)
454 clean_start: Remove all worktrees without checking (default: False)
455 ignore_pending: Report pending work but continue (default: False)
456 overlap_detection: Enable pre-flight overlap detection (default: False)
457 serialize_overlapping: If True, defer overlapping issues; if False, just warn
458 skip_learning_gate: Bypass per-worktree proof-first-task gate (default: False)
460 Returns:
461 ParallelConfig configured from BRConfig
462 """
463 return ParallelConfig(
464 max_workers=max_workers or self._parallel.base.max_workers,
465 p0_sequential=self._parallel.p0_sequential,
466 worktree_base=Path(self._parallel.base.worktree_base),
467 state_file=Path(self._parallel.base.state_file),
468 max_merge_retries=self._parallel.max_merge_retries,
469 priority_filter=priority_filter or self._issues.priorities,
470 max_issues=max_issues,
471 dry_run=dry_run,
472 timeout_per_issue=timeout_seconds or self._parallel.base.timeout_seconds,
473 idle_timeout_per_issue=idle_timeout_per_issue
474 if idle_timeout_per_issue is not None
475 else 0,
476 stream_subprocess_output=(
477 stream_output if stream_output is not None else self._parallel.base.stream_output
478 ),
479 show_model=show_model if show_model is not None else False,
480 command_prefix=self._parallel.command_prefix,
481 ready_command=self._parallel.ready_command,
482 manage_command=self._parallel.manage_command,
483 decide_command=self._parallel.decide_command,
484 only_ids=only_ids,
485 skip_ids=skip_ids,
486 type_prefixes=type_prefixes,
487 label_filter=label_filter,
488 worktree_copy_files=self._parallel.worktree_copy_files,
489 require_code_changes=self._parallel.require_code_changes,
490 use_feature_branches=(
491 use_feature_branches
492 if use_feature_branches is not None
493 else self._parallel.use_feature_branches
494 ),
495 push_feature_branches=self._parallel.push_feature_branches,
496 open_pr_for_feature_branches=self._parallel.open_pr_for_feature_branches,
497 merge_pending=merge_pending,
498 clean_start=clean_start,
499 ignore_pending=ignore_pending,
500 overlap_detection=overlap_detection,
501 serialize_overlapping=serialize_overlapping,
502 skip_learning_gate=skip_learning_gate,
503 base_branch=base_branch if base_branch is not None else self._parallel.base_branch,
504 remote_name=remote_name if remote_name is not None else self._parallel.remote_name,
505 )
507 @property
508 def issue_categories(self) -> list[str]:
509 """Get list of configured issue category names."""
510 return list(self._issues.categories.keys())
512 @property
513 def issue_priorities(self) -> list[str]:
514 """Get list of valid priority prefixes."""
515 return self._issues.priorities
517 def to_dict(self) -> dict[str, Any]:
518 """Convert configuration to dictionary.
520 Useful for variable substitution in command templates.
521 """
522 return {
523 "project": {
524 "name": self._project.name,
525 "src_dir": self._project.src_dir,
526 "test_dir": self._project.test_dir,
527 "test_cmd": self._project.test_cmd,
528 "lint_cmd": self._project.lint_cmd,
529 "type_cmd": self._project.type_cmd,
530 "format_cmd": self._project.format_cmd,
531 "build_cmd": self._project.build_cmd,
532 "run_cmd": self._project.run_cmd,
533 },
534 "issues": {
535 "base_dir": self._issues.base_dir,
536 "categories": {
537 k: {"prefix": v.prefix, "dir": v.dir, "action": v.action}
538 for k, v in self._issues.categories.items()
539 },
540 "priorities": self._issues.priorities,
541 "templates_dir": self._issues.templates_dir,
542 "capture_template": self._issues.capture_template,
543 "auto_commit": self._issues.auto_commit,
544 "auto_commit_prefix": self._issues.auto_commit_prefix,
545 },
546 "automation": {
547 "timeout_seconds": self._automation.timeout_seconds,
548 "idle_timeout_seconds": self._automation.idle_timeout_seconds,
549 "state_file": self._automation.state_file,
550 "worktree_base": self._automation.worktree_base,
551 "max_workers": self._automation.max_workers,
552 "stream_output": self._automation.stream_output,
553 "max_continuations": self._automation.max_continuations,
554 },
555 "parallel": {
556 "max_workers": self._parallel.base.max_workers,
557 "p0_sequential": self._parallel.p0_sequential,
558 "worktree_base": self._parallel.base.worktree_base,
559 "state_file": self._parallel.base.state_file,
560 "timeout_per_issue": self._parallel.base.timeout_seconds,
561 "max_merge_retries": self._parallel.max_merge_retries,
562 "stream_subprocess_output": self._parallel.base.stream_output,
563 "command_prefix": self._parallel.command_prefix,
564 "ready_command": self._parallel.ready_command,
565 "manage_command": self._parallel.manage_command,
566 "decide_command": self._parallel.decide_command,
567 "worktree_copy_files": self._parallel.worktree_copy_files,
568 "require_code_changes": self._parallel.require_code_changes,
569 "use_feature_branches": self._parallel.use_feature_branches,
570 "push_feature_branches": self._parallel.push_feature_branches,
571 "open_pr_for_feature_branches": self._parallel.open_pr_for_feature_branches,
572 "base_branch": self._parallel.base_branch,
573 "remote_name": self._parallel.remote_name,
574 },
575 "commands": {
576 "pre_implement": self._commands.pre_implement,
577 "post_implement": self._commands.post_implement,
578 "custom_verification": self._commands.custom_verification,
579 "confidence_gate": {
580 "enabled": self._commands.confidence_gate.enabled,
581 "readiness_threshold": self._commands.confidence_gate.readiness_threshold,
582 "outcome_threshold": self._commands.confidence_gate.outcome_threshold,
583 },
584 "tdd_mode": self._commands.tdd_mode,
585 "rate_limits": {
586 "max_wait_seconds": self._commands.rate_limits.max_wait_seconds,
587 "long_wait_ladder": self._commands.rate_limits.long_wait_ladder,
588 "circuit_breaker_enabled": self._commands.rate_limits.circuit_breaker_enabled,
589 "circuit_breaker_path": self._commands.rate_limits.circuit_breaker_path,
590 },
591 "recursive_refine": {
592 "max_depth": self._commands.recursive_refine.max_depth,
593 },
594 },
595 "scan": {
596 "focus_dirs": self._scan.focus_dirs,
597 "exclude_patterns": self._scan.exclude_patterns,
598 "custom_agents": self._scan.custom_agents,
599 },
600 "sprints": {
601 "sprints_dir": self._sprints.sprints_dir,
602 "default_timeout": self._sprints.default_timeout,
603 "default_max_workers": self._sprints.default_max_workers,
604 },
605 "loops": {
606 "loops_dir": self._loops.loops_dir,
607 "queue_wait_timeout_seconds": self._loops.queue_wait_timeout_seconds,
608 "glyphs": self._loops.glyphs.to_dict(),
609 },
610 "learning_tests": {
611 "enabled": self._learning_tests.enabled,
612 "stale_after_days": self._learning_tests.stale_after_days,
613 "discoverability": self._learning_tests.discoverability.to_dict(),
614 },
615 "decisions": {
616 "enabled": self._decisions.enabled,
617 "log_path": self._decisions.log_path,
618 "auto_generate": list(self._decisions.auto_generate),
619 },
620 "design_tokens": {
621 "enabled": self._design_tokens.enabled,
622 "path": self._design_tokens.path,
623 "primitives_file": self._design_tokens.primitives_file,
624 "semantic_file": self._design_tokens.semantic_file,
625 "themes_dir": self._design_tokens.themes_dir,
626 "active_theme": self._design_tokens.active_theme,
627 "active": self._design_tokens.active,
628 "profiles_dir": self._design_tokens.profiles_dir,
629 },
630 "analytics": {
631 "enabled": self._raw_config.get("analytics", {}).get("enabled", False),
632 "capture": {
633 "skills": list(self._analytics_capture.skills),
634 "cli_commands": list(self._analytics_capture.cli_commands),
635 "corrections": self._analytics_capture.corrections,
636 "file_events": self._analytics_capture.file_events,
637 "correction_patterns": list(self._analytics_capture.correction_patterns),
638 },
639 },
640 "events": {
641 "transports": list(self._events.transports),
642 "socket": {
643 "path": self._events.socket.path,
644 "max_clients": self._events.socket.max_clients,
645 },
646 "otel": {
647 "endpoint": self._events.otel.endpoint,
648 "service_name": self._events.otel.service_name,
649 },
650 "webhook": {
651 "url": self._events.webhook.url,
652 "batch_ms": self._events.webhook.batch_ms,
653 "headers": dict(self._events.webhook.headers),
654 },
655 },
656 "history": {
657 "velocity_window": self._history.velocity_window,
658 "effort_fields": list(self._history.effort_fields),
659 "max_age_days": self._history.max_age_days,
660 "planning_skills": list(self._history.planning_skills),
661 "session_digest": {
662 "enabled": self._history.session_digest.enabled,
663 "days": self._history.session_digest.days,
664 "char_cap": self._history.session_digest.char_cap,
665 "sections": list(self._history.session_digest.sections),
666 },
667 "evolution": {
668 "feedback_min_recurrence": self._history.evolution.feedback_min_recurrence,
669 "bypass_min_count": self._history.evolution.bypass_min_count,
670 },
671 "go_no_go": {
672 "correction_penalty": self._history.go_no_go.correction_penalty,
673 },
674 "capture_issue": {
675 "dup_overlap_threshold": self._history.capture_issue.dup_overlap_threshold,
676 },
677 "compaction": {
678 "enabled": self._history.compaction.enabled,
679 "budget_tokens": self._history.compaction.budget_tokens,
680 "model": self._history.compaction.model,
681 "timeout": self._history.compaction.timeout,
682 "cross_session_enabled": self._history.compaction.cross_session_enabled,
683 "max_level": self._history.compaction.max_level,
684 },
685 },
686 "sync": {
687 "enabled": self._sync.enabled,
688 "provider": self._sync.provider,
689 "github": {
690 "repo": self._sync.github.repo,
691 "label_mapping": self._sync.github.label_mapping,
692 "priority_labels": self._sync.github.priority_labels,
693 "sync_completed": self._sync.github.sync_completed,
694 "state_file": self._sync.github.state_file,
695 "pull_template": self._sync.github.pull_template,
696 },
697 },
698 "dependency_mapping": {
699 "overlap_min_files": self._dependency_mapping.overlap_min_files,
700 "overlap_min_ratio": self._dependency_mapping.overlap_min_ratio,
701 "min_directory_depth": self._dependency_mapping.min_directory_depth,
702 "conflict_threshold": self._dependency_mapping.conflict_threshold,
703 "high_conflict_threshold": self._dependency_mapping.high_conflict_threshold,
704 "confidence_modifier": self._dependency_mapping.confidence_modifier,
705 "scoring_weights": {
706 "semantic": self._dependency_mapping.scoring_weights.semantic,
707 "section": self._dependency_mapping.scoring_weights.section,
708 "type": self._dependency_mapping.scoring_weights.type,
709 },
710 "exclude_common_files": self._dependency_mapping.exclude_common_files,
711 },
712 "cli": {
713 "color": self._cli.color,
714 "colors": {
715 "logger": {
716 "info": self._cli.colors.logger.info,
717 "success": self._cli.colors.logger.success,
718 "warning": self._cli.colors.logger.warning,
719 "error": self._cli.colors.logger.error,
720 },
721 "priority": {
722 "P0": self._cli.colors.priority.P0,
723 "P1": self._cli.colors.priority.P1,
724 "P2": self._cli.colors.priority.P2,
725 "P3": self._cli.colors.priority.P3,
726 "P4": self._cli.colors.priority.P4,
727 "P5": self._cli.colors.priority.P5,
728 },
729 "type": {
730 "BUG": self._cli.colors.type.BUG,
731 "FEAT": self._cli.colors.type.FEAT,
732 "ENH": self._cli.colors.type.ENH,
733 },
734 "fsm_active_state": self._cli.colors.fsm_active_state,
735 "fsm_edge_labels": {
736 "yes": self._cli.colors.fsm_edge_labels.yes,
737 "no": self._cli.colors.fsm_edge_labels.no,
738 "error": self._cli.colors.fsm_edge_labels.error,
739 "partial": self._cli.colors.fsm_edge_labels.partial,
740 "next": self._cli.colors.fsm_edge_labels.next,
741 "default": self._cli.colors.fsm_edge_labels.default,
742 "blocked": self._cli.colors.fsm_edge_labels.blocked,
743 "retry_exhausted": self._cli.colors.fsm_edge_labels.retry_exhausted,
744 "rate_limit_exhausted": self._cli.colors.fsm_edge_labels.rate_limit_exhausted,
745 },
746 },
747 },
748 }
750 def resolve_variable(self, var_path: str) -> str | None:
751 """Resolve a variable path like 'project.src_dir' to its value.
753 Args:
754 var_path: Dot-separated path to configuration value
756 Returns:
757 The resolved value as a string, or None if not found
758 """
759 parts = var_path.split(".")
760 value: Any = self.to_dict()
762 for part in parts:
763 if isinstance(value, dict) and part in value:
764 value = value[part]
765 else:
766 return None
768 if value is None:
769 return None
770 if isinstance(value, list):
771 return " ".join(str(v) for v in value)
772 return str(value)
775# Backwards compatibility alias
776CLConfig = BRConfig