1"""Core configuration dataclasses and the root BRConfig aggregator.

2

3 ProjectConfig holds project-level settings. BRConfig is the single entry

4 point that loads ll-config.json and exposes all domain configs via properties.

5"""

6

7 from __future__ import annotations

8

9 import json

10 import os

11 import warnings

12 from dataclasses import dataclass

13 from pathlib import Path

14 from typing import Any, cast

15

16 from little_loops.config.automation import (

17 AutomationConfig,

18 CommandsConfig,

19 DependencyMappingConfig,

20 ParallelAutomationConfig,

21)

22 from little_loops.config.cli import CliConfig, RefineStatusConfig

23 from 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)

36 from little_loops.config.orchestration import OrchestrationConfig

37 from little_loops.parallel.types import ParallelConfig

38

39 CONFIG_FILENAME = "ll-config.json"

40 CONFIG_DIR = ".ll"

41 CODEX_CONFIG_DIR = ".codex"

42

43

44 def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:

45 """Deep-merge *override* into *base* for config-style overlays.

46

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:

50

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.

55

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.

60

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

72

73

74 def _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.

78

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.

82

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.

87

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

97

98

99 def resolve_config_path(project_root: Path) -> Path | None:

100 """Return the path to ``ll-config.json`` for *project_root* if found.

101

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).

108

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).

112

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

121

122

123@dataclass

124 class ProjectConfig:

125 """Project-level configuration."""

126

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

136

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 )

151

152

153 class BRConfig:

154 """Main configuration class for little-loops.

155

156 Loads configuration from .ll/ll-config.json and merges with defaults.

157 Provides convenient property access to all configuration values.

158

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 """

165

166 CONFIG_FILENAME = CONFIG_FILENAME

167 CONFIG_DIR = CONFIG_DIR

168

169 def __init__(self, project_root: Path) -> None:

170 """Initialize configuration from project root.

171

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()

178

179 def _load_config(self) -> dict[str, Any]:

180 """Load configuration from file.

181

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 {}

191

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

197

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", {}))

228

229 @property

230 def project(self) -> ProjectConfig:

231 """Get project configuration."""

232 return self._project

233

234 @property

235 def issues(self) -> IssuesConfig:

236 """Get issues configuration."""

237 return self._issues

238

239 @property

240 def automation(self) -> AutomationConfig:

241 """Get automation configuration."""

242 return self._automation

243

244 @property

245 def parallel(self) -> ParallelAutomationConfig:

246 """Get parallel automation configuration."""

247 return self._parallel

248

249 @property

250 def commands(self) -> CommandsConfig:

251 """Get commands configuration."""

252 return self._commands

253

254 @property

255 def scan(self) -> ScanConfig:

256 """Get scan configuration."""

257 return self._scan

258

259 @property

260 def sprints(self) -> SprintsConfig:

261 """Get sprints configuration."""

262 return self._sprints

263

264 @property

265 def loops(self) -> LoopsConfig:

266 """Get loops configuration."""

267 return self._loops

268

269 @property

270 def learning_tests(self) -> LearningTestsConfig:

271 """Get learning tests configuration."""

272 return self._learning_tests

273

274 @property

275 def decisions(self) -> DecisionsConfig:

276 """Get decisions log configuration."""

277 return self._decisions

278

279 @property

280 def sync(self) -> SyncConfig:

281 """Get sync configuration."""

282 return self._sync

283

284 @property

285 def dependency_mapping(self) -> DependencyMappingConfig:

286 """Get dependency mapping configuration."""

287 return self._dependency_mapping

288

289 @property

290 def cli(self) -> CliConfig:

291 """Get CLI output configuration."""

292 return self._cli

293

294 @property

295 def refine_status(self) -> RefineStatusConfig:

296 """Get refine-status display configuration."""

297 return self._refine_status

298

299 @property

300 def events(self) -> EventsConfig:

301 """Get events configuration."""

302 return self._events

303

304 @property

305 def orchestration(self) -> OrchestrationConfig:

306 """Get orchestration configuration."""

307 return self._orchestration

308

309 @property

310 def design_tokens(self) -> DesignTokensConfig:

311 """Get design tokens configuration."""

312 return self._design_tokens

313

314 @property

315 def analytics_capture(self) -> AnalyticsCaptureConfig:

316 """Get analytics capture configuration."""

317 return self._analytics_capture

318

319 @property

320 def history(self) -> HistoryConfig:

321 """Get history read/consume configuration."""

322 return self._history

323

324 @property

325 def extensions(self) -> list[str]:

326 """Get extension config paths (e.g. ``["module:Class", ...]``)."""

327 return self._raw_config.get("extensions", [])

328

329 @property

330 def repo_path(self) -> Path:

331 """Get the repository root path."""

332 return self.project_root

333

334 # Convenience methods for common operations

335

336 def get_issue_dir(self, category: str) -> Path:

337 """Get the directory path for an issue category.

338

339 Args:

340 category: Category key (e.g., "bugs", "features")

341

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

350

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

359

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

368

369 def get_issue_prefix(self, category: str) -> str:

370 """Get the issue ID prefix for a category.

371

372 Args:

373 category: Category key (e.g., "bugs", "features")

374

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]

381

382 def get_category_action(self, category: str) -> str:

383 """Get the default action for a category.

384

385 Args:

386 category: Category key (e.g., "bugs", "features")

387

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"

394

395 def get_loops_dir(self) -> Path:

396 """Get the loops directory path."""

397 return self.project_root / self._loops.loops_dir

398

399 def get_src_path(self) -> Path:

400 """Get the source directory path."""

401 return self.project_root / self._project.src_dir

402

403 def get_worktree_base(self) -> Path:

404 """Get the worktree base directory path."""

405 return self.project_root / self._automation.worktree_base

406

407 def get_state_file(self) -> Path:

408 """Get the state file path."""

409 return self.project_root / self._automation.state_file

410

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

414

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.

441

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)

459

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 )

506

507 @property

508 def issue_categories(self) -> list[str]:

509 """Get list of configured issue category names."""

510 return list(self._issues.categories.keys())

511

512 @property

513 def issue_priorities(self) -> list[str]:

514 """Get list of valid priority prefixes."""

515 return self._issues.priorities

516

517 def to_dict(self) -> dict[str, Any]:

518 """Convert configuration to dictionary.

519

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 }

749

750 def resolve_variable(self, var_path: str) -> str | None:

751 """Resolve a variable path like 'project.src_dir' to its value.

752

753 Args:

754 var_path: Dot-separated path to configuration value

755

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()

761

762 for part in parts:

763 if isinstance(value, dict) and part in value:

764 value = value[part]

765 else:

766 return None

767

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)

773

774

775# Backwards compatibility alias

776 CLConfig = BRConfig