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

186 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-05-22 16:19 -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 EventsConfig, 

25 IssuesConfig, 

26 LearningTestsConfig, 

27 LoopsConfig, 

28 ScanConfig, 

29 SprintsConfig, 

30 SyncConfig, 

31) 

32from little_loops.config.orchestration import OrchestrationConfig 

33from little_loops.parallel.types import ParallelConfig 

34 

35CONFIG_FILENAME = "ll-config.json" 

36CONFIG_DIR = ".ll" 

37CODEX_CONFIG_DIR = ".codex" 

38 

39 

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

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

42 

43 Python port of the inline bash version at ``hooks/scripts/session-start.sh:30-43`` 

44 used to apply ``.ll/ll.local.md`` frontmatter overrides on top of the base 

45 ``ll-config.json``. Semantics: 

46 

47 - Nested dicts are merged recursively at every level. 

48 - All other value types (strings, ints, bools, lists) **replace** the base 

49 value — arrays do not append. 

50 - An explicit ``None`` in *override* **removes** the key from the result. 

51 

52 Differs from ``little_loops.fsm.fragments._deep_merge`` only in the 

53 null-removal semantic — fragments-merge passes ``None`` through as a value, 

54 while config-merge treats it as a key-removal sentinel. The config variant 

55 is required for ``ll.local.md`` to be able to unset base keys. 

56 

57 Returns a new dict; neither input is mutated. 

58 """ 

59 result = dict(base) 

60 for key, value in override.items(): 

61 if value is None: 

62 result.pop(key, None) 

63 elif isinstance(value, dict) and isinstance(result.get(key), dict): 

64 result[key] = deep_merge(result[key], value) 

65 else: 

66 result[key] = value 

67 return result 

68 

69 

70def _config_candidates( 

71 project_root: Path, *, host: str | None, state_dir: str | None 

72) -> list[Path]: 

73 """Return the ordered list of ``ll-config.json`` candidate paths to probe. 

74 

75 Default order (no host env vars set): ``.ll/ll-config.json`` then 

76 root-level ``ll-config.json`` — preserves the historical behavior of 

77 :func:`resolve_config_path` before host-aware probing was added. 

78 

79 When ``host == "codex"`` or ``state_dir == ".codex"`` (FEAT-957), 

80 ``.codex/ll-config.json`` is prepended so Codex CLI projects pick up 

81 their host-specific config before falling through to the default 

82 candidates. Other host values pass through unchanged. 

83 

84 Future hosts (e.g. FEAT-992 Pi) add a new branch here rather than a 

85 new code path elsewhere. 

86 """ 

87 candidates: list[Path] = [] 

88 if host == "codex" or state_dir == CODEX_CONFIG_DIR: 

89 candidates.append(project_root / CODEX_CONFIG_DIR / CONFIG_FILENAME) 

90 candidates.append(project_root / CONFIG_DIR / CONFIG_FILENAME) 

91 candidates.append(project_root / CONFIG_FILENAME) 

92 return candidates 

93 

94 

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

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

97 

98 Iterates an ordered candidate list (see :func:`_config_candidates`) and 

99 returns the first existing file. The default order is 

100 ``<root>/.ll/ll-config.json`` then ``<root>/ll-config.json`` (parity 

101 with the legacy bash ``ll_resolve_config``); when ``LL_HOOK_HOST=codex`` 

102 or ``LL_STATE_DIR=.codex`` is set on the environment, 

103 ``<root>/.codex/ll-config.json`` is probed first (FEAT-957). 

104 

105 Pure lookup — does not create directories or mutate global state (the 

106 bash version's ``mkdir -p .ll`` side effect is intentionally dropped; 

107 callers that need ``.ll/`` to exist must create it themselves). 

108 

109 Returns ``None`` if no candidate exists. 

110 """ 

111 host = os.environ.get("LL_HOOK_HOST") 

112 state_dir = os.environ.get("LL_STATE_DIR") 

113 for candidate in _config_candidates(project_root, host=host, state_dir=state_dir): 

114 if candidate.is_file(): 

115 return candidate 

116 return None 

117 

118 

119@dataclass 

120class ProjectConfig: 

121 """Project-level configuration.""" 

122 

123 name: str = "" 

124 src_dir: str = "src/" 

125 test_dir: str = "tests" 

126 test_cmd: str = "pytest" 

127 lint_cmd: str = "ruff check ." 

128 type_cmd: str | None = "mypy" 

129 format_cmd: str | None = "ruff format ." 

130 build_cmd: str | None = None 

131 run_cmd: str | None = None 

132 

133 @classmethod 

134 def from_dict(cls, data: dict[str, Any]) -> ProjectConfig: 

135 """Create ProjectConfig from dictionary.""" 

136 return cls( 

137 name=data.get("name", ""), 

138 src_dir=data.get("src_dir", "src/"), 

139 test_dir=data.get("test_dir", "tests"), 

140 test_cmd=data.get("test_cmd", "pytest"), 

141 lint_cmd=data.get("lint_cmd", "ruff check ."), 

142 type_cmd=data.get("type_cmd", "mypy"), 

143 format_cmd=data.get("format_cmd", "ruff format ."), 

144 build_cmd=data.get("build_cmd"), 

145 run_cmd=data.get("run_cmd"), 

146 ) 

147 

148 

149class BRConfig: 

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

151 

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

153 Provides convenient property access to all configuration values. 

154 

155 Example: 

156 config = BRConfig(Path.cwd()) 

157 print(config.project.src_dir) # "src/" 

158 print(config.issues.base_dir) # ".issues" 

159 print(config.get_issue_dir("bugs")) # Path(".issues/bugs") 

160 """ 

161 

162 CONFIG_FILENAME = CONFIG_FILENAME 

163 CONFIG_DIR = CONFIG_DIR 

164 

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

166 """Initialize configuration from project root. 

167 

168 Args: 

169 project_root: Path to the project root directory 

170 """ 

171 self.project_root = project_root.resolve() 

172 self._raw_config = self._load_config() 

173 self._parse_config() 

174 

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

176 """Load configuration from file. 

177 

178 Uses :func:`resolve_config_path` which checks ``.ll/ll-config.json`` 

179 first then falls back to a root-level ``ll-config.json`` (parity with 

180 ``hooks/scripts/lib/common.sh:ll_resolve_config``). 

181 """ 

182 config_path = resolve_config_path(self.project_root) 

183 if config_path is not None: 

184 with open(config_path, encoding="utf-8") as f: 

185 return cast(dict[str, Any], json.load(f)) 

186 return {} 

187 

188 def _parse_config(self) -> None: 

189 """Parse raw config into typed dataclasses.""" 

190 self._project = ProjectConfig.from_dict(self._raw_config.get("project", {})) 

191 if not self._project.name: 

192 self._project.name = self.project_root.name 

193 

194 self._issues = IssuesConfig.from_dict(self._raw_config.get("issues", {})) 

195 self._automation = AutomationConfig.from_dict(self._raw_config.get("automation", {})) 

196 self._parallel = ParallelAutomationConfig.from_dict(self._raw_config.get("parallel", {})) 

197 self._commands = CommandsConfig.from_dict(self._raw_config.get("commands", {})) 

198 self._scan = ScanConfig.from_dict(self._raw_config.get("scan", {})) 

199 self._sprints = SprintsConfig.from_dict(self._raw_config.get("sprints", {})) 

200 self._loops = LoopsConfig.from_dict(self._raw_config.get("loops", {})) 

201 self._learning_tests = LearningTestsConfig.from_dict( 

202 self._raw_config.get("learning_tests", {}) 

203 ) 

204 self._sync = SyncConfig.from_dict(self._raw_config.get("sync", {})) 

205 self._dependency_mapping = DependencyMappingConfig.from_dict( 

206 self._raw_config.get("dependency_mapping", {}) 

207 ) 

208 self._cli = CliConfig.from_dict(self._raw_config.get("cli", {})) 

209 self._refine_status = RefineStatusConfig.from_dict( 

210 self._raw_config.get("refine_status", {}) 

211 ) 

212 self._events = EventsConfig.from_dict(self._raw_config.get("events", {})) 

213 self._orchestration = OrchestrationConfig.from_dict( 

214 self._raw_config.get("orchestration", {}) 

215 ) 

216 

217 @property 

218 def project(self) -> ProjectConfig: 

219 """Get project configuration.""" 

220 return self._project 

221 

222 @property 

223 def issues(self) -> IssuesConfig: 

224 """Get issues configuration.""" 

225 return self._issues 

226 

227 @property 

228 def automation(self) -> AutomationConfig: 

229 """Get automation configuration.""" 

230 return self._automation 

231 

232 @property 

233 def parallel(self) -> ParallelAutomationConfig: 

234 """Get parallel automation configuration.""" 

235 return self._parallel 

236 

237 @property 

238 def commands(self) -> CommandsConfig: 

239 """Get commands configuration.""" 

240 return self._commands 

241 

242 @property 

243 def scan(self) -> ScanConfig: 

244 """Get scan configuration.""" 

245 return self._scan 

246 

247 @property 

248 def sprints(self) -> SprintsConfig: 

249 """Get sprints configuration.""" 

250 return self._sprints 

251 

252 @property 

253 def loops(self) -> LoopsConfig: 

254 """Get loops configuration.""" 

255 return self._loops 

256 

257 @property 

258 def learning_tests(self) -> LearningTestsConfig: 

259 """Get learning tests configuration.""" 

260 return self._learning_tests 

261 

262 @property 

263 def sync(self) -> SyncConfig: 

264 """Get sync configuration.""" 

265 return self._sync 

266 

267 @property 

268 def dependency_mapping(self) -> DependencyMappingConfig: 

269 """Get dependency mapping configuration.""" 

270 return self._dependency_mapping 

271 

272 @property 

273 def cli(self) -> CliConfig: 

274 """Get CLI output configuration.""" 

275 return self._cli 

276 

277 @property 

278 def refine_status(self) -> RefineStatusConfig: 

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

280 return self._refine_status 

281 

282 @property 

283 def events(self) -> EventsConfig: 

284 """Get events configuration.""" 

285 return self._events 

286 

287 @property 

288 def orchestration(self) -> OrchestrationConfig: 

289 """Get orchestration configuration.""" 

290 return self._orchestration 

291 

292 @property 

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

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

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

296 

297 @property 

298 def repo_path(self) -> Path: 

299 """Get the repository root path.""" 

300 return self.project_root 

301 

302 # Convenience methods for common operations 

303 

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

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

306 

307 Args: 

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

309 

310 Returns: 

311 Path to the issue category directory 

312 """ 

313 if category in self._issues.categories: 

314 dir_name = self._issues.categories[category].dir 

315 else: 

316 dir_name = category 

317 return self.project_root / self._issues.base_dir / dir_name 

318 

319 def get_completed_dir(self) -> Path: 

320 """Get the path to the completed issues directory.""" 

321 warnings.warn( 

322 "BRConfig.get_completed_dir() is deprecated; use IssueInfo.status instead", 

323 DeprecationWarning, 

324 stacklevel=2, 

325 ) 

326 return self.project_root / self._issues.base_dir / self._issues.completed_dir 

327 

328 def get_deferred_dir(self) -> Path: 

329 """Get the path to the deferred issues directory.""" 

330 warnings.warn( 

331 "BRConfig.get_deferred_dir() is deprecated; use IssueInfo.status instead", 

332 DeprecationWarning, 

333 stacklevel=2, 

334 ) 

335 return self.project_root / self._issues.base_dir / self._issues.deferred_dir 

336 

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

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

339 

340 Args: 

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

342 

343 Returns: 

344 Issue prefix (e.g., "BUG", "FEAT") 

345 """ 

346 if category in self._issues.categories: 

347 return self._issues.categories[category].prefix 

348 return category.upper()[:3] 

349 

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

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

352 

353 Args: 

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

355 

356 Returns: 

357 Action verb (e.g., "fix", "implement") 

358 """ 

359 if category in self._issues.categories: 

360 return self._issues.categories[category].action 

361 return "fix" 

362 

363 def get_loops_dir(self) -> Path: 

364 """Get the loops directory path.""" 

365 return self.project_root / self._loops.loops_dir 

366 

367 def get_src_path(self) -> Path: 

368 """Get the source directory path.""" 

369 return self.project_root / self._project.src_dir 

370 

371 def get_worktree_base(self) -> Path: 

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

373 return self.project_root / self._automation.worktree_base 

374 

375 def get_state_file(self) -> Path: 

376 """Get the state file path.""" 

377 return self.project_root / self._automation.state_file 

378 

379 def get_parallel_state_file(self) -> Path: 

380 """Get the parallel state file path.""" 

381 return self.project_root / self._parallel.base.state_file 

382 

383 def create_parallel_config( 

384 self, 

385 *, 

386 max_workers: int | None = None, 

387 priority_filter: list[str] | None = None, 

388 max_issues: int = 0, 

389 dry_run: bool = False, 

390 timeout_seconds: int | None = None, 

391 idle_timeout_per_issue: int | None = None, 

392 stream_output: bool | None = None, 

393 show_model: bool | None = None, 

394 only_ids: set[str] | None = None, 

395 skip_ids: set[str] | None = None, 

396 type_prefixes: set[str] | None = None, 

397 label_filter: set[str] | None = None, 

398 merge_pending: bool = False, 

399 clean_start: bool = False, 

400 ignore_pending: bool = False, 

401 overlap_detection: bool = False, 

402 serialize_overlapping: bool = True, 

403 base_branch: str = "main", 

404 remote_name: str | None = None, 

405 ) -> ParallelConfig: 

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

407 

408 Args: 

409 max_workers: Override max_workers (default: from config) 

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

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

412 dry_run: Preview mode (default: False) 

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

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

415 stream_output: Stream output (default: from config) 

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

417 only_ids: If provided, only process these issue IDs 

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

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

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

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

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

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

424 

425 Returns: 

426 ParallelConfig configured from BRConfig 

427 """ 

428 return ParallelConfig( 

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

430 p0_sequential=self._parallel.p0_sequential, 

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

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

433 max_merge_retries=self._parallel.max_merge_retries, 

434 priority_filter=priority_filter or self._issues.priorities, 

435 max_issues=max_issues, 

436 dry_run=dry_run, 

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

438 idle_timeout_per_issue=idle_timeout_per_issue 

439 if idle_timeout_per_issue is not None 

440 else 0, 

441 stream_subprocess_output=( 

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

443 ), 

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

445 command_prefix=self._parallel.command_prefix, 

446 ready_command=self._parallel.ready_command, 

447 manage_command=self._parallel.manage_command, 

448 decide_command=self._parallel.decide_command, 

449 only_ids=only_ids, 

450 skip_ids=skip_ids, 

451 type_prefixes=type_prefixes, 

452 label_filter=label_filter, 

453 worktree_copy_files=self._parallel.worktree_copy_files, 

454 require_code_changes=self._parallel.require_code_changes, 

455 use_feature_branches=self._parallel.use_feature_branches, 

456 merge_pending=merge_pending, 

457 clean_start=clean_start, 

458 ignore_pending=ignore_pending, 

459 overlap_detection=overlap_detection, 

460 serialize_overlapping=serialize_overlapping, 

461 base_branch=base_branch, 

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

463 ) 

464 

465 @property 

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

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

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

469 

470 @property 

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

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

473 return self._issues.priorities 

474 

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

476 """Convert configuration to dictionary. 

477 

478 Useful for variable substitution in command templates. 

479 """ 

480 return { 

481 "project": { 

482 "name": self._project.name, 

483 "src_dir": self._project.src_dir, 

484 "test_dir": self._project.test_dir, 

485 "test_cmd": self._project.test_cmd, 

486 "lint_cmd": self._project.lint_cmd, 

487 "type_cmd": self._project.type_cmd, 

488 "format_cmd": self._project.format_cmd, 

489 "build_cmd": self._project.build_cmd, 

490 "run_cmd": self._project.run_cmd, 

491 }, 

492 "issues": { 

493 "base_dir": self._issues.base_dir, 

494 "categories": { 

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

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

497 }, 

498 "priorities": self._issues.priorities, 

499 "templates_dir": self._issues.templates_dir, 

500 "capture_template": self._issues.capture_template, 

501 }, 

502 "automation": { 

503 "timeout_seconds": self._automation.timeout_seconds, 

504 "idle_timeout_seconds": self._automation.idle_timeout_seconds, 

505 "state_file": self._automation.state_file, 

506 "worktree_base": self._automation.worktree_base, 

507 "max_workers": self._automation.max_workers, 

508 "stream_output": self._automation.stream_output, 

509 "max_continuations": self._automation.max_continuations, 

510 }, 

511 "parallel": { 

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

513 "p0_sequential": self._parallel.p0_sequential, 

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

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

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

517 "max_merge_retries": self._parallel.max_merge_retries, 

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

519 "command_prefix": self._parallel.command_prefix, 

520 "ready_command": self._parallel.ready_command, 

521 "manage_command": self._parallel.manage_command, 

522 "decide_command": self._parallel.decide_command, 

523 "worktree_copy_files": self._parallel.worktree_copy_files, 

524 "require_code_changes": self._parallel.require_code_changes, 

525 "use_feature_branches": self._parallel.use_feature_branches, 

526 "remote_name": self._parallel.remote_name, 

527 }, 

528 "commands": { 

529 "pre_implement": self._commands.pre_implement, 

530 "post_implement": self._commands.post_implement, 

531 "custom_verification": self._commands.custom_verification, 

532 "confidence_gate": { 

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

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

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

536 }, 

537 "tdd_mode": self._commands.tdd_mode, 

538 "rate_limits": { 

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

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

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

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

543 }, 

544 "recursive_refine": { 

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

546 }, 

547 }, 

548 "scan": { 

549 "focus_dirs": self._scan.focus_dirs, 

550 "exclude_patterns": self._scan.exclude_patterns, 

551 "custom_agents": self._scan.custom_agents, 

552 }, 

553 "sprints": { 

554 "sprints_dir": self._sprints.sprints_dir, 

555 "default_timeout": self._sprints.default_timeout, 

556 "default_max_workers": self._sprints.default_max_workers, 

557 }, 

558 "loops": { 

559 "loops_dir": self._loops.loops_dir, 

560 "queue_wait_timeout_seconds": self._loops.queue_wait_timeout_seconds, 

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

562 }, 

563 "learning_tests": { 

564 "stale_after_days": self._learning_tests.stale_after_days, 

565 }, 

566 "events": { 

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

568 "socket": { 

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

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

571 }, 

572 "otel": { 

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

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

575 }, 

576 "webhook": { 

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

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

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

580 }, 

581 }, 

582 "sync": { 

583 "enabled": self._sync.enabled, 

584 "provider": self._sync.provider, 

585 "github": { 

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

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

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

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

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

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

592 }, 

593 }, 

594 "dependency_mapping": { 

595 "overlap_min_files": self._dependency_mapping.overlap_min_files, 

596 "overlap_min_ratio": self._dependency_mapping.overlap_min_ratio, 

597 "min_directory_depth": self._dependency_mapping.min_directory_depth, 

598 "conflict_threshold": self._dependency_mapping.conflict_threshold, 

599 "high_conflict_threshold": self._dependency_mapping.high_conflict_threshold, 

600 "confidence_modifier": self._dependency_mapping.confidence_modifier, 

601 "scoring_weights": { 

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

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

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

605 }, 

606 "exclude_common_files": self._dependency_mapping.exclude_common_files, 

607 }, 

608 "cli": { 

609 "color": self._cli.color, 

610 "colors": { 

611 "logger": { 

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

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

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

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

616 }, 

617 "priority": { 

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

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

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

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

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

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

624 }, 

625 "type": { 

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

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

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

629 }, 

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

631 "fsm_edge_labels": { 

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

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

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

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

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

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

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

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

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

641 }, 

642 }, 

643 }, 

644 } 

645 

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

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

648 

649 Args: 

650 var_path: Dot-separated path to configuration value 

651 

652 Returns: 

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

654 """ 

655 parts = var_path.split(".") 

656 value: Any = self.to_dict() 

657 

658 for part in parts: 

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

660 value = value[part] 

661 else: 

662 return None 

663 

664 if value is None: 

665 return None 

666 if isinstance(value, list): 

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

668 return str(value) 

669 

670 

671# Backwards compatibility alias 

672CLConfig = BRConfig