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

154 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-05-11 00:29 -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 warnings 

11from dataclasses import dataclass 

12from pathlib import Path 

13from typing import Any, cast 

14 

15from little_loops.config.automation import ( 

16 AutomationConfig, 

17 CommandsConfig, 

18 DependencyMappingConfig, 

19 ParallelAutomationConfig, 

20) 

21from little_loops.config.cli import CliConfig, RefineStatusConfig 

22from little_loops.config.features import ( 

23 EventsConfig, 

24 IssuesConfig, 

25 LearningTestsConfig, 

26 LoopsConfig, 

27 ScanConfig, 

28 SprintsConfig, 

29 SyncConfig, 

30) 

31from little_loops.parallel.types import ParallelConfig 

32 

33 

34@dataclass 

35class ProjectConfig: 

36 """Project-level configuration.""" 

37 

38 name: str = "" 

39 src_dir: str = "src/" 

40 test_dir: str = "tests" 

41 test_cmd: str = "pytest" 

42 lint_cmd: str = "ruff check ." 

43 type_cmd: str | None = "mypy" 

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

45 build_cmd: str | None = None 

46 run_cmd: str | None = None 

47 

48 @classmethod 

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

50 """Create ProjectConfig from dictionary.""" 

51 return cls( 

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

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

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

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

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

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

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

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

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

61 ) 

62 

63 

64class BRConfig: 

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

66 

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

68 Provides convenient property access to all configuration values. 

69 

70 Example: 

71 config = BRConfig(Path.cwd()) 

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

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

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

75 """ 

76 

77 CONFIG_FILENAME = "ll-config.json" 

78 CONFIG_DIR = ".ll" 

79 

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

81 """Initialize configuration from project root. 

82 

83 Args: 

84 project_root: Path to the project root directory 

85 """ 

86 self.project_root = project_root.resolve() 

87 self._raw_config = self._load_config() 

88 self._parse_config() 

89 

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

91 """Load configuration from file.""" 

92 config_path = self.project_root / self.CONFIG_DIR / self.CONFIG_FILENAME 

93 if config_path.exists(): 

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

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

96 return {} 

97 

98 def _parse_config(self) -> None: 

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

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

101 if not self._project.name: 

102 self._project.name = self.project_root.name 

103 

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

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

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

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

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

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

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

111 self._learning_tests = LearningTestsConfig.from_dict( 

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

113 ) 

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

115 self._dependency_mapping = DependencyMappingConfig.from_dict( 

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

117 ) 

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

119 self._refine_status = RefineStatusConfig.from_dict( 

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

121 ) 

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

123 

124 @property 

125 def project(self) -> ProjectConfig: 

126 """Get project configuration.""" 

127 return self._project 

128 

129 @property 

130 def issues(self) -> IssuesConfig: 

131 """Get issues configuration.""" 

132 return self._issues 

133 

134 @property 

135 def automation(self) -> AutomationConfig: 

136 """Get automation configuration.""" 

137 return self._automation 

138 

139 @property 

140 def parallel(self) -> ParallelAutomationConfig: 

141 """Get parallel automation configuration.""" 

142 return self._parallel 

143 

144 @property 

145 def commands(self) -> CommandsConfig: 

146 """Get commands configuration.""" 

147 return self._commands 

148 

149 @property 

150 def scan(self) -> ScanConfig: 

151 """Get scan configuration.""" 

152 return self._scan 

153 

154 @property 

155 def sprints(self) -> SprintsConfig: 

156 """Get sprints configuration.""" 

157 return self._sprints 

158 

159 @property 

160 def loops(self) -> LoopsConfig: 

161 """Get loops configuration.""" 

162 return self._loops 

163 

164 @property 

165 def learning_tests(self) -> LearningTestsConfig: 

166 """Get learning tests configuration.""" 

167 return self._learning_tests 

168 

169 @property 

170 def sync(self) -> SyncConfig: 

171 """Get sync configuration.""" 

172 return self._sync 

173 

174 @property 

175 def dependency_mapping(self) -> DependencyMappingConfig: 

176 """Get dependency mapping configuration.""" 

177 return self._dependency_mapping 

178 

179 @property 

180 def cli(self) -> CliConfig: 

181 """Get CLI output configuration.""" 

182 return self._cli 

183 

184 @property 

185 def refine_status(self) -> RefineStatusConfig: 

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

187 return self._refine_status 

188 

189 @property 

190 def events(self) -> EventsConfig: 

191 """Get events configuration.""" 

192 return self._events 

193 

194 @property 

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

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

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

198 

199 @property 

200 def repo_path(self) -> Path: 

201 """Get the repository root path.""" 

202 return self.project_root 

203 

204 # Convenience methods for common operations 

205 

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

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

208 

209 Args: 

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

211 

212 Returns: 

213 Path to the issue category directory 

214 """ 

215 if category in self._issues.categories: 

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

217 else: 

218 dir_name = category 

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

220 

221 def get_completed_dir(self) -> Path: 

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

223 warnings.warn( 

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

225 DeprecationWarning, 

226 stacklevel=2, 

227 ) 

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

229 

230 def get_deferred_dir(self) -> Path: 

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

232 warnings.warn( 

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

234 DeprecationWarning, 

235 stacklevel=2, 

236 ) 

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

238 

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

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

241 

242 Args: 

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

244 

245 Returns: 

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

247 """ 

248 if category in self._issues.categories: 

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

250 return category.upper()[:3] 

251 

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

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

254 

255 Args: 

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

257 

258 Returns: 

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

260 """ 

261 if category in self._issues.categories: 

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

263 return "fix" 

264 

265 def get_loops_dir(self) -> Path: 

266 """Get the loops directory path.""" 

267 return self.project_root / self._loops.loops_dir 

268 

269 def get_src_path(self) -> Path: 

270 """Get the source directory path.""" 

271 return self.project_root / self._project.src_dir 

272 

273 def get_worktree_base(self) -> Path: 

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

275 return self.project_root / self._automation.worktree_base 

276 

277 def get_state_file(self) -> Path: 

278 """Get the state file path.""" 

279 return self.project_root / self._automation.state_file 

280 

281 def get_parallel_state_file(self) -> Path: 

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

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

284 

285 def create_parallel_config( 

286 self, 

287 *, 

288 max_workers: int | None = None, 

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

290 max_issues: int = 0, 

291 dry_run: bool = False, 

292 timeout_seconds: int | None = None, 

293 idle_timeout_per_issue: int | None = None, 

294 stream_output: bool | None = None, 

295 show_model: bool | None = None, 

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

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

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

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

300 merge_pending: bool = False, 

301 clean_start: bool = False, 

302 ignore_pending: bool = False, 

303 overlap_detection: bool = False, 

304 serialize_overlapping: bool = True, 

305 base_branch: str = "main", 

306 remote_name: str | None = None, 

307 ) -> ParallelConfig: 

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

309 

310 Args: 

311 max_workers: Override max_workers (default: from config) 

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

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

314 dry_run: Preview mode (default: False) 

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

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

317 stream_output: Stream output (default: from config) 

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

319 only_ids: If provided, only process these issue IDs 

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

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

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

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

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

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

326 

327 Returns: 

328 ParallelConfig configured from BRConfig 

329 """ 

330 return ParallelConfig( 

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

332 p0_sequential=self._parallel.p0_sequential, 

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

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

335 max_merge_retries=self._parallel.max_merge_retries, 

336 priority_filter=priority_filter or self._issues.priorities, 

337 max_issues=max_issues, 

338 dry_run=dry_run, 

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

340 idle_timeout_per_issue=idle_timeout_per_issue 

341 if idle_timeout_per_issue is not None 

342 else 0, 

343 stream_subprocess_output=( 

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

345 ), 

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

347 command_prefix=self._parallel.command_prefix, 

348 ready_command=self._parallel.ready_command, 

349 manage_command=self._parallel.manage_command, 

350 decide_command=self._parallel.decide_command, 

351 only_ids=only_ids, 

352 skip_ids=skip_ids, 

353 type_prefixes=type_prefixes, 

354 label_filter=label_filter, 

355 worktree_copy_files=self._parallel.worktree_copy_files, 

356 require_code_changes=self._parallel.require_code_changes, 

357 use_feature_branches=self._parallel.use_feature_branches, 

358 merge_pending=merge_pending, 

359 clean_start=clean_start, 

360 ignore_pending=ignore_pending, 

361 overlap_detection=overlap_detection, 

362 serialize_overlapping=serialize_overlapping, 

363 base_branch=base_branch, 

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

365 ) 

366 

367 @property 

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

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

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

371 

372 @property 

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

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

375 return self._issues.priorities 

376 

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

378 """Convert configuration to dictionary. 

379 

380 Useful for variable substitution in command templates. 

381 """ 

382 return { 

383 "project": { 

384 "name": self._project.name, 

385 "src_dir": self._project.src_dir, 

386 "test_dir": self._project.test_dir, 

387 "test_cmd": self._project.test_cmd, 

388 "lint_cmd": self._project.lint_cmd, 

389 "type_cmd": self._project.type_cmd, 

390 "format_cmd": self._project.format_cmd, 

391 "build_cmd": self._project.build_cmd, 

392 "run_cmd": self._project.run_cmd, 

393 }, 

394 "issues": { 

395 "base_dir": self._issues.base_dir, 

396 "categories": { 

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

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

399 }, 

400 "priorities": self._issues.priorities, 

401 "templates_dir": self._issues.templates_dir, 

402 "capture_template": self._issues.capture_template, 

403 }, 

404 "automation": { 

405 "timeout_seconds": self._automation.timeout_seconds, 

406 "idle_timeout_seconds": self._automation.idle_timeout_seconds, 

407 "state_file": self._automation.state_file, 

408 "worktree_base": self._automation.worktree_base, 

409 "max_workers": self._automation.max_workers, 

410 "stream_output": self._automation.stream_output, 

411 "max_continuations": self._automation.max_continuations, 

412 }, 

413 "parallel": { 

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

415 "p0_sequential": self._parallel.p0_sequential, 

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

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

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

419 "max_merge_retries": self._parallel.max_merge_retries, 

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

421 "command_prefix": self._parallel.command_prefix, 

422 "ready_command": self._parallel.ready_command, 

423 "manage_command": self._parallel.manage_command, 

424 "decide_command": self._parallel.decide_command, 

425 "worktree_copy_files": self._parallel.worktree_copy_files, 

426 "require_code_changes": self._parallel.require_code_changes, 

427 "use_feature_branches": self._parallel.use_feature_branches, 

428 "remote_name": self._parallel.remote_name, 

429 }, 

430 "commands": { 

431 "pre_implement": self._commands.pre_implement, 

432 "post_implement": self._commands.post_implement, 

433 "custom_verification": self._commands.custom_verification, 

434 "confidence_gate": { 

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

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

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

438 }, 

439 "tdd_mode": self._commands.tdd_mode, 

440 "rate_limits": { 

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

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

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

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

445 }, 

446 "recursive_refine": { 

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

448 }, 

449 }, 

450 "scan": { 

451 "focus_dirs": self._scan.focus_dirs, 

452 "exclude_patterns": self._scan.exclude_patterns, 

453 "custom_agents": self._scan.custom_agents, 

454 }, 

455 "sprints": { 

456 "sprints_dir": self._sprints.sprints_dir, 

457 "default_timeout": self._sprints.default_timeout, 

458 "default_max_workers": self._sprints.default_max_workers, 

459 }, 

460 "loops": { 

461 "loops_dir": self._loops.loops_dir, 

462 "queue_wait_timeout_seconds": self._loops.queue_wait_timeout_seconds, 

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

464 }, 

465 "learning_tests": { 

466 "stale_after_days": self._learning_tests.stale_after_days, 

467 }, 

468 "events": { 

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

470 "socket": { 

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

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

473 }, 

474 "otel": { 

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

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

477 }, 

478 "webhook": { 

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

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

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

482 }, 

483 }, 

484 "sync": { 

485 "enabled": self._sync.enabled, 

486 "provider": self._sync.provider, 

487 "github": { 

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

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

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

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

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

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

494 }, 

495 }, 

496 "dependency_mapping": { 

497 "overlap_min_files": self._dependency_mapping.overlap_min_files, 

498 "overlap_min_ratio": self._dependency_mapping.overlap_min_ratio, 

499 "min_directory_depth": self._dependency_mapping.min_directory_depth, 

500 "conflict_threshold": self._dependency_mapping.conflict_threshold, 

501 "high_conflict_threshold": self._dependency_mapping.high_conflict_threshold, 

502 "confidence_modifier": self._dependency_mapping.confidence_modifier, 

503 "scoring_weights": { 

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

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

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

507 }, 

508 "exclude_common_files": self._dependency_mapping.exclude_common_files, 

509 }, 

510 "cli": { 

511 "color": self._cli.color, 

512 "colors": { 

513 "logger": { 

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

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

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

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

518 }, 

519 "priority": { 

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

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

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

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

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

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

526 }, 

527 "type": { 

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

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

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

531 }, 

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

533 "fsm_edge_labels": { 

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

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

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

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

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

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

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

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

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

543 }, 

544 }, 

545 }, 

546 } 

547 

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

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

550 

551 Args: 

552 var_path: Dot-separated path to configuration value 

553 

554 Returns: 

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

556 """ 

557 parts = var_path.split(".") 

558 value: Any = self.to_dict() 

559 

560 for part in parts: 

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

562 value = value[part] 

563 else: 

564 return None 

565 

566 if value is None: 

567 return None 

568 if isinstance(value, list): 

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

570 return str(value) 

571 

572 

573# Backwards compatibility alias 

574CLConfig = BRConfig