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
« 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.
6Usage:
7 mala run [OPTIONS] [REPO_PATH]
8 mala epic-verify [OPTIONS] EPIC_ID [REPO_PATH]
9 mala clean
10 mala status
11"""
13from __future__ import annotations
15import sys
16from dataclasses import dataclass, replace
17from pathlib import Path
18from typing import TYPE_CHECKING, Any
20from ..orchestration.cli_support import USER_CONFIG_DIR, get_runs_dir, load_user_env
22if TYPE_CHECKING:
23 from src.infra.io.config import MalaConfig, ResolvedConfig
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
30# Lazy-loaded module cache for SDK-dependent imports
31# These are populated on first access via __getattr__
32_lazy_modules: dict[str, Any] = {}
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)
50def _lazy(name: str) -> Any: # noqa: ANN401
51 """Access a lazy-loaded module attribute.
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)
60def bootstrap() -> None:
61 """Initialize environment and Braintrust tracing.
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.
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
72 if _bootstrapped:
73 return
75 # Load environment variables early (needed for config)
76 load_user_env()
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
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
89 setup_claude_agent_sdk(project="mala")
90 _braintrust_enabled = True
91 except ImportError:
92 pass # braintrust not installed
94 _bootstrapped = True
97def is_braintrust_enabled() -> bool:
98 """Return whether Braintrust tracing was successfully enabled.
100 Must be called after bootstrap().
101 """
102 return _braintrust_enabled
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
110import typer
112from ..orchestration.cli_support import Colors, log, set_verbose
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.
119def display_dry_run_tasks(
120 issues: list[dict[str, object]],
121 focus: bool,
122) -> None:
123 """Display task order for dry-run preview.
125 Shows tasks in order with epic grouping when focus mode is enabled.
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
135 print()
136 log("◐", f"Dry run: {len(issues)} task(s) would be processed", Colors.CYAN)
137 print()
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}")
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)
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
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)
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")
196 # Format priority badge
197 prio_str = f"P{priority}" if priority is not None else "P?"
199 # Status indicator
200 status_indicator = ""
201 if status == "in_progress":
202 status_indicator = f" {Colors.YELLOW}(WIP){Colors.RESET}"
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}"
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 )
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)
238@dataclass(frozen=True)
239class ValidatedRunArgs:
240 """Validated and parsed CLI arguments for the run command."""
242 only_ids: set[str] | None
243 disable_set: set[str] | None
244 epic_override_ids: set[str] | None
247@dataclass(frozen=True)
248class ConfigOverrideResult:
249 """Result of applying CLI overrides to config.
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 """
255 resolved: ResolvedConfig | None = None
256 updated_config: MalaConfig | None = None
257 error: str | None = None
259 @property
260 def is_error(self) -> bool:
261 """Return True if this result represents an error."""
262 return self.error is not None
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.
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.
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 }
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.
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.
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
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 )
350 try:
351 resolved = build_resolved_config(config, cli_overrides)
352 except ValueError as exc:
353 return ConfigOverrideResult(error=str(exc))
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 )
365 return ConfigOverrideResult(resolved=resolved, updated_config=updated_config)
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.
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.
386 Raises:
387 typer.Exit: Always exits with code 0 after displaying.
388 """
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)
401 asyncio.run(_dry_run())
402 raise typer.Exit(0)
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.
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)
425 Returns:
426 ValidatedRunArgs with parsed values
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)
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)
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)
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)
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)
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)
503 return ValidatedRunArgs(
504 only_ids=only_ids,
505 disable_set=disable_set,
506 epic_override_ids=epic_override_ids,
507 )
510app = typer.Typer(
511 name="mala",
512 help="Parallel issue processing with Claude Agent SDK",
513 add_completion=False,
514)
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)
682 repo_path = repo_path.resolve()
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
698 # Ensure user config directory exists
699 USER_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
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 )
712 # Build and configure MalaConfig from environment
713 config = _lazy("MalaConfig").from_env(validate=False)
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)
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
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 )
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 )
769 orchestrator = _lazy("create_orchestrator")(
770 orch_config, mala_config=override_result.updated_config
771 )
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)
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)
826 if human_override and not close:
827 log("✗", "--human-override requires --close", Colors.RED)
828 raise typer.Exit(1)
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)
835 USER_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
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)
842 if verifier is None:
843 log("✗", "Epic verifier unavailable for this configuration", Colors.RED)
844 raise typer.Exit(1)
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 )
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)
861 if result.remediation_issues_created:
862 log(
863 "◐",
864 f"Remediation issues: {', '.join(result.remediation_issues_created)}",
865 Colors.MUTED,
866 )
868 if result.failed_count > 0:
869 log("✗", f"Epic {epic_id} failed verification", Colors.RED)
870 raise typer.Exit(1)
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)
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.
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).
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)
909 cleaned_locks = 0
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
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)
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).
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()
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)
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
957 # Display running instances
958 if all_instances:
959 log("●", f"{len(instances)} running instance(s)", Colors.MAGENTA)
960 print()
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)
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()
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()
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)
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()
1032def _display_instance(instance: object, indent: bool = False) -> None:
1033 """Display a single running instance.
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)
1045 prefix = " " if indent else ""
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)
1051 if started_at:
1052 # Calculate duration
1053 from datetime import datetime, UTC
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)
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)
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)
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}")
1095 if name in _lazy_modules:
1096 return _lazy_modules[name]
1098 if name == "BeadsClient":
1099 from ..orchestration.cli_support import BeadsClient
1101 _lazy_modules[name] = BeadsClient
1102 elif name == "MalaConfig":
1103 from src.infra.io.config import MalaConfig
1105 _lazy_modules[name] = MalaConfig
1106 elif name == "MalaOrchestrator":
1107 from ..orchestration.orchestrator import MalaOrchestrator
1109 _lazy_modules[name] = MalaOrchestrator
1110 elif name == "OrchestratorConfig":
1111 from ..orchestration.types import OrchestratorConfig
1113 _lazy_modules[name] = OrchestratorConfig
1114 elif name == "create_orchestrator":
1115 from ..orchestration.factory import create_orchestrator
1117 _lazy_modules[name] = create_orchestrator
1118 elif name == "get_all_locks":
1119 from ..infra.tools.locking import get_all_locks
1121 _lazy_modules[name] = get_all_locks
1122 elif name == "get_lock_dir":
1123 from ..orchestration.cli_support import get_lock_dir
1125 _lazy_modules[name] = get_lock_dir
1126 elif name == "get_running_instances":
1127 from ..orchestration.cli_support import get_running_instances
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
1133 _lazy_modules[name] = get_running_instances_for_dir
1135 return _lazy_modules[name]