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

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 from dataclasses import dataclass

11 from pathlib import Path

12 from typing import Any, cast

13

14 from little_loops.config.automation import (

15 AutomationConfig,

16 CommandsConfig,

17 DependencyMappingConfig,

18 ParallelAutomationConfig,

19)

20 from little_loops.config.cli import CliConfig, RefineStatusConfig

21 from little_loops.config.features import (

22 IssuesConfig,

23 LoopsConfig,

24 ScanConfig,

25 SprintsConfig,

26 SyncConfig,

27)

28 from little_loops.parallel.types import ParallelConfig

29

30

31@dataclass

32 class ProjectConfig:

33 """Project-level configuration."""

34

35 name: str = ""

36 src_dir: str = "src/"

37 test_dir: str = "tests"

38 test_cmd: str = "pytest"

39 lint_cmd: str = "ruff check ."

40 type_cmd: str | None = "mypy"

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

42 build_cmd: str | None = None

43 run_cmd: str | None = None

44

45 @classmethod

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

47 """Create ProjectConfig from dictionary."""

48 return cls(

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

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

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

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

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

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

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

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

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

58 )

59

60

61 class BRConfig:

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

63

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

65 Provides convenient property access to all configuration values.

66

67 Example:

68 config = BRConfig(Path.cwd())

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

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

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

72 """

73

74 CONFIG_FILENAME = "ll-config.json"

75 CONFIG_DIR = ".claude"

76

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

78 """Initialize configuration from project root.

79

80 Args:

81 project_root: Path to the project root directory

82 """

83 self.project_root = project_root.resolve()

84 self._raw_config = self._load_config()

85 self._parse_config()

86

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

88 """Load configuration from file."""

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

90 if config_path.exists():

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

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

93 return {}

94

95 def _parse_config(self) -> None:

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

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

98 if not self._project.name:

99 self._project.name = self.project_root.name

100

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

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

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

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

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

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

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

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

109 self._dependency_mapping = DependencyMappingConfig.from_dict(

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

111 )

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

113 self._refine_status = RefineStatusConfig.from_dict(

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

115 )

116

117 @property

118 def project(self) -> ProjectConfig:

119 """Get project configuration."""

120 return self._project

121

122 @property

123 def issues(self) -> IssuesConfig:

124 """Get issues configuration."""

125 return self._issues

126

127 @property

128 def automation(self) -> AutomationConfig:

129 """Get automation configuration."""

130 return self._automation

131

132 @property

133 def parallel(self) -> ParallelAutomationConfig:

134 """Get parallel automation configuration."""

135 return self._parallel

136

137 @property

138 def commands(self) -> CommandsConfig:

139 """Get commands configuration."""

140 return self._commands

141

142 @property

143 def scan(self) -> ScanConfig:

144 """Get scan configuration."""

145 return self._scan

146

147 @property

148 def sprints(self) -> SprintsConfig:

149 """Get sprints configuration."""

150 return self._sprints

151

152 @property

153 def loops(self) -> LoopsConfig:

154 """Get loops configuration."""

155 return self._loops

156

157 @property

158 def sync(self) -> SyncConfig:

159 """Get sync configuration."""

160 return self._sync

161

162 @property

163 def dependency_mapping(self) -> DependencyMappingConfig:

164 """Get dependency mapping configuration."""

165 return self._dependency_mapping

166

167 @property

168 def cli(self) -> CliConfig:

169 """Get CLI output configuration."""

170 return self._cli

171

172 @property

173 def refine_status(self) -> RefineStatusConfig:

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

175 return self._refine_status

176

177 @property

178 def repo_path(self) -> Path:

179 """Get the repository root path."""

180 return self.project_root

181

182 # Convenience methods for common operations

183

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

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

186

187 Args:

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

189

190 Returns:

191 Path to the issue category directory

192 """

193 if category in self._issues.categories:

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

195 else:

196 dir_name = category

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

198

199 def get_completed_dir(self) -> Path:

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

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

202

203 def get_deferred_dir(self) -> Path:

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

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

206

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

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

209

210 Args:

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

212

213 Returns:

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

215 """

216 if category in self._issues.categories:

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

218 return category.upper()[:3]

219

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

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

222

223 Args:

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

225

226 Returns:

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

228 """

229 if category in self._issues.categories:

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

231 return "fix"

232

233 def get_loops_dir(self) -> Path:

234 """Get the loops directory path."""

235 return self.project_root / self._loops.loops_dir

236

237 def get_src_path(self) -> Path:

238 """Get the source directory path."""

239 return self.project_root / self._project.src_dir

240

241 def get_worktree_base(self) -> Path:

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

243 return self.project_root / self._automation.worktree_base

244

245 def get_state_file(self) -> Path:

246 """Get the state file path."""

247 return self.project_root / self._automation.state_file

248

249 def get_parallel_state_file(self) -> Path:

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

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

252

253 def create_parallel_config(

254 self,

255 *,

256 max_workers: int | None = None,

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

258 max_issues: int = 0,

259 dry_run: bool = False,

260 timeout_seconds: int | None = None,

261 idle_timeout_per_issue: int | None = None,

262 stream_output: bool | None = None,

263 show_model: bool | None = None,

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

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

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

267 merge_pending: bool = False,

268 clean_start: bool = False,

269 ignore_pending: bool = False,

270 overlap_detection: bool = False,

271 serialize_overlapping: bool = True,

272 base_branch: str = "main",

273 ) -> ParallelConfig:

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

275

276 Args:

277 max_workers: Override max_workers (default: from config)

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

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

280 dry_run: Preview mode (default: False)

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

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

283 stream_output: Stream output (default: from config)

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

285 only_ids: If provided, only process these issue IDs

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

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

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

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

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

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

292

293 Returns:

294 ParallelConfig configured from BRConfig

295 """

296 return ParallelConfig(

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

298 p0_sequential=self._parallel.p0_sequential,

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

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

301 max_merge_retries=self._parallel.max_merge_retries,

302 priority_filter=priority_filter or self._issues.priorities,

303 max_issues=max_issues,

304 dry_run=dry_run,

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

306 idle_timeout_per_issue=idle_timeout_per_issue

307 if idle_timeout_per_issue is not None

308 else 0,

309 stream_subprocess_output=(

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

311 ),

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

313 command_prefix=self._parallel.command_prefix,

314 ready_command=self._parallel.ready_command,

315 manage_command=self._parallel.manage_command,

316 only_ids=only_ids,

317 skip_ids=skip_ids,

318 type_prefixes=type_prefixes,

319 worktree_copy_files=self._parallel.worktree_copy_files,

320 require_code_changes=self._parallel.require_code_changes,

321 merge_pending=merge_pending,

322 clean_start=clean_start,

323 ignore_pending=ignore_pending,

324 overlap_detection=overlap_detection,

325 serialize_overlapping=serialize_overlapping,

326 base_branch=base_branch,

327 )

328

329 @property

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

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

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

333

334 @property

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

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

337 return self._issues.priorities

338

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

340 """Convert configuration to dictionary.

341

342 Useful for variable substitution in command templates.

343 """

344 return {

345 "project": {

346 "name": self._project.name,

347 "src_dir": self._project.src_dir,

348 "test_dir": self._project.test_dir,

349 "test_cmd": self._project.test_cmd,

350 "lint_cmd": self._project.lint_cmd,

351 "type_cmd": self._project.type_cmd,

352 "format_cmd": self._project.format_cmd,

353 "build_cmd": self._project.build_cmd,

354 "run_cmd": self._project.run_cmd,

355 },

356 "issues": {

357 "base_dir": self._issues.base_dir,

358 "categories": {

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

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

361 },

362 "completed_dir": self._issues.completed_dir,

363 "deferred_dir": self._issues.deferred_dir,

364 "priorities": self._issues.priorities,

365 "templates_dir": self._issues.templates_dir,

366 "capture_template": self._issues.capture_template,

367 },

368 "automation": {

369 "timeout_seconds": self._automation.timeout_seconds,

370 "state_file": self._automation.state_file,

371 "worktree_base": self._automation.worktree_base,

372 "max_workers": self._automation.max_workers,

373 "stream_output": self._automation.stream_output,

374 "max_continuations": self._automation.max_continuations,

375 },

376 "parallel": {

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

378 "p0_sequential": self._parallel.p0_sequential,

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

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

381 "timeout_seconds": self._parallel.base.timeout_seconds,

382 "max_merge_retries": self._parallel.max_merge_retries,

383 "stream_output": self._parallel.base.stream_output,

384 "command_prefix": self._parallel.command_prefix,

385 "ready_command": self._parallel.ready_command,

386 "manage_command": self._parallel.manage_command,

387 },

388 "commands": {

389 "pre_implement": self._commands.pre_implement,

390 "post_implement": self._commands.post_implement,

391 "custom_verification": self._commands.custom_verification,

392 "confidence_gate": {

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

394 "threshold": self._commands.confidence_gate.threshold,

395 },

396 "tdd_mode": self._commands.tdd_mode,

397 },

398 "scan": {

399 "focus_dirs": self._scan.focus_dirs,

400 "exclude_patterns": self._scan.exclude_patterns,

401 "custom_agents": self._scan.custom_agents,

402 },

403 "sprints": {

404 "sprints_dir": self._sprints.sprints_dir,

405 "default_timeout": self._sprints.default_timeout,

406 "default_max_workers": self._sprints.default_max_workers,

407 },

408 "loops": {

409 "loops_dir": self._loops.loops_dir,

410 },

411 "sync": {

412 "enabled": self._sync.enabled,

413 "provider": self._sync.provider,

414 "github": {

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

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

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

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

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

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

421 },

422 },

423 "dependency_mapping": {

424 "overlap_min_files": self._dependency_mapping.overlap_min_files,

425 "overlap_min_ratio": self._dependency_mapping.overlap_min_ratio,

426 "min_directory_depth": self._dependency_mapping.min_directory_depth,

427 "conflict_threshold": self._dependency_mapping.conflict_threshold,

428 "high_conflict_threshold": self._dependency_mapping.high_conflict_threshold,

429 "confidence_modifier": self._dependency_mapping.confidence_modifier,

430 "scoring_weights": {

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

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

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

434 },

435 "exclude_common_files": self._dependency_mapping.exclude_common_files,

436 },

437 }

438

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

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

441

442 Args:

443 var_path: Dot-separated path to configuration value

444

445 Returns:

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

447 """

448 parts = var_path.split(".")

449 value: Any = self.to_dict()

450

451 for part in parts:

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

453 value = value[part]

454 else:

455 return None

456

457 if value is None:

458 return None

459 if isinstance(value, list):

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

461 return str(value)

462

463

464# Backwards compatibility alias

465 CLConfig = BRConfig