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

1#!/usr/bin/env python3

2# ruff: noqa: E402

3"""

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

5

6 Usage:

7 mala run [OPTIONS] [REPO_PATH]

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

9 mala clean

10 mala status

11"""

12

13 from __future__ import annotations

14

15 import sys

16 from dataclasses import dataclass, replace

17 from pathlib import Path

18 from typing import TYPE_CHECKING, Any

19

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

21

22 if 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

50 def _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

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

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

106 import asyncio

107 from datetime import datetime

108 from typing import Annotated, Never

109

110 import typer

111

112 from ..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

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

186 def _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)

225 VALID_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)

239 class 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)

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

265 def _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

310 def _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

368 def _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

405 def _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

510 app = typer.Typer(

511 name="mala",

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

513 add_completion=False,

514)

515

516

517@app.command()

518 def 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")

780 def 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()

880 def 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()

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

1032 def _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

1090 def __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]