Coverage for src / cli / cli.py: 16%

347 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-01-04 04:43 +0000

1#!/usr/bin/env python3 

2# ruff: noqa: E402 

3""" 

4mala CLI: Agent SDK orchestrator for parallel issue processing. 

5 

6Usage: 

7 mala run [OPTIONS] [REPO_PATH] 

8 mala epic-verify [OPTIONS] EPIC_ID [REPO_PATH] 

9 mala clean 

10 mala status 

11""" 

12 

13from __future__ import annotations 

14 

15import sys 

16from dataclasses import dataclass, replace 

17from pathlib import Path 

18from typing import TYPE_CHECKING, Any 

19 

20from ..orchestration.cli_support import USER_CONFIG_DIR, get_runs_dir, load_user_env 

21 

22if TYPE_CHECKING: 

23 from src.infra.io.config import MalaConfig, ResolvedConfig 

24 

25# Bootstrap state: tracks whether bootstrap() has been called 

26# These are populated on first access via __getattr__ 

27_bootstrapped = False 

28_braintrust_enabled = False 

29 

30# Lazy-loaded module cache for SDK-dependent imports 

31# These are populated on first access via __getattr__ 

32_lazy_modules: dict[str, Any] = {} 

33 

34# Names that are lazily loaded (for __getattr__ to handle) 

35_LAZY_NAMES = frozenset( 

36 { 

37 "BeadsClient", 

38 "MalaConfig", 

39 "MalaOrchestrator", 

40 "OrchestratorConfig", 

41 "create_orchestrator", 

42 "get_all_locks", 

43 "get_lock_dir", 

44 "get_running_instances", 

45 "get_running_instances_for_dir", 

46 } 

47) 

48 

49 

50def _lazy(name: str) -> Any: # noqa: ANN401 

51 """Access a lazy-loaded module attribute. 

52 

53 This function provides access to SDK-dependent imports that are 

54 lazy-loaded via __getattr__. Using this avoids runtime imports 

55 inside command functions while still deferring SDK loading. 

56 """ 

57 return getattr(sys.modules[__name__], name) 

58 

59 

60def bootstrap() -> None: 

61 """Initialize environment and Braintrust tracing. 

62 

63 Must be called before using any CLI commands or importing claude_agent_sdk. 

64 This function is idempotent - calling it multiple times has no additional effect. 

65 

66 Side effects: 

67 - Loads environment variables from ~/.config/mala/.env 

68 - Sets up Braintrust SDK patching if BRAINTRUST_API_KEY is set 

69 """ 

70 global _bootstrapped, _braintrust_enabled 

71 

72 if _bootstrapped: 

73 return 

74 

75 # Load environment variables early (needed for config) 

76 load_user_env() 

77 

78 # Setup Braintrust tracing BEFORE importing claude_agent_sdk anywhere 

79 # This patches the SDK before any imports can use the unpatched version 

80 # Note: We use MalaConfig here to read BRAINTRUST_API_KEY consistently 

81 # from the environment (which now includes user .env after load_user_env) 

82 from src.infra.io.config import MalaConfig 

83 

84 config = MalaConfig.from_env(validate=False) 

85 if config.braintrust_api_key: 

86 try: 

87 from braintrust.wrappers.claude_agent_sdk import setup_claude_agent_sdk 

88 

89 setup_claude_agent_sdk(project="mala") 

90 _braintrust_enabled = True 

91 except ImportError: 

92 pass # braintrust not installed 

93 

94 _bootstrapped = True 

95 

96 

97def is_braintrust_enabled() -> bool: 

98 """Return whether Braintrust tracing was successfully enabled. 

99 

100 Must be called after bootstrap(). 

101 """ 

102 return _braintrust_enabled 

103 

104 

105# Import modules that do NOT depend on claude_agent_sdk at module level 

106import asyncio 

107from datetime import datetime 

108from typing import Annotated, Never 

109 

110import typer 

111 

112from ..orchestration.cli_support import Colors, log, set_verbose 

113 

114# SDK-dependent imports (BeadsClient, MalaOrchestrator, get_lock_dir, run_metadata) 

115# are lazy-loaded via __getattr__ to ensure bootstrap() runs before claude_agent_sdk 

116# is imported. Access them as module attributes: BeadsClient, MalaOrchestrator, etc. 

117 

118 

119def display_dry_run_tasks( 

120 issues: list[dict[str, object]], 

121 focus: bool, 

122) -> None: 

123 """Display task order for dry-run preview. 

124 

125 Shows tasks in order with epic grouping when focus mode is enabled. 

126 

127 Args: 

128 issues: List of issue dicts with id, title, priority, status, parent_epic. 

129 focus: If True, display with epic headers for grouped output. 

130 """ 

131 if not issues: 

132 log("○", "No ready tasks found", Colors.GRAY) 

133 return 

134 

135 print() 

136 log("◐", f"Dry run: {len(issues)} task(s) would be processed", Colors.CYAN) 

137 print() 

138 

139 if focus: 

140 # Group by epic for display 

141 current_epic: str | None = None 

142 epic_count = 0 

143 task_in_epic = 0 

144 for issue in issues: 

145 epic = issue.get("parent_epic") 

146 # Ensure epic is a string or None 

147 epic_str: str | None = str(epic) if epic is not None else None 

148 if epic_str != current_epic: 

149 if current_epic is not None or ( 

150 current_epic is None and task_in_epic > 0 

151 ): 

152 print() # Add spacing between epics 

153 current_epic = epic_str 

154 epic_count += 1 

155 task_in_epic = 0 

156 if epic_str: 

157 print(f" {Colors.MAGENTA}▸ Epic: {epic_str}{Colors.RESET}") 

158 else: 

159 print(f" {Colors.GRAY}▸ (Orphan tasks){Colors.RESET}") 

160 

161 task_in_epic += 1 

162 _print_task_line(issue, indent=" ") 

163 else: 

164 # Simple flat list - show epic per task since there are no group headers 

165 for issue in issues: 

166 _print_task_line(issue, indent=" ", show_epic=True) 

167 

168 print() 

169 # Summary: count tasks per epic 

170 if focus: 

171 epic_counts: dict[str | None, int] = {} 

172 for issue in issues: 

173 epic = issue.get("parent_epic") 

174 epic_key: str | None = str(epic) if epic is not None else None 

175 epic_counts[epic_key] = epic_counts.get(epic_key, 0) + 1 

176 

177 epic_summary = ", ".join( 

178 f"{epic or '(orphan)'}: {count}" 

179 for epic, count in sorted( 

180 epic_counts.items(), key=lambda x: (x[0] is None, x[0] or "") 

181 ) 

182 ) 

183 log("◐", f"By epic: {epic_summary}", Colors.MUTED) 

184 

185 

186def _print_task_line( 

187 issue: dict[str, object], indent: str = " ", show_epic: bool = False 

188) -> None: 

189 """Print a single task line with ID, priority, title, and optionally epic.""" 

190 issue_id = issue.get("id", "?") 

191 title = issue.get("title", "") 

192 priority = issue.get("priority") 

193 status = issue.get("status", "") 

194 parent_epic = issue.get("parent_epic") 

195 

196 # Format priority badge 

197 prio_str = f"P{priority}" if priority is not None else "P?" 

198 

199 # Status indicator 

200 status_indicator = "" 

201 if status == "in_progress": 

202 status_indicator = f" {Colors.YELLOW}(WIP){Colors.RESET}" 

203 

204 # Epic indicator (shown in non-focus mode) 

205 epic_indicator = "" 

206 if show_epic and parent_epic: 

207 epic_indicator = f" {Colors.MUTED}({parent_epic}){Colors.RESET}" 

208 

209 print( 

210 f"{indent}{Colors.CYAN}{issue_id}{Colors.RESET} " 

211 f"[{Colors.MUTED}{prio_str}{Colors.RESET}] " 

212 f"{title}{epic_indicator}{status_indicator}" 

213 ) 

214 

215 

216# Valid values for --disable-validations flag 

217# Each value controls a specific validation phase: 

218# post-validate: Skip all validation commands (pytest, ruff, ty) after agent commits 

219# run-level-validate: Skip run-level validation at end of batch (reserved for future use) 

220# integration-tests: Exclude @pytest.mark.integration tests from pytest runs 

221# coverage: Disable code coverage enforcement (threshold check) 

222# e2e: Disable end-to-end fixture repo tests (run-level only) 

223# review: Disable automated LLM code review after quality gate passes 

224# followup-on-run-validate-fail: Disable auto-retry on run validation failures (reserved) 

225VALID_DISABLE_VALUES = frozenset( 

226 { 

227 "post-validate", 

228 "run-level-validate", 

229 "integration-tests", 

230 "coverage", 

231 "e2e", 

232 "review", 

233 "followup-on-run-validate-fail", 

234 } 

235) 

236 

237 

238@dataclass(frozen=True) 

239class ValidatedRunArgs: 

240 """Validated and parsed CLI arguments for the run command.""" 

241 

242 only_ids: set[str] | None 

243 disable_set: set[str] | None 

244 epic_override_ids: set[str] | None 

245 

246 

247@dataclass(frozen=True) 

248class ConfigOverrideResult: 

249 """Result of applying CLI overrides to config. 

250 

251 On success: resolved and updated_config are set, error is None. 

252 On failure: error is set, resolved and updated_config are None. 

253 """ 

254 

255 resolved: ResolvedConfig | None = None 

256 updated_config: MalaConfig | None = None 

257 error: str | None = None 

258 

259 @property 

260 def is_error(self) -> bool: 

261 """Return True if this result represents an error.""" 

262 return self.error is not None 

263 

264 

265def _build_cli_args_metadata( 

266 *, 

267 disable_validations: str | None, 

268 coverage_threshold: float | None, 

269 wip: bool, 

270 max_issues: int | None, 

271 max_gate_retries: int, 

272 max_review_retries: int, 

273 epic_override: str | None, 

274 resolved: ResolvedConfig, 

275 braintrust_enabled: bool, 

276) -> dict[str, object]: 

277 """Build the cli_args metadata dictionary for logging and OrchestratorConfig. 

278 

279 Args: 

280 disable_validations: Raw disable validations string from CLI. 

281 coverage_threshold: Coverage threshold from CLI. 

282 wip: Whether WIP prioritization is enabled. 

283 max_issues: Maximum issues to process. 

284 max_gate_retries: Maximum gate retry attempts. 

285 max_review_retries: Maximum review retry attempts. 

286 epic_override: Raw epic override string from CLI. 

287 resolved: Resolved config with effective values. 

288 braintrust_enabled: Whether braintrust actually initialized successfully. 

289 

290 Returns: 

291 Dictionary of CLI arguments for logging/metadata. 

292 """ 

293 return { 

294 "disable_validations": disable_validations, 

295 "coverage_threshold": coverage_threshold, 

296 "wip": wip, 

297 "max_issues": max_issues, 

298 "max_gate_retries": max_gate_retries, 

299 "max_review_retries": max_review_retries, 

300 "braintrust": braintrust_enabled, 

301 "review_timeout": resolved.review_timeout, 

302 "cerberus_spawn_args": list(resolved.cerberus_spawn_args), 

303 "cerberus_wait_args": list(resolved.cerberus_wait_args), 

304 "cerberus_env": dict(resolved.cerberus_env), 

305 "epic_override": epic_override, 

306 "max_epic_verification_retries": resolved.max_epic_verification_retries, 

307 } 

308 

309 

310def _apply_config_overrides( 

311 config: MalaConfig, 

312 review_timeout: int | None, 

313 cerberus_spawn_args: str | None, 

314 cerberus_wait_args: str | None, 

315 cerberus_env: str | None, 

316 max_epic_verification_retries: int | None, 

317 braintrust_enabled: bool, 

318 disable_review: bool, 

319 deadlock_detection_enabled: bool, 

320) -> ConfigOverrideResult: 

321 """Apply CLI overrides to MalaConfig, returning error on parse failures. 

322 

323 Args: 

324 config: Base MalaConfig from environment. 

325 review_timeout: Optional review timeout override. 

326 cerberus_spawn_args: Raw string of extra args for spawn. 

327 cerberus_wait_args: Raw string of extra args for wait. 

328 cerberus_env: Raw string of extra env vars (key=value pairs). 

329 max_epic_verification_retries: Optional max retries override. 

330 braintrust_enabled: Whether braintrust is enabled. 

331 disable_review: Whether review is disabled. 

332 deadlock_detection_enabled: Whether deadlock detection is enabled. 

333 

334 Returns: 

335 ConfigOverrideResult with resolved config and updated MalaConfig on success, 

336 or with error message on failure. 

337 """ 

338 from src.infra.io.config import CLIOverrides, build_resolved_config 

339 

340 cli_overrides = CLIOverrides( 

341 cerberus_spawn_args=cerberus_spawn_args, 

342 cerberus_wait_args=cerberus_wait_args, 

343 cerberus_env=cerberus_env, 

344 review_timeout=review_timeout, 

345 max_epic_verification_retries=max_epic_verification_retries, 

346 no_braintrust=not braintrust_enabled, 

347 disable_review=disable_review, 

348 ) 

349 

350 try: 

351 resolved = build_resolved_config(config, cli_overrides) 

352 except ValueError as exc: 

353 return ConfigOverrideResult(error=str(exc)) 

354 

355 updated_config = replace( 

356 config, 

357 review_timeout=resolved.review_timeout, 

358 cerberus_spawn_args=resolved.cerberus_spawn_args, 

359 cerberus_wait_args=resolved.cerberus_wait_args, 

360 cerberus_env=resolved.cerberus_env, 

361 max_epic_verification_retries=resolved.max_epic_verification_retries, 

362 deadlock_detection_enabled=deadlock_detection_enabled, 

363 ) 

364 

365 return ConfigOverrideResult(resolved=resolved, updated_config=updated_config) 

366 

367 

368def _handle_dry_run( 

369 repo_path: Path, 

370 epic: str | None, 

371 only_ids: set[str] | None, 

372 wip: bool, 

373 focus: bool, 

374 orphans_only: bool, 

375) -> Never: 

376 """Execute dry-run mode: display task order and exit. 

377 

378 Args: 

379 repo_path: Path to the repository. 

380 epic: Epic ID to filter by. 

381 only_ids: Set of specific issue IDs to process. 

382 wip: Whether to prioritize WIP issues. 

383 focus: Whether focus mode is enabled. 

384 orphans_only: Whether to only process orphan issues. 

385 

386 Raises: 

387 typer.Exit: Always exits with code 0 after displaying. 

388 """ 

389 

390 async def _dry_run() -> None: 

391 beads = _lazy("BeadsClient")(repo_path) 

392 issues = await beads.get_ready_issues_async( 

393 epic_id=epic, 

394 only_ids=only_ids, 

395 prioritize_wip=wip, 

396 focus=focus, 

397 orphans_only=orphans_only, 

398 ) 

399 display_dry_run_tasks(issues, focus=focus) 

400 

401 asyncio.run(_dry_run()) 

402 raise typer.Exit(0) 

403 

404 

405def _validate_run_args( 

406 only: str | None, 

407 disable_validations: str | None, 

408 coverage_threshold: float | None, 

409 epic: str | None, 

410 orphans_only: bool, 

411 epic_override: str | None, 

412 repo_path: Path, 

413) -> ValidatedRunArgs: 

414 """Validate and parse CLI arguments, raising typer.Exit(1) on errors. 

415 

416 Args: 

417 only: Comma-separated issue IDs to process (--only flag) 

418 disable_validations: Comma-separated validation names to disable 

419 coverage_threshold: Coverage threshold percentage (0-100) 

420 epic: Epic ID to filter by 

421 orphans_only: Whether to only process orphan issues 

422 epic_override: Comma-separated epic IDs to override 

423 repo_path: Path to the repository (must exist) 

424 

425 Returns: 

426 ValidatedRunArgs with parsed values 

427 

428 Raises: 

429 typer.Exit: If any validation fails 

430 """ 

431 # Parse --only flag into a set of issue IDs 

432 only_ids: set[str] | None = None 

433 if only: 

434 only_ids = { 

435 issue_id.strip() for issue_id in only.split(",") if issue_id.strip() 

436 } 

437 if not only_ids: 

438 log("✗", "Invalid --only value: no valid issue IDs found", Colors.RED) 

439 raise typer.Exit(1) 

440 

441 # Parse --disable-validations flag into a set 

442 disable_set: set[str] | None = None 

443 if disable_validations: 

444 disable_set = { 

445 val.strip() for val in disable_validations.split(",") if val.strip() 

446 } 

447 if not disable_set: 

448 log( 

449 "✗", 

450 "Invalid --disable-validations value: no valid values found", 

451 Colors.RED, 

452 ) 

453 raise typer.Exit(1) 

454 # Validate against known values 

455 unknown = disable_set - VALID_DISABLE_VALUES 

456 if unknown: 

457 log( 

458 "✗", 

459 f"Unknown --disable-validations value(s): {', '.join(sorted(unknown))}. " 

460 f"Valid values: {', '.join(sorted(VALID_DISABLE_VALUES))}", 

461 Colors.RED, 

462 ) 

463 raise typer.Exit(1) 

464 

465 # Validate coverage threshold range 

466 if coverage_threshold is not None and not 0 <= coverage_threshold <= 100: 

467 log( 

468 "✗", 

469 f"Invalid --coverage-threshold value: {coverage_threshold}. Must be between 0 and 100.", 

470 Colors.RED, 

471 ) 

472 raise typer.Exit(1) 

473 

474 # Validate --epic and --orphans-only are mutually exclusive 

475 if epic and orphans_only: 

476 log( 

477 "✗", 

478 "--epic and --orphans-only are mutually exclusive. " 

479 "--epic filters to children of an epic, while --orphans-only filters to issues without a parent epic.", 

480 Colors.RED, 

481 ) 

482 raise typer.Exit(1) 

483 

484 # Parse --epic-override flag into a set of epic IDs 

485 epic_override_ids: set[str] | None = None 

486 if epic_override: 

487 epic_override_ids = { 

488 eid.strip() for eid in epic_override.split(",") if eid.strip() 

489 } 

490 if not epic_override_ids: 

491 log( 

492 "✗", 

493 "Invalid --epic-override value: no valid epic IDs found", 

494 Colors.RED, 

495 ) 

496 raise typer.Exit(1) 

497 

498 # Validate repo_path exists 

499 if not repo_path.exists(): 

500 log("✗", f"Repository not found: {repo_path}", Colors.RED) 

501 raise typer.Exit(1) 

502 

503 return ValidatedRunArgs( 

504 only_ids=only_ids, 

505 disable_set=disable_set, 

506 epic_override_ids=epic_override_ids, 

507 ) 

508 

509 

510app = typer.Typer( 

511 name="mala", 

512 help="Parallel issue processing with Claude Agent SDK", 

513 add_completion=False, 

514) 

515 

516 

517@app.command() 

518def run( 

519 repo_path: Annotated[ 

520 Path, 

521 typer.Argument( 

522 help="Path to repository with beads issues", 

523 ), 

524 ] = Path("."), 

525 max_agents: Annotated[ 

526 int | None, 

527 typer.Option( 

528 "--max-agents", "-n", help="Maximum concurrent agents (default: unlimited)" 

529 ), 

530 ] = None, 

531 timeout: Annotated[ 

532 int | None, 

533 typer.Option( 

534 "--timeout", "-t", help="Timeout per agent in minutes (default: 60)" 

535 ), 

536 ] = None, 

537 max_issues: Annotated[ 

538 int | None, 

539 typer.Option( 

540 "--max-issues", "-i", help="Maximum issues to process (default: unlimited)" 

541 ), 

542 ] = None, 

543 epic: Annotated[ 

544 str | None, 

545 typer.Option( 

546 "--epic", "-e", help="Only process tasks that are children of this epic" 

547 ), 

548 ] = None, 

549 only: Annotated[ 

550 str | None, 

551 typer.Option( 

552 "--only", 

553 "-o", 

554 help="Comma-separated list of issue IDs to process exclusively", 

555 ), 

556 ] = None, 

557 max_gate_retries: Annotated[ 

558 int, 

559 typer.Option( 

560 "--max-gate-retries", 

561 help="Maximum quality gate retry attempts per issue (default: 3)", 

562 ), 

563 ] = 3, 

564 max_review_retries: Annotated[ 

565 int, 

566 typer.Option( 

567 "--max-review-retries", 

568 help="Maximum codex review retry attempts per issue (default: 3)", 

569 ), 

570 ] = 3, 

571 disable_validations: Annotated[ 

572 str | None, 

573 typer.Option( 

574 "--disable-validations", 

575 help=( 

576 "Comma-separated validations to skip. Options: " 

577 "post-validate (skip pytest/ruff/ty after commits), " 

578 "integration-tests (exclude @pytest.mark.integration tests), " 

579 "coverage (disable coverage threshold check), " 

580 "e2e (skip end-to-end fixture tests), " 

581 "review (skip LLM code review)" 

582 ), 

583 ), 

584 ] = None, 

585 coverage_threshold: Annotated[ 

586 float | None, 

587 typer.Option( 

588 "--coverage-threshold", 

589 help="Minimum coverage percentage (0-100). If not set, uses 'no decrease' mode which requires coverage >= previous baseline.", 

590 ), 

591 ] = None, 

592 wip: Annotated[ 

593 bool, 

594 typer.Option( 

595 "--wip", 

596 help="Prioritize in_progress issues before open issues", 

597 ), 

598 ] = False, 

599 focus: Annotated[ 

600 bool, 

601 typer.Option( 

602 "--focus/--no-focus", 

603 help="Group tasks by epic for focused work (default: on); --no-focus uses priority-only ordering", 

604 ), 

605 ] = True, 

606 dry_run: Annotated[ 

607 bool, 

608 typer.Option( 

609 "--dry-run", 

610 help="Preview task order without processing; shows what would be run", 

611 ), 

612 ] = False, 

613 verbose: Annotated[ 

614 bool, 

615 typer.Option( 

616 "--verbose", 

617 "-v", 

618 help="Enable verbose output; shows full tool arguments instead of single line per tool call", 

619 ), 

620 ] = False, 

621 review_timeout: Annotated[ 

622 int | None, 

623 typer.Option( 

624 "--review-timeout", 

625 help="Timeout in seconds for review operations (default: 1200)", 

626 ), 

627 ] = None, 

628 cerberus_spawn_args: Annotated[ 

629 str | None, 

630 typer.Option( 

631 "--cerberus-spawn-args", 

632 help="Extra args for `review-gate spawn-code-review` (shlex-style string)", 

633 ), 

634 ] = None, 

635 cerberus_wait_args: Annotated[ 

636 str | None, 

637 typer.Option( 

638 "--cerberus-wait-args", 

639 help="Extra args for `review-gate wait` (shlex-style string)", 

640 ), 

641 ] = None, 

642 cerberus_env: Annotated[ 

643 str | None, 

644 typer.Option( 

645 "--cerberus-env", 

646 help="Extra env for review-gate (JSON object or comma KEY=VALUE list)", 

647 ), 

648 ] = None, 

649 epic_override: Annotated[ 

650 str | None, 

651 typer.Option( 

652 "--epic-override", 

653 help="Comma-separated epic IDs to close without verification (explicit human bypass)", 

654 ), 

655 ] = None, 

656 orphans_only: Annotated[ 

657 bool, 

658 typer.Option( 

659 "--orphans-only", 

660 help="Only process issues with no parent epic (standalone/orphan issues)", 

661 ), 

662 ] = False, 

663 max_epic_verification_retries: Annotated[ 

664 int | None, 

665 typer.Option( 

666 "--max-epic-verification-retries", 

667 help="Maximum retries for epic verification loop (default: 3)", 

668 ), 

669 ] = None, 

670 deadlock_detection: Annotated[ 

671 bool, 

672 typer.Option( 

673 "--deadlock-detection/--no-deadlock-detection", 

674 help="Enable deadlock detection (default: on); --no-deadlock-detection disables it", 

675 ), 

676 ] = True, 

677) -> Never: 

678 """Run parallel issue processing.""" 

679 # Apply verbose setting 

680 set_verbose(verbose) 

681 

682 repo_path = repo_path.resolve() 

683 

684 # Validate and parse CLI arguments 

685 validated = _validate_run_args( 

686 only=only, 

687 disable_validations=disable_validations, 

688 coverage_threshold=coverage_threshold, 

689 epic=epic, 

690 orphans_only=orphans_only, 

691 epic_override=epic_override, 

692 repo_path=repo_path, 

693 ) 

694 only_ids = validated.only_ids 

695 disable_set = validated.disable_set 

696 epic_override_ids = validated.epic_override_ids 

697 

698 # Ensure user config directory exists 

699 USER_CONFIG_DIR.mkdir(parents=True, exist_ok=True) 

700 

701 # Handle dry-run mode: display task order and exit 

702 if dry_run: 

703 _handle_dry_run( 

704 repo_path=repo_path, 

705 epic=epic, 

706 only_ids=only_ids, 

707 wip=wip, 

708 focus=focus, 

709 orphans_only=orphans_only, 

710 ) 

711 

712 # Build and configure MalaConfig from environment 

713 config = _lazy("MalaConfig").from_env(validate=False) 

714 

715 # Apply CLI overrides to config 

716 override_result = _apply_config_overrides( 

717 config=config, 

718 review_timeout=review_timeout, 

719 cerberus_spawn_args=cerberus_spawn_args, 

720 cerberus_wait_args=cerberus_wait_args, 

721 cerberus_env=cerberus_env, 

722 max_epic_verification_retries=max_epic_verification_retries, 

723 braintrust_enabled=_braintrust_enabled, 

724 disable_review="review" in (disable_set or set()), 

725 deadlock_detection_enabled=deadlock_detection, 

726 ) 

727 if override_result.is_error: 

728 assert override_result.error is not None # for type narrowing 

729 log("✗", override_result.error, Colors.RED) 

730 raise typer.Exit(1) 

731 

732 # After error check, resolved and updated_config are guaranteed non-None 

733 assert override_result.resolved is not None 

734 assert override_result.updated_config is not None 

735 

736 # Build cli_args metadata for logging 

737 cli_args = _build_cli_args_metadata( 

738 disable_validations=disable_validations, 

739 coverage_threshold=coverage_threshold, 

740 wip=wip, 

741 max_issues=max_issues, 

742 max_gate_retries=max_gate_retries, 

743 max_review_retries=max_review_retries, 

744 epic_override=epic_override, 

745 resolved=override_result.resolved, 

746 braintrust_enabled=_braintrust_enabled, 

747 ) 

748 

749 # Build OrchestratorConfig and run 

750 orch_config = _lazy("OrchestratorConfig")( 

751 repo_path=repo_path, 

752 max_agents=max_agents, 

753 timeout_minutes=timeout, 

754 max_issues=max_issues, 

755 epic_id=epic, 

756 only_ids=only_ids, 

757 braintrust_enabled=override_result.resolved.braintrust_enabled, 

758 max_gate_retries=max_gate_retries, 

759 max_review_retries=max_review_retries, 

760 disable_validations=disable_set, 

761 coverage_threshold=coverage_threshold, 

762 prioritize_wip=wip, 

763 focus=focus, 

764 cli_args=cli_args, 

765 epic_override_ids=epic_override_ids, 

766 orphans_only=orphans_only, 

767 ) 

768 

769 orchestrator = _lazy("create_orchestrator")( 

770 orch_config, mala_config=override_result.updated_config 

771 ) 

772 

773 success_count, total = asyncio.run(orchestrator.run()) 

774 # Exit 0 if: no issues to process (no-op) OR at least one succeeded 

775 # Exit 1 only if: issues were processed but all failed 

776 raise typer.Exit(0 if success_count > 0 or total == 0 else 1) 

777 

778 

779@app.command("epic-verify") 

780def epic_verify( 

781 epic_id: Annotated[ 

782 str, 

783 typer.Argument( 

784 help="Epic ID to verify and optionally close", 

785 ), 

786 ], 

787 repo_path: Annotated[ 

788 Path, 

789 typer.Argument( 

790 help="Path to repository with beads issues", 

791 ), 

792 ] = Path("."), 

793 force: Annotated[ 

794 bool, 

795 typer.Option( 

796 "--force", 

797 help="Run verification even if epic is not eligible or already closed", 

798 ), 

799 ] = False, 

800 close: Annotated[ 

801 bool, 

802 typer.Option( 

803 "--close/--no-close", 

804 help="Close the epic after passing verification (default: close)", 

805 ), 

806 ] = True, 

807 human_override: Annotated[ 

808 bool, 

809 typer.Option( 

810 "--human-override", 

811 help="Bypass verification and close directly (requires --close)", 

812 ), 

813 ] = False, 

814 verbose: Annotated[ 

815 bool, 

816 typer.Option( 

817 "--verbose", 

818 "-v", 

819 help="Enable verbose output", 

820 ), 

821 ] = False, 

822) -> None: 

823 """Verify a single epic and optionally close it without running tasks.""" 

824 set_verbose(verbose) 

825 

826 if human_override and not close: 

827 log("✗", "--human-override requires --close", Colors.RED) 

828 raise typer.Exit(1) 

829 

830 repo_path = repo_path.resolve() 

831 if not repo_path.exists(): 

832 log("✗", f"Repository not found: {repo_path}", Colors.RED) 

833 raise typer.Exit(1) 

834 

835 USER_CONFIG_DIR.mkdir(parents=True, exist_ok=True) 

836 

837 config = _lazy("MalaConfig").from_env(validate=False) 

838 orch_config = _lazy("OrchestratorConfig")(repo_path=repo_path) 

839 orchestrator = _lazy("create_orchestrator")(orch_config, mala_config=config) 

840 verifier = getattr(orchestrator, "epic_verifier", None) 

841 

842 if verifier is None: 

843 log("✗", "Epic verifier unavailable for this configuration", Colors.RED) 

844 raise typer.Exit(1) 

845 

846 result = asyncio.run( 

847 verifier.verify_epic_with_options( 

848 epic_id, 

849 human_override=human_override, 

850 require_eligible=not force, 

851 close_epic=close, 

852 ) 

853 ) 

854 

855 if result.verified_count == 0: 

856 log("○", f"No verification run for epic {epic_id}", Colors.GRAY) 

857 if not force: 

858 log("◐", "Use --force to bypass eligibility checks", Colors.MUTED) 

859 raise typer.Exit(1) 

860 

861 if result.remediation_issues_created: 

862 log( 

863 "◐", 

864 f"Remediation issues: {', '.join(result.remediation_issues_created)}", 

865 Colors.MUTED, 

866 ) 

867 

868 if result.failed_count > 0: 

869 log("✗", f"Epic {epic_id} failed verification", Colors.RED) 

870 raise typer.Exit(1) 

871 

872 if close: 

873 log("✓", f"Epic {epic_id} verified and closed", Colors.GREEN) 

874 else: 

875 log("✓", f"Epic {epic_id} verified (no close)", Colors.GREEN) 

876 raise typer.Exit(0) 

877 

878 

879@app.command() 

880def clean( 

881 force: Annotated[ 

882 bool, 

883 typer.Option( 

884 "--force", 

885 "-f", 

886 help="Force cleanup even if a mala instance is running", 

887 ), 

888 ] = False, 

889) -> None: 

890 """Clean up lock files. 

891 

892 Removes orphaned lock files from the lock directory. Use this when 

893 a previous run left stale locks behind (e.g., after a crash). 

894 

895 If a mala instance is currently running, the command will exit early 

896 unless --force is specified. 

897 """ 

898 # Check for running instances 

899 running = _lazy("get_running_instances")() 

900 if running and not force: 

901 log( 

902 "⚠", 

903 f"A mala instance is running (PID {running[0].pid}). " 

904 "Use --force to clean anyway.", 

905 Colors.YELLOW, 

906 ) 

907 raise typer.Exit(1) 

908 

909 cleaned_locks = 0 

910 

911 lock_dir = _lazy("get_lock_dir")() 

912 if lock_dir.exists(): 

913 for lock in lock_dir.glob("*.lock"): 

914 lock.unlink() 

915 cleaned_locks += 1 

916 

917 if cleaned_locks: 

918 log("🧹", f"Removed {cleaned_locks} lock files", Colors.GREEN) 

919 else: 

920 log("○", "No lock files to clean", Colors.GRAY) 

921 

922 

923@app.command() 

924def status( 

925 all_instances: Annotated[ 

926 bool, 

927 typer.Option( 

928 "--all", 

929 help="Show all running instances across all directories", 

930 ), 

931 ] = False, 

932) -> None: 

933 """Show status of mala instance(s). 

934 

935 By default, shows status only for mala running in the current directory. 

936 Use --all to show all running instances across all directories. 

937 """ 

938 print() 

939 cwd = Path.cwd().resolve() 

940 

941 # Get running instances (filtered by cwd unless --all) 

942 if all_instances: 

943 instances = _lazy("get_running_instances")() 

944 else: 

945 instances = _lazy("get_running_instances_for_dir")(cwd) 

946 

947 if not instances: 

948 if all_instances: 

949 log("○", "No mala instances running", Colors.GRAY) 

950 else: 

951 log("○", "No mala instance running in this directory", Colors.GRAY) 

952 log("◐", f"cwd: {cwd}", Colors.MUTED) 

953 log("◐", "Use --all to show instances in other directories", Colors.MUTED) 

954 print() 

955 return 

956 

957 # Display running instances 

958 if all_instances: 

959 log("●", f"{len(instances)} running instance(s)", Colors.MAGENTA) 

960 print() 

961 

962 # Group by repo path for display with directory headings 

963 instances_by_repo: dict[Path, list[object]] = {} 

964 for instance in instances: 

965 repo = getattr(instance, "repo_path", Path(".")) 

966 if repo not in instances_by_repo: 

967 instances_by_repo[repo] = [] 

968 instances_by_repo[repo].append(instance) 

969 

970 for repo_path, repo_instances in instances_by_repo.items(): 

971 # Directory heading 

972 print(f"{Colors.CYAN}{repo_path}:{Colors.RESET}") 

973 for instance in repo_instances: 

974 _display_instance(instance, indent=True) 

975 print() 

976 else: 

977 # Single instance for this directory 

978 log("●", "mala running", Colors.MAGENTA) 

979 print() 

980 for instance in instances: 

981 _display_instance(instance) 

982 print() 

983 

984 # Show config directory 

985 config_env = USER_CONFIG_DIR / ".env" 

986 if config_env.exists(): 

987 log("◐", f"config: {config_env}", Colors.MUTED) 

988 else: 

989 log("○", f"config: {config_env} (not found)", Colors.MUTED) 

990 print() 

991 

992 # Check locks (show all locks grouped by agent) 

993 locks_by_agent = _lazy("get_all_locks")() 

994 if locks_by_agent: 

995 total_locks = sum(len(files) for files in locks_by_agent.values()) 

996 log( 

997 "⚠", 

998 f"{total_locks} active lock(s) held by {len(locks_by_agent)} agent(s)", 

999 Colors.YELLOW, 

1000 ) 

1001 for agent_id, files in sorted(locks_by_agent.items()): 

1002 print(f" {Colors.CYAN}{agent_id}{Colors.RESET}") 

1003 for filepath in sorted(files): 

1004 print(f" {Colors.MUTED}{filepath}{Colors.RESET}") 

1005 else: 

1006 log("○", "No active locks", Colors.GRAY) 

1007 

1008 # Check run metadata (completed runs) 

1009 if get_runs_dir().exists(): 

1010 # Use rglob to find run files in both legacy flat structure 

1011 # and new repo-segmented subdirectories 

1012 run_files = list(get_runs_dir().rglob("*.json")) 

1013 if run_files: 

1014 log( 

1015 "◐", 

1016 f"{len(run_files)} run metadata files in {get_runs_dir()}", 

1017 Colors.MUTED, 

1018 ) 

1019 recent = sorted(run_files, key=lambda p: p.stat().st_mtime, reverse=True)[ 

1020 :3 

1021 ] 

1022 for run_file in recent: 

1023 mtime = datetime.fromtimestamp(run_file.stat().st_mtime).strftime( 

1024 "%H:%M:%S" 

1025 ) 

1026 # Show relative path from runs_dir for clarity 

1027 rel_path = run_file.relative_to(get_runs_dir()) 

1028 print(f" {Colors.MUTED}{mtime} {rel_path}{Colors.RESET}") 

1029 print() 

1030 

1031 

1032def _display_instance(instance: object, indent: bool = False) -> None: 

1033 """Display a single running instance. 

1034 

1035 Args: 

1036 instance: RunningInstance object (typed as object to avoid import issues) 

1037 indent: If True, indent output (for grouped display under directory heading) 

1038 """ 

1039 # Access attributes directly (duck typing) 

1040 repo_path = getattr(instance, "repo_path", None) 

1041 started_at = getattr(instance, "started_at", None) 

1042 max_agents = getattr(instance, "max_agents", None) 

1043 pid = getattr(instance, "pid", None) 

1044 

1045 prefix = " " if indent else "" 

1046 

1047 # Only show repo path when not indented (when indented, directory is already shown as heading) 

1048 if not indent: 

1049 log("◐", f"repo: {repo_path}", Colors.CYAN) 

1050 

1051 if started_at: 

1052 # Calculate duration 

1053 from datetime import datetime, UTC 

1054 

1055 now = datetime.now(UTC) 

1056 duration = now - started_at 

1057 minutes = int(duration.total_seconds() // 60) 

1058 if minutes >= 60: 

1059 hours = minutes // 60 

1060 mins = minutes % 60 

1061 duration_str = f"{hours}h {mins}m" 

1062 elif minutes > 0: 

1063 duration_str = f"{minutes} minute(s)" 

1064 else: 

1065 seconds = int(duration.total_seconds()) 

1066 duration_str = f"{seconds} second(s)" 

1067 if indent: 

1068 print(f"{prefix}{Colors.MUTED}started: {duration_str} ago{Colors.RESET}") 

1069 else: 

1070 log("◐", f"started: {duration_str} ago", Colors.MUTED) 

1071 

1072 if max_agents is not None: 

1073 if indent: 

1074 print(f"{prefix}{Colors.MUTED}max-agents: {max_agents}{Colors.RESET}") 

1075 else: 

1076 log("◐", f"max-agents: {max_agents}", Colors.MUTED) 

1077 else: 

1078 if indent: 

1079 print(f"{prefix}{Colors.MUTED}max-agents: unlimited{Colors.RESET}") 

1080 else: 

1081 log("◐", "max-agents: unlimited", Colors.MUTED) 

1082 

1083 if pid: 

1084 if indent: 

1085 print(f"{prefix}{Colors.MUTED}pid: {pid}{Colors.RESET}") 

1086 else: 

1087 log("◐", f"pid: {pid}", Colors.MUTED) 

1088 

1089 

1090def __getattr__(name: str) -> Any: # noqa: ANN401 

1091 """Lazy-load SDK-dependent modules on first access.""" 

1092 if name not in _LAZY_NAMES: 

1093 raise AttributeError(f"module {__name__} has no attribute {name}") 

1094 

1095 if name in _lazy_modules: 

1096 return _lazy_modules[name] 

1097 

1098 if name == "BeadsClient": 

1099 from ..orchestration.cli_support import BeadsClient 

1100 

1101 _lazy_modules[name] = BeadsClient 

1102 elif name == "MalaConfig": 

1103 from src.infra.io.config import MalaConfig 

1104 

1105 _lazy_modules[name] = MalaConfig 

1106 elif name == "MalaOrchestrator": 

1107 from ..orchestration.orchestrator import MalaOrchestrator 

1108 

1109 _lazy_modules[name] = MalaOrchestrator 

1110 elif name == "OrchestratorConfig": 

1111 from ..orchestration.types import OrchestratorConfig 

1112 

1113 _lazy_modules[name] = OrchestratorConfig 

1114 elif name == "create_orchestrator": 

1115 from ..orchestration.factory import create_orchestrator 

1116 

1117 _lazy_modules[name] = create_orchestrator 

1118 elif name == "get_all_locks": 

1119 from ..infra.tools.locking import get_all_locks 

1120 

1121 _lazy_modules[name] = get_all_locks 

1122 elif name == "get_lock_dir": 

1123 from ..orchestration.cli_support import get_lock_dir 

1124 

1125 _lazy_modules[name] = get_lock_dir 

1126 elif name == "get_running_instances": 

1127 from ..orchestration.cli_support import get_running_instances 

1128 

1129 _lazy_modules[name] = get_running_instances 

1130 elif name == "get_running_instances_for_dir": 

1131 from ..orchestration.cli_support import get_running_instances_for_dir 

1132 

1133 _lazy_modules[name] = get_running_instances_for_dir 

1134 

1135 return _lazy_modules[name]