Coverage for little_loops / config / core.py: 70%

202 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-16 13:12 -0500

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

2 

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

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

5""" 

6 

7from __future__ import annotations 

8 

9import json 

10import os 

11import warnings 

12from dataclasses import dataclass 

13from pathlib import Path 

14from typing import Any, cast 

15 

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 

38 

39CONFIG_FILENAME = "ll-config.json" 

40CONFIG_DIR = ".ll" 

41CODEX_CONFIG_DIR = ".codex" 

42 

43 

44def 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 

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. 

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 

99def 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 

124class 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 

153class 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 ) -> ParallelConfig: 

439 """Create a ParallelConfig from BRConfig settings with optional overrides. 

440 

441 Args: 

442 max_workers: Override max_workers (default: from config) 

443 priority_filter: Override priority filter (default: from issues config) 

444 max_issues: Maximum issues to process (default: 0 = unlimited) 

445 dry_run: Preview mode (default: False) 

446 timeout_seconds: Per-issue timeout (default: from config) 

447 idle_timeout_per_issue: Kill worker if no output for N seconds (0 to disable, default: 0) 

448 stream_output: Stream output (default: from config) 

449 show_model: Make API call to verify model (default: False) 

450 only_ids: If provided, only process these issue IDs 

451 skip_ids: Issue IDs to skip (in addition to completed/failed) 

452 merge_pending: Attempt to merge pending worktrees (default: False) 

453 clean_start: Remove all worktrees without checking (default: False) 

454 ignore_pending: Report pending work but continue (default: False) 

455 overlap_detection: Enable pre-flight overlap detection (default: False) 

456 serialize_overlapping: If True, defer overlapping issues; if False, just warn 

457 

458 Returns: 

459 ParallelConfig configured from BRConfig 

460 """ 

461 return ParallelConfig( 

462 max_workers=max_workers or self._parallel.base.max_workers, 

463 p0_sequential=self._parallel.p0_sequential, 

464 worktree_base=Path(self._parallel.base.worktree_base), 

465 state_file=Path(self._parallel.base.state_file), 

466 max_merge_retries=self._parallel.max_merge_retries, 

467 priority_filter=priority_filter or self._issues.priorities, 

468 max_issues=max_issues, 

469 dry_run=dry_run, 

470 timeout_per_issue=timeout_seconds or self._parallel.base.timeout_seconds, 

471 idle_timeout_per_issue=idle_timeout_per_issue 

472 if idle_timeout_per_issue is not None 

473 else 0, 

474 stream_subprocess_output=( 

475 stream_output if stream_output is not None else self._parallel.base.stream_output 

476 ), 

477 show_model=show_model if show_model is not None else False, 

478 command_prefix=self._parallel.command_prefix, 

479 ready_command=self._parallel.ready_command, 

480 manage_command=self._parallel.manage_command, 

481 decide_command=self._parallel.decide_command, 

482 only_ids=only_ids, 

483 skip_ids=skip_ids, 

484 type_prefixes=type_prefixes, 

485 label_filter=label_filter, 

486 worktree_copy_files=self._parallel.worktree_copy_files, 

487 require_code_changes=self._parallel.require_code_changes, 

488 use_feature_branches=( 

489 use_feature_branches if use_feature_branches is not None else self._parallel.use_feature_branches 

490 ), 

491 push_feature_branches=self._parallel.push_feature_branches, 

492 open_pr_for_feature_branches=self._parallel.open_pr_for_feature_branches, 

493 merge_pending=merge_pending, 

494 clean_start=clean_start, 

495 ignore_pending=ignore_pending, 

496 overlap_detection=overlap_detection, 

497 serialize_overlapping=serialize_overlapping, 

498 base_branch=base_branch if base_branch is not None else self._parallel.base_branch, 

499 remote_name=remote_name if remote_name is not None else self._parallel.remote_name, 

500 ) 

501 

502 @property 

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

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

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

506 

507 @property 

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

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

510 return self._issues.priorities 

511 

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

513 """Convert configuration to dictionary. 

514 

515 Useful for variable substitution in command templates. 

516 """ 

517 return { 

518 "project": { 

519 "name": self._project.name, 

520 "src_dir": self._project.src_dir, 

521 "test_dir": self._project.test_dir, 

522 "test_cmd": self._project.test_cmd, 

523 "lint_cmd": self._project.lint_cmd, 

524 "type_cmd": self._project.type_cmd, 

525 "format_cmd": self._project.format_cmd, 

526 "build_cmd": self._project.build_cmd, 

527 "run_cmd": self._project.run_cmd, 

528 }, 

529 "issues": { 

530 "base_dir": self._issues.base_dir, 

531 "categories": { 

532 k: {"prefix": v.prefix, "dir": v.dir, "action": v.action} 

533 for k, v in self._issues.categories.items() 

534 }, 

535 "priorities": self._issues.priorities, 

536 "templates_dir": self._issues.templates_dir, 

537 "capture_template": self._issues.capture_template, 

538 "auto_commit": self._issues.auto_commit, 

539 "auto_commit_prefix": self._issues.auto_commit_prefix, 

540 }, 

541 "automation": { 

542 "timeout_seconds": self._automation.timeout_seconds, 

543 "idle_timeout_seconds": self._automation.idle_timeout_seconds, 

544 "state_file": self._automation.state_file, 

545 "worktree_base": self._automation.worktree_base, 

546 "max_workers": self._automation.max_workers, 

547 "stream_output": self._automation.stream_output, 

548 "max_continuations": self._automation.max_continuations, 

549 }, 

550 "parallel": { 

551 "max_workers": self._parallel.base.max_workers, 

552 "p0_sequential": self._parallel.p0_sequential, 

553 "worktree_base": self._parallel.base.worktree_base, 

554 "state_file": self._parallel.base.state_file, 

555 "timeout_per_issue": self._parallel.base.timeout_seconds, 

556 "max_merge_retries": self._parallel.max_merge_retries, 

557 "stream_subprocess_output": self._parallel.base.stream_output, 

558 "command_prefix": self._parallel.command_prefix, 

559 "ready_command": self._parallel.ready_command, 

560 "manage_command": self._parallel.manage_command, 

561 "decide_command": self._parallel.decide_command, 

562 "worktree_copy_files": self._parallel.worktree_copy_files, 

563 "require_code_changes": self._parallel.require_code_changes, 

564 "use_feature_branches": self._parallel.use_feature_branches, 

565 "push_feature_branches": self._parallel.push_feature_branches, 

566 "open_pr_for_feature_branches": self._parallel.open_pr_for_feature_branches, 

567 "base_branch": self._parallel.base_branch, 

568 "remote_name": self._parallel.remote_name, 

569 }, 

570 "commands": { 

571 "pre_implement": self._commands.pre_implement, 

572 "post_implement": self._commands.post_implement, 

573 "custom_verification": self._commands.custom_verification, 

574 "confidence_gate": { 

575 "enabled": self._commands.confidence_gate.enabled, 

576 "readiness_threshold": self._commands.confidence_gate.readiness_threshold, 

577 "outcome_threshold": self._commands.confidence_gate.outcome_threshold, 

578 }, 

579 "tdd_mode": self._commands.tdd_mode, 

580 "rate_limits": { 

581 "max_wait_seconds": self._commands.rate_limits.max_wait_seconds, 

582 "long_wait_ladder": self._commands.rate_limits.long_wait_ladder, 

583 "circuit_breaker_enabled": self._commands.rate_limits.circuit_breaker_enabled, 

584 "circuit_breaker_path": self._commands.rate_limits.circuit_breaker_path, 

585 }, 

586 "recursive_refine": { 

587 "max_depth": self._commands.recursive_refine.max_depth, 

588 }, 

589 }, 

590 "scan": { 

591 "focus_dirs": self._scan.focus_dirs, 

592 "exclude_patterns": self._scan.exclude_patterns, 

593 "custom_agents": self._scan.custom_agents, 

594 }, 

595 "sprints": { 

596 "sprints_dir": self._sprints.sprints_dir, 

597 "default_timeout": self._sprints.default_timeout, 

598 "default_max_workers": self._sprints.default_max_workers, 

599 }, 

600 "loops": { 

601 "loops_dir": self._loops.loops_dir, 

602 "queue_wait_timeout_seconds": self._loops.queue_wait_timeout_seconds, 

603 "glyphs": self._loops.glyphs.to_dict(), 

604 }, 

605 "learning_tests": { 

606 "enabled": self._learning_tests.enabled, 

607 "stale_after_days": self._learning_tests.stale_after_days, 

608 "discoverability": self._learning_tests.discoverability.to_dict(), 

609 }, 

610 "decisions": { 

611 "enabled": self._decisions.enabled, 

612 "log_path": self._decisions.log_path, 

613 "auto_generate": list(self._decisions.auto_generate), 

614 }, 

615 "design_tokens": { 

616 "enabled": self._design_tokens.enabled, 

617 "path": self._design_tokens.path, 

618 "primitives_file": self._design_tokens.primitives_file, 

619 "semantic_file": self._design_tokens.semantic_file, 

620 "themes_dir": self._design_tokens.themes_dir, 

621 "active_theme": self._design_tokens.active_theme, 

622 "active": self._design_tokens.active, 

623 "profiles_dir": self._design_tokens.profiles_dir, 

624 }, 

625 "analytics": { 

626 "enabled": self._raw_config.get("analytics", {}).get("enabled", False), 

627 "capture": { 

628 "skills": list(self._analytics_capture.skills), 

629 "cli_commands": list(self._analytics_capture.cli_commands), 

630 "corrections": self._analytics_capture.corrections, 

631 "file_events": self._analytics_capture.file_events, 

632 "correction_patterns": list(self._analytics_capture.correction_patterns), 

633 }, 

634 }, 

635 "events": { 

636 "transports": list(self._events.transports), 

637 "socket": { 

638 "path": self._events.socket.path, 

639 "max_clients": self._events.socket.max_clients, 

640 }, 

641 "otel": { 

642 "endpoint": self._events.otel.endpoint, 

643 "service_name": self._events.otel.service_name, 

644 }, 

645 "webhook": { 

646 "url": self._events.webhook.url, 

647 "batch_ms": self._events.webhook.batch_ms, 

648 "headers": dict(self._events.webhook.headers), 

649 }, 

650 }, 

651 "history": { 

652 "velocity_window": self._history.velocity_window, 

653 "effort_fields": list(self._history.effort_fields), 

654 "max_age_days": self._history.max_age_days, 

655 "planning_skills": list(self._history.planning_skills), 

656 "session_digest": { 

657 "enabled": self._history.session_digest.enabled, 

658 "days": self._history.session_digest.days, 

659 "char_cap": self._history.session_digest.char_cap, 

660 "sections": list(self._history.session_digest.sections), 

661 }, 

662 "evolution": { 

663 "feedback_min_recurrence": self._history.evolution.feedback_min_recurrence, 

664 "bypass_min_count": self._history.evolution.bypass_min_count, 

665 }, 

666 "go_no_go": { 

667 "correction_penalty": self._history.go_no_go.correction_penalty, 

668 }, 

669 "capture_issue": { 

670 "dup_overlap_threshold": self._history.capture_issue.dup_overlap_threshold, 

671 }, 

672 "compaction": { 

673 "enabled": self._history.compaction.enabled, 

674 "budget_tokens": self._history.compaction.budget_tokens, 

675 "model": self._history.compaction.model, 

676 "timeout": self._history.compaction.timeout, 

677 "cross_session_enabled": self._history.compaction.cross_session_enabled, 

678 "max_level": self._history.compaction.max_level, 

679 }, 

680 }, 

681 "sync": { 

682 "enabled": self._sync.enabled, 

683 "provider": self._sync.provider, 

684 "github": { 

685 "repo": self._sync.github.repo, 

686 "label_mapping": self._sync.github.label_mapping, 

687 "priority_labels": self._sync.github.priority_labels, 

688 "sync_completed": self._sync.github.sync_completed, 

689 "state_file": self._sync.github.state_file, 

690 "pull_template": self._sync.github.pull_template, 

691 }, 

692 }, 

693 "dependency_mapping": { 

694 "overlap_min_files": self._dependency_mapping.overlap_min_files, 

695 "overlap_min_ratio": self._dependency_mapping.overlap_min_ratio, 

696 "min_directory_depth": self._dependency_mapping.min_directory_depth, 

697 "conflict_threshold": self._dependency_mapping.conflict_threshold, 

698 "high_conflict_threshold": self._dependency_mapping.high_conflict_threshold, 

699 "confidence_modifier": self._dependency_mapping.confidence_modifier, 

700 "scoring_weights": { 

701 "semantic": self._dependency_mapping.scoring_weights.semantic, 

702 "section": self._dependency_mapping.scoring_weights.section, 

703 "type": self._dependency_mapping.scoring_weights.type, 

704 }, 

705 "exclude_common_files": self._dependency_mapping.exclude_common_files, 

706 }, 

707 "cli": { 

708 "color": self._cli.color, 

709 "colors": { 

710 "logger": { 

711 "info": self._cli.colors.logger.info, 

712 "success": self._cli.colors.logger.success, 

713 "warning": self._cli.colors.logger.warning, 

714 "error": self._cli.colors.logger.error, 

715 }, 

716 "priority": { 

717 "P0": self._cli.colors.priority.P0, 

718 "P1": self._cli.colors.priority.P1, 

719 "P2": self._cli.colors.priority.P2, 

720 "P3": self._cli.colors.priority.P3, 

721 "P4": self._cli.colors.priority.P4, 

722 "P5": self._cli.colors.priority.P5, 

723 }, 

724 "type": { 

725 "BUG": self._cli.colors.type.BUG, 

726 "FEAT": self._cli.colors.type.FEAT, 

727 "ENH": self._cli.colors.type.ENH, 

728 }, 

729 "fsm_active_state": self._cli.colors.fsm_active_state, 

730 "fsm_edge_labels": { 

731 "yes": self._cli.colors.fsm_edge_labels.yes, 

732 "no": self._cli.colors.fsm_edge_labels.no, 

733 "error": self._cli.colors.fsm_edge_labels.error, 

734 "partial": self._cli.colors.fsm_edge_labels.partial, 

735 "next": self._cli.colors.fsm_edge_labels.next, 

736 "default": self._cli.colors.fsm_edge_labels.default, 

737 "blocked": self._cli.colors.fsm_edge_labels.blocked, 

738 "retry_exhausted": self._cli.colors.fsm_edge_labels.retry_exhausted, 

739 "rate_limit_exhausted": self._cli.colors.fsm_edge_labels.rate_limit_exhausted, 

740 }, 

741 }, 

742 }, 

743 } 

744 

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

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

747 

748 Args: 

749 var_path: Dot-separated path to configuration value 

750 

751 Returns: 

752 The resolved value as a string, or None if not found 

753 """ 

754 parts = var_path.split(".") 

755 value: Any = self.to_dict() 

756 

757 for part in parts: 

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

759 value = value[part] 

760 else: 

761 return None 

762 

763 if value is None: 

764 return None 

765 if isinstance(value, list): 

766 return " ".join(str(v) for v in value) 

767 return str(value) 

768 

769 

770# Backwards compatibility alias 

771CLConfig = BRConfig