diff --git a/.claude-plugin/commands/fireteam.md b/.claude-plugin/commands/fireteam.md deleted file mode 100644 index 074f69f..0000000 --- a/.claude-plugin/commands/fireteam.md +++ /dev/null @@ -1,65 +0,0 @@ -# /fireteam - -Multi-phase autonomous task execution with complexity-based routing. - -## Usage - -``` -/fireteam -``` - -## Configuration - -Set these environment variables to configure fireteam behavior: - -| Variable | Default | Description | -|----------|---------|-------------| -| `ANTHROPIC_API_KEY` | (required) | API key for Claude | -| `FIRETEAM_MAX_ITERATIONS` | (none/infinite) | Maximum loop iterations. Leave unset for infinite. | -| `FIRETEAM_LOG_LEVEL` | INFO | Logging verbosity (DEBUG, INFO, WARNING, ERROR) | - -## Examples - -``` -/fireteam Fix the authentication bug in auth.py -/fireteam Refactor the user module to use dependency injection -/fireteam Add comprehensive tests for the payment service -``` - -## How It Works - -1. **Complexity Estimation**: Analyzes your goal and estimates complexity (TRIVIAL, SIMPLE, MODERATE, COMPLEX) -2. **Mode Selection**: Routes to appropriate execution strategy: - - TRIVIAL/SIMPLE → SINGLE_TURN (one-shot execution) - - MODERATE → Execute → Review loop until >95% complete - - COMPLEX → Plan → Execute → 3 Parallel Reviews loop until 2/3 majority says >95% -3. **Loop Until Complete**: MODERATE and FULL modes loop continuously until the task is complete or max_iterations is reached (if set) - -## Configuration via Code - -When using fireteam as a library: - -```python -from fireteam import execute, ExecutionMode - -# Infinite iterations (default) -result = await execute( - project_dir="/path/to/project", - goal="Implement feature X", -) - -# Limited iterations -result = await execute( - project_dir="/path/to/project", - goal="Implement feature X", - max_iterations=10, # Stop after 10 iterations if not complete -) - -# Force a specific mode -result = await execute( - project_dir="/path/to/project", - goal="Implement feature X", - mode=ExecutionMode.FULL, - max_iterations=5, -) -``` diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 38afee2..a8031f6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,6 +7,34 @@ on: branches: [ main ] jobs: + lint: + name: Lint & Type Check + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install uv + run: curl -LsSf https://astral.sh/uv/install.sh | sh + + - name: Install dependencies + run: uv sync --extra dev + + - name: Run ruff + run: | + source .venv/bin/activate + ruff check src/ tests/ + + - name: Run mypy + run: | + source .venv/bin/activate + mypy src/ + fast-tests: name: Fast Tests (Unit + Lightweight) runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 8359771..7197906 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,6 @@ logs/ # Benchmark runs runs/ + +# Fireteam job output +jobs/ diff --git a/README.md b/README.md index 7869ee2..6be901f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Fireteam -Adaptive task execution using Claude Agent SDK with complexity-based routing and loop-until-complete behavior. +Adaptive task execution using Claude Code CLI with complexity-based routing and loop-until-complete behavior. ## Overview @@ -19,7 +19,7 @@ Fireteam estimates task complexity and routes to the appropriate execution strat uv add fireteam ``` -Requires Python 3.12+ and a valid `ANTHROPIC_API_KEY` environment variable. +Requires Python 3.12+ and Claude Code CLI installed. ## Usage @@ -88,7 +88,7 @@ print(f"Estimated complexity: {complexity}") ## Execution Modes ### SINGLE_TURN -For trivial and simple tasks. Single SDK call, no review loop. +For trivial and simple tasks. Single CLI call, no review loop. ### MODERATE For moderate tasks requiring validation: @@ -123,7 +123,6 @@ async def execute( goal: str, context: str = "", mode: ExecutionMode | None = None, # Auto-detect if None - run_tests: bool = True, max_iterations: int | None = None, # None = infinite (default) ) -> ExecutionResult ``` @@ -158,44 +157,32 @@ Environment variables: | Variable | Default | Description | |----------|---------|-------------| -| `ANTHROPIC_API_KEY` | (required) | API key for Claude | | `FIRETEAM_MAX_ITERATIONS` | (none) | Max loop iterations. Unset = infinite. | | `FIRETEAM_LOG_LEVEL` | INFO | Logging verbosity | -## Quality Hooks - -Fireteam includes SDK hooks for quality enforcement: - -- **QUALITY_HOOKS**: Run tests after edits, block user questions -- **AUTONOMOUS_HOOKS**: Block all user interaction -- **DEBUG_HOOKS**: Log all tool usage - -```python -from fireteam import execute - -result = await execute( - project_dir="/path/to/project", - goal="Add feature", - run_tests=True, # Enables QUALITY_HOOKS (default) -) -``` - ## Project Structure ``` fireteam/ ├── .claude-plugin/ -│ ├── plugin.json # Claude Code plugin manifest -│ └── commands/ -│ └── fireteam.md # /fireteam command definition +│ └── plugin.json # Claude Code plugin manifest +├── commands/ +│ └── fireteam.md # /fireteam command definition +├── hooks/ +│ └── hooks.json # Claude Code hooks configuration ├── src/ │ ├── __init__.py # Public API exports │ ├── api.py # Core execute() function │ ├── models.py # Data models (ExecutionMode, ExecutionResult, etc.) │ ├── loops.py # Loop implementations (moderate_loop, full_loop) +│ ├── claude_cli.py # Claude Code CLI wrapper │ ├── complexity.py # Complexity estimation +│ ├── circuit_breaker.py # Stuck loop detection +│ ├── rate_limiter.py # API call budget management +│ ├── runner.py # tmux-based autonomous execution +│ ├── prompt.py # PROMPT.md parsing with file includes │ ├── config.py # Configuration -│ ├── hooks.py # SDK hooks for quality +│ ├── claude_hooks/ # Claude Code hook handlers │ └── prompts/ │ ├── __init__.py # Prompt loader │ ├── builder.py # Prompt building with feedback injection diff --git a/docs/ralph-comparison-audit.md b/docs/ralph-comparison-audit.md deleted file mode 100644 index 1208caf..0000000 --- a/docs/ralph-comparison-audit.md +++ /dev/null @@ -1,414 +0,0 @@ -# Ralph vs Fireteam: Comprehensive Audit - -**Date:** January 2026 -**Purpose:** Compare Ralph (https://github.com/frankbria/ralph-claude-code) with Fireteam to identify strengths, weaknesses, and opportunities for improvement. - ---- - -## Executive Summary - -Both Ralph and Fireteam solve the same core problem: **autonomous, iterative AI-assisted development with quality gates**. However, they take fundamentally different approaches: - -| Aspect | Ralph | Fireteam | -|--------|-------|----------| -| **Language** | Bash/Shell scripts | Python (claude-agent-sdk) | -| **Target** | Claude Code CLI wrapper | Library + Claude Code plugin | -| **Authentication** | Uses Claude Code session/credits | Requires separate API key | -| **Complexity Handling** | Uniform (all tasks same loop) | Adaptive (routes by complexity) | -| **Exit Detection** | Dual-gate (heuristics + explicit signal) | Reviewer consensus (1 or 3 reviewers) | -| **Safety Mechanisms** | Circuit breaker, rate limiting | Max iterations, test hooks | -| **Architecture** | Procedural scripts | Async Python with SDK | - ---- - -## Where Fireteam Excels - -### 1. **Adaptive Complexity Routing** ✓ -Fireteam's biggest differentiator. It estimates task complexity and routes to appropriate execution strategies: - -- **TRIVIAL/SIMPLE** → Single-turn execution (no overhead) -- **MODERATE** → Execute-review loop (1 reviewer) -- **COMPLEX** → Plan + execute + parallel reviews (3 reviewers, majority consensus) - -Ralph treats all tasks identically, running the same loop regardless of whether you're fixing a typo or building a feature. This wastes API calls on simple tasks and may under-validate complex ones. - -**Verdict: Fireteam significantly better** - -### 2. **SDK-Native Integration** ✓ -Fireteam uses the `claude-agent-sdk` directly, providing: -- Type-safe Python API -- Proper async/await patterns -- Direct tool control per phase -- Programmable hooks system -- Easy embedding in other Python projects - -Ralph shells out to `claude` CLI, parsing JSON output. This is more fragile and harder to extend. - -**Verdict: Fireteam significantly better** - -### 3. **Parallel Reviewer Consensus** ✓ -For complex tasks, Fireteam runs 3 reviewers in parallel and requires 2/3 agreement. This: -- Reduces false positives from a single biased review -- Provides diverse perspectives on completion -- Catches issues one reviewer might miss - -Ralph uses a single response analyzer with heuristics. - -**Verdict: Fireteam better** - -### 4. **Planning Phase for Complex Tasks** ✓ -Fireteam's FULL mode creates an explicit plan before execution: -- Read-only exploration phase -- Detailed step-by-step plan -- Plan injected into executor context - -Ralph jumps straight into execution, relying on PROMPT.md for guidance. - -**Verdict: Fireteam better** - -### 5. **Quality Hooks with Immediate Feedback** ✓ -Fireteam's `PostToolUse` hook runs tests after every Edit/Write: -- Immediate test failure feedback -- Auto-detects test framework (pytest, npm, cargo, etc.) -- Claude sees failures and can fix in same iteration - -Ralph runs tests but doesn't inject failures back into Claude's context mid-loop. - -**Verdict: Fireteam better** - -### 6. **Library-First Design** ✓ -Fireteam is designed as a library with a clean public API: -```python -from fireteam import execute -result = await execute(project_dir, goal) -``` - -This enables: -- Embedding in CI/CD pipelines -- Building custom workflows -- Programmatic control and monitoring - -Ralph is primarily a CLI tool, harder to integrate. - -**Verdict: Fireteam better** - ---- - -## Where Ralph Excels - -### 1. **Claude Code Session Piggybacking** ★★★★ -Ralph wraps the `claude` CLI, which means it automatically uses: -- The user's existing Claude Code session -- The user's existing credits/billing -- No separate API key required -- Single source of truth for usage and billing - -Fireteam uses `claude-agent-sdk` which makes **direct API calls**, requiring: -- A separate `ANTHROPIC_API_KEY` environment variable -- Separate billing to that API account -- Users need both Claude Code credits AND API credits - -**Current Fireteam architecture:** -``` -Claude Code session (user's credits) - ↓ (invokes hook) -Fireteam plugin (user_prompt_submit.py) - ↓ (calls execute()) -claude-agent-sdk → Direct Anthropic API (separate API key/billing) -``` - -**This is fundamentally wrong.** Fireteam should piggyback on Claude Code's session so users don't need to manage two separate billing sources. - -**This is a critical architectural gap in Fireteam.** - -### 2. **Circuit Breaker Pattern** ★★★ -Ralph's circuit breaker is sophisticated: -- Tracks files changed per loop -- Detects repeated identical errors -- Monitors output length decline -- Three states: CLOSED → HALF_OPEN → OPEN -- Thresholds: 3 loops no progress, 5 repeated errors - -Fireteam only has `max_iterations` (optional) - it can loop infinitely if reviewer never says "complete". No detection of stuck patterns. - -**This is a significant gap in Fireteam.** - -### 3. **Rate Limiting** ★★★ -Ralph implements per-hour API call quotas: -- Configurable calls per hour limit -- Automatic pause when quota exhausted -- Wait-for-reset functionality -- 5-hour API limit detection with graceful handling - -Fireteam has no rate limiting - it will happily burn through API quota without bounds. - -**This is a significant gap in Fireteam.** - -### 4. **Session Continuity** ★★ -Ralph preserves context across iterations: -- 24-hour session expiration -- Session state tracking -- Resume capability after interruption -- Automatic cleanup of stale sessions - -Fireteam starts fresh each `execute()` call - no cross-session memory. - -**Moderate gap - depends on use case.** - -### 5. **Dual-Gate Exit Detection** ★★ -Ralph requires BOTH conditions: -1. Natural language completion indicators (heuristics) -2. Explicit `EXIT_SIGNAL: true` from Claude - -This respects Claude's judgment over automation assumptions. If Claude says "I'm still working on this" despite heuristics suggesting completion, the loop continues. - -Fireteam relies solely on reviewer completion percentage (≥95%). The executor's opinion isn't considered. - -**Moderate improvement opportunity.** - -### 6. **Live Monitoring Dashboard** ★★ -Ralph provides tmux-based real-time monitoring: -- Loop status visualization -- Progress tracking -- Execution logs -- Interactive observation - -Fireteam only logs to console - no dashboard or monitoring UI. - -**Nice-to-have gap.** - -### 7. **PRD Import Functionality** ★ -Ralph can convert documents (MD, JSON, PDF, Word) into structured projects: -- Analyzes existing documentation -- Creates PROMPT.md automatically -- Integrates with Claude for analysis - -Fireteam requires manual goal/context specification. - -**Nice-to-have feature.** - -### 8. **Explicit Error Classification** ★ -Ralph's response analyzer has two-stage error filtering: -- Distinguishes JSON field "error" from actual errors -- Context-aware pattern matching -- Prevents false positives - -Fireteam doesn't explicitly track error patterns. - -**Minor improvement opportunity.** - ---- - -## Ideas to Pull into Fireteam - -### Priority 0: Foundational (Architecture Change Required) - -#### 0.1 Use Claude Code Session Instead of Direct API -**What:** Refactor to use Claude Code CLI instead of claude-agent-sdk direct API calls -**Why:** Users should not need a separate API key; billing should be unified -**Impact:** This is an architectural change that affects the core execution model - -**Current flow (wrong):** -``` -Claude Code → Fireteam hook → claude-agent-sdk → Anthropic API (separate billing) -``` - -**Target flow (correct):** -``` -Claude Code → Fireteam hook → claude CLI subprocess → Claude Code session (same billing) -``` - -**Implementation approaches:** - -1. **Subprocess approach (like Ralph):** - - Shell out to `claude` CLI with structured prompts - - Parse JSON output - - Simpler but loses type safety - -2. **SDK with session passthrough:** - - Investigate if claude-agent-sdk can accept session tokens - - Would preserve type safety if possible - - Needs SDK documentation review - -3. **Hybrid approach:** - - Use CLI for actual execution (billing goes to user's Claude Code) - - Use SDK for local-only operations (complexity estimation with caching) - -**Recommendation:** Start with subprocess approach for MVP, then optimize. - -### Priority 1: Critical (Safety & Resource Management) - -#### 1.1 Circuit Breaker Pattern -**What:** Implement stuck-loop detection -**Why:** Prevent infinite loops that waste API credits -**How:** -```python -@dataclass -class CircuitBreaker: - state: Literal["closed", "half_open", "open"] = "closed" - no_progress_count: int = 0 - repeated_error_count: int = 0 - last_error_hash: str = "" - - def record_iteration(self, files_changed: int, error: str | None): - if files_changed == 0: - self.no_progress_count += 1 - else: - self.no_progress_count = 0 - - if error and hash(error) == self.last_error_hash: - self.repeated_error_count += 1 - else: - self.repeated_error_count = 0 - self.last_error_hash = hash(error) if error else "" - - self._update_state() - - def should_halt(self) -> bool: - return self.state == "open" -``` - -**Thresholds to consider:** -- 3 consecutive loops with no file changes -- 5 repeated identical errors -- Output length decline >70% - -#### 1.2 Rate Limiting -**What:** API call budget management -**Why:** Prevent runaway costs -**How:** -```python -@dataclass -class RateLimiter: - calls_per_hour: int = 100 - calls_this_hour: int = 0 - hour_started: datetime = field(default_factory=datetime.now) - - async def acquire(self): - if self._is_new_hour(): - self._reset() - if self.calls_this_hour >= self.calls_per_hour: - await self._wait_for_reset() - self.calls_this_hour += 1 -``` - -### Priority 2: High (Quality Improvement) - -#### 2.1 Dual-Gate Exit with Executor Opinion -**What:** Let the executor signal if it believes work is incomplete -**Why:** Respects Claude's judgment, prevents premature termination -**How:** After execution, check for explicit "WORK_COMPLETE: false" or similar signal. If executor says incomplete, continue regardless of reviewer. - -#### 2.2 Progress Tracking Metrics -**What:** Track files changed, errors encountered, output length per iteration -**Why:** Better visibility into execution health -**How:** Add `IterationMetrics` dataclass collected each loop. - -### Priority 3: Medium (UX Improvement) - -#### 3.1 Session Continuity -**What:** Persist state across execute() calls -**Why:** Allow resumption after interruption -**How:** Optional session file that stores plan, iteration history, accumulated feedback. - -#### 3.2 Live Progress Dashboard -**What:** Real-time execution monitoring -**Why:** Visibility into long-running tasks -**How:** Optional WebSocket or file-based progress updates that can be consumed by a UI. - -### Priority 4: Low (Nice-to-Have) - -#### 4.1 Document Import -**What:** Convert PRDs/specs to goals+context -**Why:** Smoother onboarding -**How:** Pre-processing step that uses Claude to extract actionable goals. - -#### 4.2 Error Pattern Classification -**What:** Categorize and track error patterns -**Why:** Better stuck-loop detection -**How:** Error fingerprinting and classification. - ---- - -## Comparative Analysis Matrix - -| Feature | Ralph | Fireteam | Winner | Gap Severity | -|---------|-------|----------|--------|--------------| -| Complexity-based routing | No | Yes (4 levels) | **Fireteam** | N/A | -| Parallel reviews | No | Yes (3 reviewers) | **Fireteam** | N/A | -| Planning phase | No | Yes (FULL mode) | **Fireteam** | N/A | -| Test feedback injection | Partial | Yes (hooks) | **Fireteam** | N/A | -| Library-first design | No | Yes | **Fireteam** | N/A | -| **Uses Claude Code session** | Yes | No (separate API) | **Ralph** | **Critical** | -| Circuit breaker | Yes (sophisticated) | No | **Ralph** | **Critical** | -| Rate limiting | Yes | No | **Ralph** | **Critical** | -| Session continuity | Yes (24h) | No | **Ralph** | Medium | -| Dual-gate exit | Yes | No | **Ralph** | Medium | -| Live monitoring | Yes (tmux) | No | **Ralph** | Low | -| PRD import | Yes | No | **Ralph** | Low | - ---- - -## Implementation Status - -**All gaps have been closed.** The following features have been implemented: - -| Gap | Status | Implementation | -|-----|--------|----------------| -| Claude Code session | ✅ DONE | `claude_cli.py` - wraps `claude` CLI | -| Circuit breaker | ✅ DONE | `circuit_breaker.py` - warns on stuck loops | -| Rate limiting | ✅ DONE | `rate_limiter.py` - 100 calls/hour default | -| Dual-gate exit | ✅ DONE | `WORK_COMPLETE:` signal in loops.py | -| Session continuity | ✅ DONE | Uses Claude Code's `--resume` | -| Live monitoring | ✅ DONE | `runner.py` - tmux-based execution | - -### New Architecture - -``` -Before (SDK direct): -Claude Code → Fireteam → claude-agent-sdk → Anthropic API (separate billing) - -After (CLI wrapper): -Claude Code → Fireteam → claude CLI subprocess → Claude Code session (same billing) -``` - -### CLI Commands - -```bash -# Start autonomous session in tmux -fireteam start --project-dir /path --goal "Complete the feature" - -# List running sessions -fireteam list - -# Attach to session for live monitoring -fireteam attach fireteam-myproject - -# View recent logs -fireteam logs fireteam-myproject -n 100 - -# Terminate session -fireteam kill fireteam-myproject -``` - ---- - -## Conclusion - -Fireteam now has feature parity with Ralph's safety mechanisms while maintaining its architectural advantages: - -**Fireteam's advantages over Ralph:** -- Adaptive complexity routing (trivial → complex) -- Parallel reviewer consensus (3 reviewers, 2/3 majority) -- Clean Python library API for embedding -- Type-safe, async-native codebase - -**Ralph's features now in Fireteam:** -- Claude Code session piggybacking (unified billing) -- Circuit breaker (stuck loop detection) -- Rate limiting (API budget management) -- Tmux-based autonomous execution -- Session continuity -- Dual-gate exit detection - -**Result:** Fireteam's intelligent orchestration + Ralph's "just works with Claude Code" integration model. diff --git a/pyproject.toml b/pyproject.toml index b52d315..05313ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,8 @@ dependencies = [ dev = [ "pytest>=7.0.0", "pytest-asyncio>=0.21.0", + "ruff>=0.4.0", + "mypy>=1.10.0", ] [project.scripts] @@ -39,3 +41,38 @@ markers = [ "integration: Integration tests (require API key, use --run-integration)", "slow: Slow running tests", ] + +[tool.ruff] +target-version = "py312" +line-length = 100 +src = ["src", "tests"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade +] +ignore = [ + "E501", # line too long (handled by formatter) +] + +[tool.ruff.lint.isort] +known-first-party = ["fireteam"] + +[tool.mypy] +python_version = "3.12" +files = ["src"] +strict = true +warn_return_any = true +warn_unused_ignores = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_configs = true diff --git a/src/__init__.py b/src/__init__.py index a94ea57..f3b163b 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -29,13 +29,13 @@ """ from .api import execute -from .models import ExecutionMode, ExecutionResult -from .complexity import ComplexityLevel, estimate_complexity -from .claude_cli import CLISession, CLIResult, ClaudeCLI from .circuit_breaker import CircuitBreaker, CircuitState, IterationMetrics, create_circuit_breaker -from .rate_limiter import RateLimiter, RateLimitExceeded, get_rate_limiter, reset_rate_limiter -from .runner import start_session, attach_session, kill_session, list_sessions, SessionInfo +from .claude_cli import ClaudeCLI, CLIResult, CLISession +from .complexity import ComplexityLevel, estimate_complexity +from .models import ExecutionMode, ExecutionResult from .prompt import Prompt, resolve_prompt +from .rate_limiter import RateLimiter, RateLimitExceeded, get_rate_limiter, reset_rate_limiter +from .runner import SessionInfo, attach_session, kill_session, list_sessions, start_session __all__ = [ # Main API diff --git a/src/api.py b/src/api.py index 1ab67e3..f8e5845 100644 --- a/src/api.py +++ b/src/api.py @@ -18,13 +18,12 @@ from pathlib import Path from . import config -from .claude_cli import CLISession from .circuit_breaker import CircuitBreaker, create_circuit_breaker +from .claude_cli import CLISession from .complexity import ComplexityLevel, estimate_complexity -from .loops import single_turn, moderate_loop, full_loop +from .loops import full_loop, moderate_loop, single_turn from .models import ExecutionMode, ExecutionResult, LoopConfig -from .rate_limiter import RateLimiter, get_rate_limiter - +from .rate_limiter import get_rate_limiter # Map complexity levels to execution modes # SIMPLE is now treated as SINGLE_TURN (no separate mode) diff --git a/src/circuit_breaker.py b/src/circuit_breaker.py index 4059c1f..fe48447 100644 --- a/src/circuit_breaker.py +++ b/src/circuit_breaker.py @@ -10,9 +10,9 @@ import hashlib import logging +from collections.abc import Callable from dataclasses import dataclass, field from enum import Enum -from typing import Callable class CircuitState(Enum): @@ -69,7 +69,7 @@ class CircuitBreaker: # Callbacks on_warning: Callable[[str], None] | None = None - def __post_init__(self): + def __post_init__(self) -> None: self.log = logging.getLogger("fireteam.circuit_breaker") def record_iteration(self, metrics: IterationMetrics) -> None: @@ -130,7 +130,6 @@ def _update_state(self, metrics: IterationMetrics) -> None: # Update state if warnings: - old_state = self.state self.state = CircuitState.OPEN self._issue_warnings(warnings, metrics) elif self.state == CircuitState.OPEN: @@ -159,7 +158,7 @@ def is_open(self) -> bool: """Check if circuit is open (problem detected).""" return self.state == CircuitState.OPEN - def get_status(self) -> dict: + def get_status(self) -> dict[str, str | int | bool]: """Get current circuit breaker status.""" return { "state": self.state.value, diff --git a/src/claude_cli.py b/src/claude_cli.py index 61c12be..b675a23 100644 --- a/src/claude_cli.py +++ b/src/claude_cli.py @@ -9,15 +9,13 @@ import asyncio import json import logging -import subprocess import uuid from dataclasses import dataclass, field from pathlib import Path -from typing import AsyncIterator +from typing import Any from .models import PhaseType - # Tool permission sets per phase PHASE_TOOLS = { PhaseType.PLAN: ["Glob", "Grep", "Read"], @@ -41,7 +39,7 @@ class CLIResult: error: str | None = None cost_usd: float = 0.0 duration_ms: int = 0 - raw_json: dict = field(default_factory=dict) + raw_json: dict[str, Any] = field(default_factory=dict) @dataclass @@ -50,7 +48,7 @@ class CLISession: session_id: str = field(default_factory=lambda: str(uuid.uuid4())) is_first_call: bool = True - def mark_used(self): + def mark_used(self) -> None: """Mark that this session has been used.""" self.is_first_call = False @@ -103,7 +101,7 @@ async def query( self.session.mark_used() return result - except asyncio.TimeoutError: + except TimeoutError: self.log.error(f"CLI timeout after {timeout_seconds}s") return CLIResult( success=False, diff --git a/src/claude_hooks/user_prompt_submit.py b/src/claude_hooks/user_prompt_submit.py index 98a3822..6bf27fd 100644 --- a/src/claude_hooks/user_prompt_submit.py +++ b/src/claude_hooks/user_prompt_submit.py @@ -6,10 +6,11 @@ 2. Invokes fireteam.execute() with the task 3. Returns the result to Claude Code """ -import sys -import json import asyncio +import json +import sys from pathlib import Path +from typing import Any def is_fireteam_enabled() -> bool: @@ -17,16 +18,17 @@ def is_fireteam_enabled() -> bool: state_file = Path.home() / ".claude" / "fireteam_state.json" if state_file.exists(): try: - state = json.loads(state_file.read_text()) - return state.get("enabled", False) - except (json.JSONDecodeError, IOError): + state: dict[str, Any] = json.loads(state_file.read_text()) + enabled = state.get("enabled", False) + return bool(enabled) + except (OSError, json.JSONDecodeError): return False return False -async def main(): +async def main() -> None: """Main hook entry point.""" - input_data = json.loads(sys.stdin.read()) + input_data: dict[str, Any] = json.loads(sys.stdin.read()) if not is_fireteam_enabled(): # Fireteam mode is OFF - pass through normally @@ -34,11 +36,11 @@ async def main(): return # Fireteam mode is ON - inject orchestration context - user_prompt = input_data.get("prompt", "") - cwd = input_data.get("cwd", ".") + user_prompt: str = input_data.get("prompt", "") + cwd: str = input_data.get("cwd", ".") - # Import and run fireteam - from fireteam import execute + # Import and run fireteam (use relative import) + from ..api import execute result = await execute( project_dir=cwd, @@ -46,7 +48,7 @@ async def main(): ) # Return result to Claude Code - output = { + output: dict[str, Any] = { "hookSpecificOutput": { "additionalContext": f"Fireteam completed with {result.completion_percentage}% completion.\n\nResult:\n{result.output}", } diff --git a/src/complexity.py b/src/complexity.py index 21903f4..c80d683 100644 --- a/src/complexity.py +++ b/src/complexity.py @@ -13,10 +13,10 @@ from enum import Enum from pathlib import Path -from .claude_cli import run_cli_query, CLISession +from . import config +from .claude_cli import CLISession, run_cli_query from .models import PhaseType from .prompts import COMPLEXITY_PROMPT -from . import config class ComplexityLevel(Enum): diff --git a/src/config.py b/src/config.py index bca70b7..9159f1b 100644 --- a/src/config.py +++ b/src/config.py @@ -6,6 +6,7 @@ import os from pathlib import Path + from dotenv import load_dotenv # Load environment variables from .env file diff --git a/src/hooks.py b/src/hooks.py deleted file mode 100644 index b785baf..0000000 --- a/src/hooks.py +++ /dev/null @@ -1,299 +0,0 @@ -""" -SDK Hooks for automatic quality enforcement. - -Provides PostToolUse hooks that run tests after code changes, -giving Claude immediate feedback when tests fail. -""" - -import asyncio -import logging -import subprocess -from pathlib import Path -from typing import Any - -from claude_agent_sdk import HookMatcher - - -# Default test commands to try (in order of preference) -DEFAULT_TEST_COMMANDS = [ - ["pytest", "-x", "--tb=short"], # Python - ["npm", "test"], # Node.js - ["cargo", "test"], # Rust - ["go", "test", "./..."], # Go - ["make", "test"], # Makefile-based -] - - -def detect_test_command(project_dir: Path) -> list[str] | None: - """ - Detect the appropriate test command for a project. - Returns None if no test framework is detected. - """ - # Check for Python (pytest/pyproject.toml) - if (project_dir / "pytest.ini").exists() or \ - (project_dir / "pyproject.toml").exists() or \ - (project_dir / "setup.py").exists() or \ - (project_dir / "tests").is_dir(): - return ["pytest", "-x", "--tb=short"] - - # Check for Node.js - if (project_dir / "package.json").exists(): - return ["npm", "test"] - - # Check for Rust - if (project_dir / "Cargo.toml").exists(): - return ["cargo", "test"] - - # Check for Go - if (project_dir / "go.mod").exists(): - return ["go", "test", "./..."] - - # Check for Makefile with test target - makefile = project_dir / "Makefile" - if makefile.exists(): - content = makefile.read_text() - if "test:" in content: - return ["make", "test"] - - return None - - -def run_tests_sync(project_dir: Path, test_command: list[str], timeout: int = 120) -> tuple[bool, str]: - """ - Run tests synchronously and return (success, output). - """ - try: - result = subprocess.run( - test_command, - cwd=project_dir, - capture_output=True, - text=True, - timeout=timeout, - ) - - output = result.stdout + result.stderr - success = result.returncode == 0 - - return success, output - - except subprocess.TimeoutExpired: - return False, f"Tests timed out after {timeout}s" - except FileNotFoundError: - return False, f"Test command not found: {test_command[0]}" - except Exception as e: - return False, f"Error running tests: {e}" - - -async def run_tests_after_edit(input_data: dict, tool_use_id: str | None, context: Any) -> dict: - """ - PostToolUse hook: Run tests after any Edit/Write operation. - - Provides feedback to Claude if tests fail, allowing immediate correction. - """ - # Only process PostToolUse events - if input_data.get("hook_event_name") != "PostToolUse": - return {} - - tool_name = input_data.get("tool_name", "") - - # Only run for file modification tools - if tool_name not in ("Edit", "Write"): - return {} - - # Get project directory from context - cwd = input_data.get("cwd", "") - if not cwd: - return {} - - project_dir = Path(cwd) - - # Detect test command - test_command = detect_test_command(project_dir) - if not test_command: - # No test framework detected - skip - return {} - - # Get the file that was modified - tool_input = input_data.get("tool_input", {}) - modified_file = tool_input.get("file_path", "unknown") - - # Run tests - success, output = await asyncio.to_thread( - run_tests_sync, project_dir, test_command - ) - - if success: - # Tests passed - no feedback needed - return {} - - # Tests failed - provide feedback to Claude - # Truncate output if too long - max_output_len = 2000 - if len(output) > max_output_len: - output = output[:max_output_len] + "\n... (output truncated)" - - feedback = f"""Tests failed after editing {modified_file}. - -Command: {' '.join(test_command)} - -Output: -{output} - -Please fix the failing tests before continuing.""" - - return { - "hookSpecificOutput": { - "hookEventName": input_data["hook_event_name"], - "additionalContext": feedback, - } - } - - -async def log_tool_usage(input_data: dict, tool_use_id: str | None, context: Any) -> dict: - """ - PostToolUse hook: Log all tool usage for debugging/auditing. - """ - if input_data.get("hook_event_name") != "PostToolUse": - return {} - - tool_name = input_data.get("tool_name", "") - tool_input = input_data.get("tool_input", {}) - - logger = logging.getLogger("fireteam.hooks") - logger.debug(f"Tool used: {tool_name}, input: {tool_input}") - - return {} - - -async def block_user_questions(input_data: dict, tool_use_id: str | None, context: Any) -> dict: - """ - PreToolUse hook: Block AskUserQuestion in autonomous mode. - - Fireteam runs autonomously without user interaction. If Claude tries to - ask a clarifying question, we deny it and tell Claude to proceed with - its best judgment. - - This is a belt+suspenders approach - AskUserQuestion should also not be - in allowed_tools, but this hook catches it if it somehow gets through. - """ - if input_data.get("hook_event_name") != "PreToolUse": - return {} - - tool_name = input_data.get("tool_name", "") - - if tool_name == "AskUserQuestion": - return { - "hookSpecificOutput": { - "hookEventName": input_data["hook_event_name"], - "permissionDecision": "deny", - "permissionDecisionReason": ( - "This is an autonomous execution - no user is available to answer questions. " - "Proceed with your best judgment based on the available context. " - "Make reasonable assumptions and document them in your work." - ), - } - } - - return {} - - -def create_test_hooks( - test_command: list[str] | None = None, - test_timeout: int = 120, -) -> dict[str, list]: - """ - Create hook configuration for automatic test running. - - Args: - test_command: Explicit test command to use (auto-detected if None) - test_timeout: Timeout in seconds for test execution - - Returns: - Hook configuration dict to pass to ClaudeAgentOptions - """ - - async def test_hook(input_data: dict, tool_use_id: str | None, context: Any) -> dict: - """Custom test hook with configured command and timeout.""" - if input_data.get("hook_event_name") != "PostToolUse": - return {} - - tool_name = input_data.get("tool_name", "") - if tool_name not in ("Edit", "Write"): - return {} - - cwd = input_data.get("cwd", "") - if not cwd: - return {} - - project_dir = Path(cwd) - - # Use configured command or auto-detect - cmd = test_command or detect_test_command(project_dir) - if not cmd: - return {} - - tool_input = input_data.get("tool_input", {}) - modified_file = tool_input.get("file_path", "unknown") - - success, output = await asyncio.to_thread( - run_tests_sync, project_dir, cmd, test_timeout - ) - - if success: - return {} - - max_output_len = 2000 - if len(output) > max_output_len: - output = output[:max_output_len] + "\n... (output truncated)" - - feedback = f"""Tests failed after editing {modified_file}. - -Command: {' '.join(cmd)} - -Output: -{output} - -Please fix the failing tests before continuing.""" - - return { - "hookSpecificOutput": { - "hookEventName": input_data["hook_event_name"], - "additionalContext": feedback, - } - } - - return { - "PreToolUse": [ - # Block AskUserQuestion in autonomous mode - HookMatcher(matcher="AskUserQuestion", hooks=[block_user_questions]) - ], - "PostToolUse": [ - HookMatcher(matcher="Edit|Write", hooks=[test_hook]) - ] - } - - -# Pre-configured hook sets for common use cases -QUALITY_HOOKS = { - "PreToolUse": [ - # Block AskUserQuestion in autonomous mode (belt+suspenders) - HookMatcher(matcher="AskUserQuestion", hooks=[block_user_questions]) - ], - "PostToolUse": [ - # Run tests after code changes - HookMatcher(matcher="Edit|Write", hooks=[run_tests_after_edit]) - ] -} - -AUTONOMOUS_HOOKS = { - "PreToolUse": [ - # Block AskUserQuestion in autonomous mode - HookMatcher(matcher="AskUserQuestion", hooks=[block_user_questions]) - ], -} - -DEBUG_HOOKS = { - "PostToolUse": [ - HookMatcher(hooks=[log_tool_usage]) - ] -} diff --git a/src/loops.py b/src/loops.py index 87fa7a2..213da8c 100644 --- a/src/loops.py +++ b/src/loops.py @@ -15,9 +15,8 @@ from pathlib import Path from . import config -from .claude_cli import ClaudeCLI, CLISession, CLIResult, run_cli_query from .circuit_breaker import CircuitBreaker, IterationMetrics, create_circuit_breaker -from .rate_limiter import RateLimiter, get_rate_limiter +from .claude_cli import CLIResult, CLISession, run_cli_query from .models import ( ExecutionMode, ExecutionResult, @@ -27,6 +26,7 @@ ReviewResult, ) from .prompts.builder import build_prompt +from .rate_limiter import RateLimiter, get_rate_limiter async def single_turn( @@ -180,7 +180,7 @@ async def run_parallel_reviews( processed: list[ReviewResult] = [] for i, result in enumerate(results): - if isinstance(result, Exception): + if isinstance(result, BaseException): log.warning(f"Reviewer {i + 1} failed: {result}") processed.append( ReviewResult( @@ -191,6 +191,7 @@ async def run_parallel_reviews( ) ) else: + # result is ReviewResult after the exception check processed.append(result) return processed diff --git a/src/prompt.py b/src/prompt.py index 079b9b8..0ac62eb 100644 --- a/src/prompt.py +++ b/src/prompt.py @@ -22,13 +22,11 @@ - Include validation """ +import glob as globlib import os import re -import glob as globlib from dataclasses import dataclass, field from pathlib import Path -from typing import Callable - # Pattern for inline file includes: @path/to/file or @path/to/dir/ INCLUDE_PATTERN = re.compile(r'@([^\s\n]+)') @@ -192,11 +190,12 @@ def _expand_includes(self) -> None: self.base_dir = Path.cwd() expanded = self.raw_content - included_files = [] + included_files: list[str] = [] + base_dir = self.base_dir # Capture for closure (guaranteed non-None) - def replace_include(match: re.Match) -> str: + def replace_include(match: re.Match[str]) -> str: include_path = match.group(1) - full_path = self.base_dir / include_path + full_path = base_dir / include_path # Handle different include types if "**" in include_path or "*" in include_path: diff --git a/src/prompts/builder.py b/src/prompts/builder.py index ae95bad..a58b4a6 100644 --- a/src/prompts/builder.py +++ b/src/prompts/builder.py @@ -8,7 +8,7 @@ """ from ..models import PhaseType -from . import EXECUTOR_PROMPT, REVIEWER_PROMPT, PLANNER_PROMPT +from . import EXECUTOR_PROMPT, PLANNER_PROMPT, REVIEWER_PROMPT def build_prompt( diff --git a/src/rate_limiter.py b/src/rate_limiter.py index a63c0f2..17d87d7 100644 --- a/src/rate_limiter.py +++ b/src/rate_limiter.py @@ -9,7 +9,6 @@ import logging import time from dataclasses import dataclass, field -from datetime import datetime, timedelta @dataclass @@ -33,7 +32,7 @@ class RateLimiter: hour_started: float = field(default_factory=time.time) total_calls: int = 0 - def __post_init__(self): + def __post_init__(self) -> None: self.log = logging.getLogger("fireteam.rate_limiter") self._lock = asyncio.Lock() @@ -96,7 +95,7 @@ async def _wait_for_reset(self) -> None: await asyncio.sleep(wait_seconds) self._reset() - def get_status(self) -> dict: + def get_status(self) -> dict[str, int | float | bool]: """Get current rate limiter status.""" return { "calls_this_hour": self.calls_this_hour, diff --git a/src/runner.py b/src/runner.py index 0f8816f..26f3122 100644 --- a/src/runner.py +++ b/src/runner.py @@ -21,12 +21,10 @@ from typing import Literal from .api import execute -from .models import ExecutionMode, ExecutionResult -from .claude_cli import CLISession from .circuit_breaker import create_circuit_breaker -from .rate_limiter import get_rate_limiter -from .prompt import Prompt, resolve_prompt - +from .claude_cli import CLISession +from .models import ExecutionMode, ExecutionResult +from .prompt import resolve_prompt # Session state file location STATE_DIR = Path.home() / ".fireteam" @@ -173,7 +171,6 @@ def start_session( project_dir=project_dir, edit=False, # Can't do interactive edit when starting tmux session ) - goal_display = prompt.goal[:100] + "..." if len(prompt.goal) > 100 else prompt.goal # Create log directory LOG_DIR.mkdir(parents=True, exist_ok=True) @@ -211,11 +208,12 @@ def start_session( check=True, ) - # Save session info + # Save session info (use truncated prompt.goal for display) + goal_summary = prompt.goal[:200] + "..." if len(prompt.goal) > 200 else prompt.goal info = SessionInfo( session_name=session_name, project_dir=str(project_dir), - goal=goal, + goal=goal_summary, started_at=datetime.now().isoformat(), log_file=str(log_file), status="running", @@ -316,7 +314,6 @@ async def run_autonomous( session = CLISession() circuit_breaker = create_circuit_breaker() - rate_limiter = get_rate_limiter() try: result = await execute( @@ -350,7 +347,7 @@ async def run_autonomous( raise -def main(): +def main() -> None: """CLI entry point for the tmux runner.""" import argparse diff --git a/tests/conftest.py b/tests/conftest.py index c0a6513..34762c0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,10 @@ """Shared pytest fixtures for fireteam tests.""" -import pytest -import tempfile import shutil +import tempfile from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch + +import pytest @pytest.fixture @@ -62,7 +62,7 @@ async def _mock_query(*args, **kwargs): @pytest.fixture def mock_execution_result(): """Create a mock ExecutionResult for testing.""" - from fireteam.models import ExecutionResult, ExecutionMode + from fireteam.models import ExecutionMode, ExecutionResult return ExecutionResult( success=True, mode=ExecutionMode.SINGLE_TURN, diff --git a/tests/test_api.py b/tests/test_api.py index a3ac2f9..40ab963 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,14 +1,15 @@ """Unit tests for the fireteam API.""" -import pytest from pathlib import Path -from unittest.mock import patch, MagicMock, AsyncMock +from unittest.mock import patch -from fireteam.api import execute, COMPLEXITY_TO_MODE -from fireteam.models import ExecutionMode, ExecutionResult, _extract_completion, _extract_issues -from fireteam.prompts import EXECUTOR_PROMPT, REVIEWER_PROMPT, PLANNER_PROMPT -from fireteam.complexity import ComplexityLevel +import pytest + +from fireteam.api import COMPLEXITY_TO_MODE, execute from fireteam.claude_cli import CLIResult +from fireteam.complexity import ComplexityLevel +from fireteam.models import ExecutionMode, ExecutionResult, _extract_completion, _extract_issues +from fireteam.prompts import EXECUTOR_PROMPT, PLANNER_PROMPT, REVIEWER_PROMPT class TestExecutionMode: diff --git a/tests/test_complexity.py b/tests/test_complexity.py index a9c0819..b4105c4 100644 --- a/tests/test_complexity.py +++ b/tests/test_complexity.py @@ -1,11 +1,12 @@ """Unit tests for complexity estimation.""" +from unittest.mock import patch + import pytest -from unittest.mock import patch, AsyncMock, MagicMock +from fireteam.claude_cli import CLIResult from fireteam.complexity import ComplexityLevel, estimate_complexity from fireteam.prompts import COMPLEXITY_PROMPT -from fireteam.claude_cli import CLIResult class TestComplexityLevel: diff --git a/tests/test_hooks.py b/tests/test_hooks.py deleted file mode 100644 index d530e92..0000000 --- a/tests/test_hooks.py +++ /dev/null @@ -1,373 +0,0 @@ -"""Unit tests for SDK hooks.""" - -import pytest -from pathlib import Path -from unittest.mock import patch, MagicMock -import subprocess - -from fireteam.hooks import ( - detect_test_command, - run_tests_sync, - run_tests_after_edit, - block_user_questions, - log_tool_usage, - create_test_hooks, - QUALITY_HOOKS, - AUTONOMOUS_HOOKS, - DEBUG_HOOKS, - DEFAULT_TEST_COMMANDS, -) - - -class TestDetectTestCommand: - """Tests for test command detection.""" - - def test_detects_pytest_ini(self, isolated_tmp_dir): - """Detects Python project with pytest.ini.""" - (isolated_tmp_dir / "pytest.ini").write_text("[pytest]") - result = detect_test_command(isolated_tmp_dir) - assert result == ["pytest", "-x", "--tb=short"] - - def test_detects_pyproject_toml(self, isolated_tmp_dir): - """Detects Python project with pyproject.toml.""" - (isolated_tmp_dir / "pyproject.toml").write_text("[project]") - result = detect_test_command(isolated_tmp_dir) - assert result == ["pytest", "-x", "--tb=short"] - - def test_detects_setup_py(self, isolated_tmp_dir): - """Detects Python project with setup.py.""" - (isolated_tmp_dir / "setup.py").write_text("from setuptools import setup") - result = detect_test_command(isolated_tmp_dir) - assert result == ["pytest", "-x", "--tb=short"] - - def test_detects_tests_directory(self, isolated_tmp_dir): - """Detects Python project with tests/ directory.""" - (isolated_tmp_dir / "tests").mkdir() - result = detect_test_command(isolated_tmp_dir) - assert result == ["pytest", "-x", "--tb=short"] - - def test_detects_nodejs(self, isolated_tmp_dir): - """Detects Node.js project with package.json.""" - (isolated_tmp_dir / "package.json").write_text('{"name": "test"}') - result = detect_test_command(isolated_tmp_dir) - assert result == ["npm", "test"] - - def test_detects_rust(self, isolated_tmp_dir): - """Detects Rust project with Cargo.toml.""" - (isolated_tmp_dir / "Cargo.toml").write_text("[package]") - result = detect_test_command(isolated_tmp_dir) - assert result == ["cargo", "test"] - - def test_detects_go(self, isolated_tmp_dir): - """Detects Go project with go.mod.""" - (isolated_tmp_dir / "go.mod").write_text("module test") - result = detect_test_command(isolated_tmp_dir) - assert result == ["go", "test", "./..."] - - def test_detects_makefile_with_test(self, isolated_tmp_dir): - """Detects Makefile with test target.""" - (isolated_tmp_dir / "Makefile").write_text("test:\n\techo 'testing'") - result = detect_test_command(isolated_tmp_dir) - assert result == ["make", "test"] - - def test_ignores_makefile_without_test(self, isolated_tmp_dir): - """Ignores Makefile without test target.""" - (isolated_tmp_dir / "Makefile").write_text("build:\n\techo 'building'") - result = detect_test_command(isolated_tmp_dir) - assert result is None - - def test_returns_none_for_unknown_project(self, isolated_tmp_dir): - """Returns None for unknown project type.""" - result = detect_test_command(isolated_tmp_dir) - assert result is None - - def test_python_takes_priority(self, isolated_tmp_dir): - """Python detection takes priority over other frameworks.""" - # Create both Python and Node.js markers - (isolated_tmp_dir / "pyproject.toml").write_text("[project]") - (isolated_tmp_dir / "package.json").write_text('{"name": "test"}') - result = detect_test_command(isolated_tmp_dir) - assert result == ["pytest", "-x", "--tb=short"] - - -class TestRunTestsSync: - """Tests for synchronous test execution.""" - - def test_returns_success_on_zero_exit(self, isolated_tmp_dir): - """Returns success=True when command exits with 0.""" - with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock( - returncode=0, - stdout="All tests passed", - stderr="" - ) - success, output = run_tests_sync(isolated_tmp_dir, ["pytest"]) - assert success is True - assert "All tests passed" in output - - def test_returns_failure_on_nonzero_exit(self, isolated_tmp_dir): - """Returns success=False when command exits with non-zero.""" - with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock( - returncode=1, - stdout="", - stderr="1 test failed" - ) - success, output = run_tests_sync(isolated_tmp_dir, ["pytest"]) - assert success is False - assert "1 test failed" in output - - def test_handles_timeout(self, isolated_tmp_dir): - """Handles test timeout gracefully.""" - with patch("subprocess.run") as mock_run: - mock_run.side_effect = subprocess.TimeoutExpired(cmd="pytest", timeout=120) - success, output = run_tests_sync(isolated_tmp_dir, ["pytest"], timeout=120) - assert success is False - assert "timed out" in output - - def test_handles_command_not_found(self, isolated_tmp_dir): - """Handles missing command gracefully.""" - with patch("subprocess.run") as mock_run: - mock_run.side_effect = FileNotFoundError() - success, output = run_tests_sync(isolated_tmp_dir, ["nonexistent"]) - assert success is False - assert "not found" in output - - def test_handles_generic_error(self, isolated_tmp_dir): - """Handles generic errors gracefully.""" - with patch("subprocess.run") as mock_run: - mock_run.side_effect = Exception("Something went wrong") - success, output = run_tests_sync(isolated_tmp_dir, ["pytest"]) - assert success is False - assert "Error" in output - - def test_combines_stdout_and_stderr(self, isolated_tmp_dir): - """Combines stdout and stderr in output.""" - with patch("subprocess.run") as mock_run: - mock_run.return_value = MagicMock( - returncode=0, - stdout="stdout content", - stderr="stderr content" - ) - success, output = run_tests_sync(isolated_tmp_dir, ["pytest"]) - assert "stdout content" in output - assert "stderr content" in output - - -class TestRunTestsAfterEdit: - """Tests for PostToolUse test running hook.""" - - @pytest.mark.asyncio - async def test_ignores_non_post_tool_use(self): - """Ignores events that aren't PostToolUse.""" - result = await run_tests_after_edit( - {"hook_event_name": "PreToolUse"}, - None, - None - ) - assert result == {} - - @pytest.mark.asyncio - async def test_ignores_non_edit_write_tools(self): - """Ignores tools other than Edit/Write.""" - result = await run_tests_after_edit( - {"hook_event_name": "PostToolUse", "tool_name": "Read"}, - None, - None - ) - assert result == {} - - @pytest.mark.asyncio - async def test_ignores_missing_cwd(self): - """Ignores when cwd is not provided.""" - result = await run_tests_after_edit( - {"hook_event_name": "PostToolUse", "tool_name": "Edit", "cwd": ""}, - None, - None - ) - assert result == {} - - @pytest.mark.asyncio - async def test_ignores_no_test_framework(self, isolated_tmp_dir): - """Ignores when no test framework is detected.""" - result = await run_tests_after_edit( - { - "hook_event_name": "PostToolUse", - "tool_name": "Edit", - "cwd": str(isolated_tmp_dir), - }, - None, - None - ) - assert result == {} - - @pytest.mark.asyncio - async def test_returns_empty_on_success(self, isolated_tmp_dir): - """Returns empty dict when tests pass.""" - (isolated_tmp_dir / "pyproject.toml").write_text("[project]") - - with patch("fireteam.hooks.run_tests_sync", return_value=(True, "All passed")): - result = await run_tests_after_edit( - { - "hook_event_name": "PostToolUse", - "tool_name": "Edit", - "cwd": str(isolated_tmp_dir), - "tool_input": {"file_path": "test.py"}, - }, - None, - None - ) - assert result == {} - - @pytest.mark.asyncio - async def test_returns_feedback_on_failure(self, isolated_tmp_dir): - """Returns feedback when tests fail.""" - (isolated_tmp_dir / "pyproject.toml").write_text("[project]") - - with patch("fireteam.hooks.run_tests_sync", return_value=(False, "1 test failed")): - result = await run_tests_after_edit( - { - "hook_event_name": "PostToolUse", - "tool_name": "Edit", - "cwd": str(isolated_tmp_dir), - "tool_input": {"file_path": "test.py"}, - }, - None, - None - ) - assert "hookSpecificOutput" in result - assert "Tests failed" in result["hookSpecificOutput"]["additionalContext"] - - @pytest.mark.asyncio - async def test_truncates_long_output(self, isolated_tmp_dir): - """Truncates output longer than 2000 chars.""" - (isolated_tmp_dir / "pyproject.toml").write_text("[project]") - long_output = "x" * 3000 - - with patch("fireteam.hooks.run_tests_sync", return_value=(False, long_output)): - result = await run_tests_after_edit( - { - "hook_event_name": "PostToolUse", - "tool_name": "Edit", - "cwd": str(isolated_tmp_dir), - "tool_input": {"file_path": "test.py"}, - }, - None, - None - ) - context = result["hookSpecificOutput"]["additionalContext"] - assert "truncated" in context - assert len(context) < 3000 - - -class TestBlockUserQuestions: - """Tests for PreToolUse AskUserQuestion blocking hook.""" - - @pytest.mark.asyncio - async def test_ignores_non_pre_tool_use(self): - """Ignores events that aren't PreToolUse.""" - result = await block_user_questions( - {"hook_event_name": "PostToolUse"}, - None, - None - ) - assert result == {} - - @pytest.mark.asyncio - async def test_ignores_other_tools(self): - """Ignores tools other than AskUserQuestion.""" - result = await block_user_questions( - {"hook_event_name": "PreToolUse", "tool_name": "Edit"}, - None, - None - ) - assert result == {} - - @pytest.mark.asyncio - async def test_blocks_ask_user_question(self): - """Blocks AskUserQuestion with deny decision.""" - result = await block_user_questions( - {"hook_event_name": "PreToolUse", "tool_name": "AskUserQuestion"}, - None, - None - ) - assert "hookSpecificOutput" in result - output = result["hookSpecificOutput"] - assert output["permissionDecision"] == "deny" - assert "autonomous" in output["permissionDecisionReason"].lower() - - -class TestLogToolUsage: - """Tests for debug logging hook.""" - - @pytest.mark.asyncio - async def test_ignores_non_post_tool_use(self): - """Ignores events that aren't PostToolUse.""" - result = await log_tool_usage( - {"hook_event_name": "PreToolUse"}, - None, - None - ) - assert result == {} - - @pytest.mark.asyncio - async def test_returns_empty_dict(self): - """Always returns empty dict (just logs).""" - result = await log_tool_usage( - {"hook_event_name": "PostToolUse", "tool_name": "Edit", "tool_input": {}}, - None, - None - ) - assert result == {} - - -class TestCreateTestHooks: - """Tests for hook configuration factory.""" - - def test_returns_dict_with_pre_and_post(self): - """Returns dict with PreToolUse and PostToolUse keys.""" - hooks = create_test_hooks() - assert "PreToolUse" in hooks - assert "PostToolUse" in hooks - - def test_pre_tool_use_blocks_questions(self): - """PreToolUse contains AskUserQuestion blocker.""" - hooks = create_test_hooks() - pre_hooks = hooks["PreToolUse"] - assert len(pre_hooks) > 0 - - def test_post_tool_use_runs_tests(self): - """PostToolUse contains test runner.""" - hooks = create_test_hooks() - post_hooks = hooks["PostToolUse"] - assert len(post_hooks) > 0 - - -class TestPreConfiguredHooks: - """Tests for pre-configured hook sets.""" - - def test_quality_hooks_has_pre_and_post(self): - """QUALITY_HOOKS has both PreToolUse and PostToolUse.""" - assert "PreToolUse" in QUALITY_HOOKS - assert "PostToolUse" in QUALITY_HOOKS - - def test_autonomous_hooks_has_pre(self): - """AUTONOMOUS_HOOKS has PreToolUse.""" - assert "PreToolUse" in AUTONOMOUS_HOOKS - - def test_debug_hooks_has_post(self): - """DEBUG_HOOKS has PostToolUse.""" - assert "PostToolUse" in DEBUG_HOOKS - - -class TestDefaultTestCommands: - """Tests for default test commands list.""" - - def test_includes_common_frameworks(self): - """Includes commands for common test frameworks.""" - commands_flat = [cmd[0] for cmd in DEFAULT_TEST_COMMANDS] - assert "pytest" in commands_flat - assert "npm" in commands_flat - assert "cargo" in commands_flat - assert "go" in commands_flat - assert "make" in commands_flat diff --git a/tests/test_integration.py b/tests/test_integration.py index 615d770..00660c4 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -4,14 +4,14 @@ Run with --run-integration for tests that require Claude Code CLI. """ +from unittest.mock import patch + import pytest -from pathlib import Path -from unittest.mock import patch, MagicMock from fireteam.api import execute -from fireteam.models import ExecutionMode, ExecutionResult -from fireteam.complexity import ComplexityLevel from fireteam.claude_cli import CLIResult +from fireteam.complexity import ComplexityLevel +from fireteam.models import ExecutionMode class TestComplexityToExecutionFlow: @@ -245,7 +245,7 @@ async def mock_cli_query(prompt, *args, **kwargs): return CLIResult(success=True, output="Done.", session_id="test") with patch("fireteam.loops.run_cli_query", mock_cli_query): - result = await execute( + await execute( project_dir=project_dir, goal="fix bug", mode=ExecutionMode.FULL, diff --git a/tests/test_prompt.py b/tests/test_prompt.py index 9e34102..fea2d7a 100644 --- a/tests/test_prompt.py +++ b/tests/test_prompt.py @@ -1,10 +1,10 @@ """Unit tests for prompt parsing and file inclusion.""" -import pytest from pathlib import Path -from unittest.mock import patch -from fireteam.prompt import Prompt, resolve_prompt, _guess_language, _should_skip +import pytest + +from fireteam.prompt import Prompt, _guess_language, _should_skip, resolve_prompt class TestPromptFromString: diff --git a/uv.lock b/uv.lock index 9c76046..75a771e 100644 --- a/uv.lock +++ b/uv.lock @@ -203,16 +203,20 @@ dependencies = [ [package.optional-dependencies] dev = [ + { name = "mypy" }, { name = "pytest" }, { name = "pytest-asyncio" }, + { name = "ruff" }, ] [package.metadata] requires-dist = [ { name = "claude-agent-sdk", specifier = ">=0.1.4" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.0" }, ] provides-extras = ["dev"] @@ -307,6 +311,58 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "librt" +version = "0.7.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/24/5f3646ff414285e0f7708fa4e946b9bf538345a41d1c375c439467721a5e/librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862", size = 148323, upload-time = "2026-01-14T12:56:16.876Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/04/79d8fcb43cae376c7adbab7b2b9f65e48432c9eced62ac96703bcc16e09b/librt-0.7.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b6943885b2d49c48d0cff23b16be830ba46b0152d98f62de49e735c6e655a63", size = 57472, upload-time = "2026-01-14T12:55:08.528Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ba/60b96e93043d3d659da91752689023a73981336446ae82078cddf706249e/librt-0.7.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46ef1f4b9b6cc364b11eea0ecc0897314447a66029ee1e55859acb3dd8757c93", size = 58986, upload-time = "2026-01-14T12:55:09.466Z" }, + { url = "https://files.pythonhosted.org/packages/7c/26/5215e4cdcc26e7be7eee21955a7e13cbf1f6d7d7311461a6014544596fac/librt-0.7.8-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:907ad09cfab21e3c86e8f1f87858f7049d1097f77196959c033612f532b4e592", size = 168422, upload-time = "2026-01-14T12:55:10.499Z" }, + { url = "https://files.pythonhosted.org/packages/0f/84/e8d1bc86fa0159bfc24f3d798d92cafd3897e84c7fea7fe61b3220915d76/librt-0.7.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2991b6c3775383752b3ca0204842743256f3ad3deeb1d0adc227d56b78a9a850", size = 177478, upload-time = "2026-01-14T12:55:11.577Z" }, + { url = "https://files.pythonhosted.org/packages/57/11/d0268c4b94717a18aa91df1100e767b010f87b7ae444dafaa5a2d80f33a6/librt-0.7.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03679b9856932b8c8f674e87aa3c55ea11c9274301f76ae8dc4d281bda55cf62", size = 192439, upload-time = "2026-01-14T12:55:12.7Z" }, + { url = "https://files.pythonhosted.org/packages/8d/56/1e8e833b95fe684f80f8894ae4d8b7d36acc9203e60478fcae599120a975/librt-0.7.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3968762fec1b2ad34ce57458b6de25dbb4142713e9ca6279a0d352fa4e9f452b", size = 191483, upload-time = "2026-01-14T12:55:13.838Z" }, + { url = "https://files.pythonhosted.org/packages/17/48/f11cf28a2cb6c31f282009e2208312aa84a5ee2732859f7856ee306176d5/librt-0.7.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bb7a7807523a31f03061288cc4ffc065d684c39db7644c676b47d89553c0d714", size = 185376, upload-time = "2026-01-14T12:55:15.017Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6a/d7c116c6da561b9155b184354a60a3d5cdbf08fc7f3678d09c95679d13d9/librt-0.7.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad64a14b1e56e702e19b24aae108f18ad1bf7777f3af5fcd39f87d0c5a814449", size = 206234, upload-time = "2026-01-14T12:55:16.571Z" }, + { url = "https://files.pythonhosted.org/packages/61/de/1975200bb0285fc921c5981d9978ce6ce11ae6d797df815add94a5a848a3/librt-0.7.8-cp312-cp312-win32.whl", hash = "sha256:0241a6ed65e6666236ea78203a73d800dbed896cf12ae25d026d75dc1fcd1dac", size = 44057, upload-time = "2026-01-14T12:55:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/8e/cd/724f2d0b3461426730d4877754b65d39f06a41ac9d0a92d5c6840f72b9ae/librt-0.7.8-cp312-cp312-win_amd64.whl", hash = "sha256:6db5faf064b5bab9675c32a873436b31e01d66ca6984c6f7f92621656033a708", size = 50293, upload-time = "2026-01-14T12:55:19.179Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cf/7e899acd9ee5727ad8160fdcc9994954e79fab371c66535c60e13b968ffc/librt-0.7.8-cp312-cp312-win_arm64.whl", hash = "sha256:57175aa93f804d2c08d2edb7213e09276bd49097611aefc37e3fa38d1fb99ad0", size = 43574, upload-time = "2026-01-14T12:55:20.185Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fe/b1f9de2829cf7fc7649c1dcd202cfd873837c5cc2fc9e526b0e7f716c3d2/librt-0.7.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4c3995abbbb60b3c129490fa985dfe6cac11d88fc3c36eeb4fb1449efbbb04fc", size = 57500, upload-time = "2026-01-14T12:55:21.219Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d4/4a60fbe2e53b825f5d9a77325071d61cd8af8506255067bf0c8527530745/librt-0.7.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44e0c2cbc9bebd074cf2cdbe472ca185e824be4e74b1c63a8e934cea674bebf2", size = 59019, upload-time = "2026-01-14T12:55:22.256Z" }, + { url = "https://files.pythonhosted.org/packages/6a/37/61ff80341ba5159afa524445f2d984c30e2821f31f7c73cf166dcafa5564/librt-0.7.8-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d2f1e492cae964b3463a03dc77a7fe8742f7855d7258c7643f0ee32b6651dd3", size = 169015, upload-time = "2026-01-14T12:55:23.24Z" }, + { url = "https://files.pythonhosted.org/packages/1c/86/13d4f2d6a93f181ebf2fc953868826653ede494559da8268023fe567fca3/librt-0.7.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:451e7ffcef8f785831fdb791bd69211f47e95dc4c6ddff68e589058806f044c6", size = 178161, upload-time = "2026-01-14T12:55:24.826Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/e24ef01305954fc4d771f1f09f3dd682f9eb610e1bec188ffb719374d26e/librt-0.7.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3469e1af9f1380e093ae06bedcbdd11e407ac0b303a56bbe9afb1d6824d4982d", size = 193015, upload-time = "2026-01-14T12:55:26.04Z" }, + { url = "https://files.pythonhosted.org/packages/88/a0/92b6bd060e720d7a31ed474d046a69bd55334ec05e9c446d228c4b806ae3/librt-0.7.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f11b300027ce19a34f6d24ebb0a25fd0e24a9d53353225a5c1e6cadbf2916b2e", size = 192038, upload-time = "2026-01-14T12:55:27.208Z" }, + { url = "https://files.pythonhosted.org/packages/06/bb/6f4c650253704279c3a214dad188101d1b5ea23be0606628bc6739456624/librt-0.7.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4adc73614f0d3c97874f02f2c7fd2a27854e7e24ad532ea6b965459c5b757eca", size = 186006, upload-time = "2026-01-14T12:55:28.594Z" }, + { url = "https://files.pythonhosted.org/packages/dc/00/1c409618248d43240cadf45f3efb866837fa77e9a12a71481912135eb481/librt-0.7.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60c299e555f87e4c01b2eca085dfccda1dde87f5a604bb45c2906b8305819a93", size = 206888, upload-time = "2026-01-14T12:55:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/d9/83/b2cfe8e76ff5c1c77f8a53da3d5de62d04b5ebf7cf913e37f8bca43b5d07/librt-0.7.8-cp313-cp313-win32.whl", hash = "sha256:b09c52ed43a461994716082ee7d87618096851319bf695d57ec123f2ab708951", size = 44126, upload-time = "2026-01-14T12:55:31.44Z" }, + { url = "https://files.pythonhosted.org/packages/a9/0b/c59d45de56a51bd2d3a401fc63449c0ac163e4ef7f523ea8b0c0dee86ec5/librt-0.7.8-cp313-cp313-win_amd64.whl", hash = "sha256:f8f4a901a3fa28969d6e4519deceab56c55a09d691ea7b12ca830e2fa3461e34", size = 50262, upload-time = "2026-01-14T12:55:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b9/973455cec0a1ec592395250c474164c4a58ebf3e0651ee920fef1a2623f1/librt-0.7.8-cp313-cp313-win_arm64.whl", hash = "sha256:43d4e71b50763fcdcf64725ac680d8cfa1706c928b844794a7aa0fa9ac8e5f09", size = 43600, upload-time = "2026-01-14T12:55:34.054Z" }, + { url = "https://files.pythonhosted.org/packages/1a/73/fa8814c6ce2d49c3827829cadaa1589b0bf4391660bd4510899393a23ebc/librt-0.7.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:be927c3c94c74b05128089a955fba86501c3b544d1d300282cc1b4bd370cb418", size = 57049, upload-time = "2026-01-14T12:55:35.056Z" }, + { url = "https://files.pythonhosted.org/packages/53/fe/f6c70956da23ea235fd2e3cc16f4f0b4ebdfd72252b02d1164dd58b4e6c3/librt-0.7.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7b0803e9008c62a7ef79058233db7ff6f37a9933b8f2573c05b07ddafa226611", size = 58689, upload-time = "2026-01-14T12:55:36.078Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4d/7a2481444ac5fba63050d9abe823e6bc16896f575bfc9c1e5068d516cdce/librt-0.7.8-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:79feb4d00b2a4e0e05c9c56df707934f41fcb5fe53fd9efb7549068d0495b758", size = 166808, upload-time = "2026-01-14T12:55:37.595Z" }, + { url = "https://files.pythonhosted.org/packages/ac/3c/10901d9e18639f8953f57c8986796cfbf4c1c514844a41c9197cf87cb707/librt-0.7.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9122094e3f24aa759c38f46bd8863433820654927370250f460ae75488b66ea", size = 175614, upload-time = "2026-01-14T12:55:38.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/01/5cbdde0951a5090a80e5ba44e6357d375048123c572a23eecfb9326993a7/librt-0.7.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e03bea66af33c95ce3addf87a9bf1fcad8d33e757bc479957ddbc0e4f7207ac", size = 189955, upload-time = "2026-01-14T12:55:39.939Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b4/e80528d2f4b7eaf1d437fcbd6fc6ba4cbeb3e2a0cb9ed5a79f47c7318706/librt-0.7.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1ade7f31675db00b514b98f9ab9a7698c7282dad4be7492589109471852d398", size = 189370, upload-time = "2026-01-14T12:55:41.057Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ab/938368f8ce31a9787ecd4becb1e795954782e4312095daf8fd22420227c8/librt-0.7.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a14229ac62adcf1b90a15992f1ab9c69ae8b99ffb23cb64a90878a6e8a2f5b81", size = 183224, upload-time = "2026-01-14T12:55:42.328Z" }, + { url = "https://files.pythonhosted.org/packages/3c/10/559c310e7a6e4014ac44867d359ef8238465fb499e7eb31b6bfe3e3f86f5/librt-0.7.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bcaaf624fd24e6a0cb14beac37677f90793a96864c67c064a91458611446e83", size = 203541, upload-time = "2026-01-14T12:55:43.501Z" }, + { url = "https://files.pythonhosted.org/packages/f8/db/a0db7acdb6290c215f343835c6efda5b491bb05c3ddc675af558f50fdba3/librt-0.7.8-cp314-cp314-win32.whl", hash = "sha256:7aa7d5457b6c542ecaed79cec4ad98534373c9757383973e638ccced0f11f46d", size = 40657, upload-time = "2026-01-14T12:55:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/72/e0/4f9bdc2a98a798511e81edcd6b54fe82767a715e05d1921115ac70717f6f/librt-0.7.8-cp314-cp314-win_amd64.whl", hash = "sha256:3d1322800771bee4a91f3b4bd4e49abc7d35e65166821086e5afd1e6c0d9be44", size = 46835, upload-time = "2026-01-14T12:55:45.655Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3d/59c6402e3dec2719655a41ad027a7371f8e2334aa794ed11533ad5f34969/librt-0.7.8-cp314-cp314-win_arm64.whl", hash = "sha256:5363427bc6a8c3b1719f8f3845ea53553d301382928a86e8fab7984426949bce", size = 39885, upload-time = "2026-01-14T12:55:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9c/2481d80950b83085fb14ba3c595db56330d21bbc7d88a19f20165f3538db/librt-0.7.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ca916919793a77e4a98d4a1701e345d337ce53be4a16620f063191f7322ac80f", size = 59161, upload-time = "2026-01-14T12:55:48.45Z" }, + { url = "https://files.pythonhosted.org/packages/96/79/108df2cfc4e672336765d54e3ff887294c1cc36ea4335c73588875775527/librt-0.7.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:54feb7b4f2f6706bb82325e836a01be805770443e2400f706e824e91f6441dde", size = 61008, upload-time = "2026-01-14T12:55:49.527Z" }, + { url = "https://files.pythonhosted.org/packages/46/f2/30179898f9994a5637459d6e169b6abdc982012c0a4b2d4c26f50c06f911/librt-0.7.8-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39a4c76fee41007070f872b648cc2f711f9abf9a13d0c7162478043377b52c8e", size = 187199, upload-time = "2026-01-14T12:55:50.587Z" }, + { url = "https://files.pythonhosted.org/packages/b4/da/f7563db55cebdc884f518ba3791ad033becc25ff68eb70902b1747dc0d70/librt-0.7.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac9c8a458245c7de80bc1b9765b177055efff5803f08e548dd4bb9ab9a8d789b", size = 198317, upload-time = "2026-01-14T12:55:51.991Z" }, + { url = "https://files.pythonhosted.org/packages/b3/6c/4289acf076ad371471fa86718c30ae353e690d3de6167f7db36f429272f1/librt-0.7.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b67aa7eff150f075fda09d11f6bfb26edffd300f6ab1666759547581e8f666", size = 210334, upload-time = "2026-01-14T12:55:53.682Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7f/377521ac25b78ac0a5ff44127a0360ee6d5ddd3ce7327949876a30533daa/librt-0.7.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:535929b6eff670c593c34ff435d5440c3096f20fa72d63444608a5aef64dd581", size = 211031, upload-time = "2026-01-14T12:55:54.827Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b1/e1e96c3e20b23d00cf90f4aad48f0deb4cdfec2f0ed8380d0d85acf98bbf/librt-0.7.8-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:63937bd0f4d1cb56653dc7ae900d6c52c41f0015e25aaf9902481ee79943b33a", size = 204581, upload-time = "2026-01-14T12:55:56.811Z" }, + { url = "https://files.pythonhosted.org/packages/43/71/0f5d010e92ed9747e14bef35e91b6580533510f1e36a8a09eb79ee70b2f0/librt-0.7.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf243da9e42d914036fd362ac3fa77d80a41cadcd11ad789b1b5eec4daaf67ca", size = 224731, upload-time = "2026-01-14T12:55:58.175Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/07fb6ab5c39a4ca9af3e37554f9d42f25c464829254d72e4ebbd81da351c/librt-0.7.8-cp314-cp314t-win32.whl", hash = "sha256:171ca3a0a06c643bd0a2f62a8944e1902c94aa8e5da4db1ea9a8daf872685365", size = 41173, upload-time = "2026-01-14T12:55:59.315Z" }, + { url = "https://files.pythonhosted.org/packages/24/d4/7e4be20993dc6a782639625bd2f97f3c66125c7aa80c82426956811cfccf/librt-0.7.8-cp314-cp314t-win_amd64.whl", hash = "sha256:445b7304145e24c60288a2f172b5ce2ca35c0f81605f5299f3fa567e189d2e32", size = 47668, upload-time = "2026-01-14T12:56:00.261Z" }, + { url = "https://files.pythonhosted.org/packages/fc/85/69f92b2a7b3c0f88ffe107c86b952b397004b5b8ea5a81da3d9c04c04422/librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06", size = 40550, upload-time = "2026-01-14T12:56:01.542Z" }, +] + [[package]] name = "mcp" version = "1.25.0" @@ -332,6 +388,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e2/fc/6dc7659c2ae5ddf280477011f4213a74f806862856b796ef08f028e664bf/mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a", size = 233076, upload-time = "2025-12-19T10:19:55.416Z" }, ] +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + [[package]] name = "packaging" version = "25.0" @@ -341,6 +439,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, ] +[[package]] +name = "pathspec" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/b2/bb8e495d5262bfec41ab5cb18f522f1012933347fb5d9e62452d446baca2/pathspec-1.0.3.tar.gz", hash = "sha256:bac5cf97ae2c2876e2d25ebb15078eb04d76e4b98921ee31c6f85ade8b59444d", size = 130841, upload-time = "2026-01-09T15:46:46.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/2b/121e912bd60eebd623f873fd090de0e84f322972ab25a7f9044c056804ed/pathspec-1.0.3-py3-none-any.whl", hash = "sha256:e80767021c1cc524aa3fb14bedda9c34406591343cc42797b386ce7b9354fb6c", size = 55021, upload-time = "2026-01-09T15:46:44.652Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -640,6 +747,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d0/02/fa464cdfbe6b26e0600b62c528b72d8608f5cc49f96b8d6e38c95d60c676/rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3", size = 226532, upload-time = "2025-11-30T20:24:14.634Z" }, ] +[[package]] +name = "ruff" +version = "0.14.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/50/0a/1914efb7903174b381ee2ffeebb4253e729de57f114e63595114c8ca451f/ruff-0.14.13.tar.gz", hash = "sha256:83cd6c0763190784b99650a20fec7633c59f6ebe41c5cc9d45ee42749563ad47", size = 6059504, upload-time = "2026-01-15T20:15:16.918Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/ae/0deefbc65ca74b0ab1fd3917f94dc3b398233346a74b8bbb0a916a1a6bf6/ruff-0.14.13-py3-none-linux_armv6l.whl", hash = "sha256:76f62c62cd37c276cb03a275b198c7c15bd1d60c989f944db08a8c1c2dbec18b", size = 13062418, upload-time = "2026-01-15T20:14:50.779Z" }, + { url = "https://files.pythonhosted.org/packages/47/df/5916604faa530a97a3c154c62a81cb6b735c0cb05d1e26d5ad0f0c8ac48a/ruff-0.14.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:914a8023ece0528d5cc33f5a684f5f38199bbb566a04815c2c211d8f40b5d0ed", size = 13442344, upload-time = "2026-01-15T20:15:07.94Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f3/e0e694dd69163c3a1671e102aa574a50357536f18a33375050334d5cd517/ruff-0.14.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d24899478c35ebfa730597a4a775d430ad0d5631b8647a3ab368c29b7e7bd063", size = 12354720, upload-time = "2026-01-15T20:15:09.854Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e8/67f5fcbbaee25e8fc3b56cc33e9892eca7ffe09f773c8e5907757a7e3bdb/ruff-0.14.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9aaf3870f14d925bbaf18b8a2347ee0ae7d95a2e490e4d4aea6813ed15ebc80e", size = 12774493, upload-time = "2026-01-15T20:15:20.908Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ce/d2e9cb510870b52a9565d885c0d7668cc050e30fa2c8ac3fb1fda15c083d/ruff-0.14.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac5b7f63dd3b27cc811850f5ffd8fff845b00ad70e60b043aabf8d6ecc304e09", size = 12815174, upload-time = "2026-01-15T20:15:05.74Z" }, + { url = "https://files.pythonhosted.org/packages/88/00/c38e5da58beebcf4fa32d0ddd993b63dfacefd02ab7922614231330845bf/ruff-0.14.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78d2b1097750d90ba82ce4ba676e85230a0ed694178ca5e61aa9b459970b3eb9", size = 13680909, upload-time = "2026-01-15T20:15:14.537Z" }, + { url = "https://files.pythonhosted.org/packages/61/61/cd37c9dd5bd0a3099ba79b2a5899ad417d8f3b04038810b0501a80814fd7/ruff-0.14.13-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:7d0bf87705acbbcb8d4c24b2d77fbb73d40210a95c3903b443cd9e30824a5032", size = 15144215, upload-time = "2026-01-15T20:15:22.886Z" }, + { url = "https://files.pythonhosted.org/packages/56/8a/85502d7edbf98c2df7b8876f316c0157359165e16cdf98507c65c8d07d3d/ruff-0.14.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3eb5da8e2c9e9f13431032fdcbe7681de9ceda5835efee3269417c13f1fed5c", size = 14706067, upload-time = "2026-01-15T20:14:48.271Z" }, + { url = "https://files.pythonhosted.org/packages/7e/2f/de0df127feb2ee8c1e54354dc1179b4a23798f0866019528c938ba439aca/ruff-0.14.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:642442b42957093811cd8d2140dfadd19c7417030a7a68cf8d51fcdd5f217427", size = 14133916, upload-time = "2026-01-15T20:14:57.357Z" }, + { url = "https://files.pythonhosted.org/packages/0d/77/9b99686bb9fe07a757c82f6f95e555c7a47801a9305576a9c67e0a31d280/ruff-0.14.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4acdf009f32b46f6e8864af19cbf6841eaaed8638e65c8dac845aea0d703c841", size = 13859207, upload-time = "2026-01-15T20:14:55.111Z" }, + { url = "https://files.pythonhosted.org/packages/7d/46/2bdcb34a87a179a4d23022d818c1c236cb40e477faf0d7c9afb6813e5876/ruff-0.14.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:591a7f68860ea4e003917d19b5c4f5ac39ff558f162dc753a2c5de897fd5502c", size = 14043686, upload-time = "2026-01-15T20:14:52.841Z" }, + { url = "https://files.pythonhosted.org/packages/1a/a9/5c6a4f56a0512c691cf143371bcf60505ed0f0860f24a85da8bd123b2bf1/ruff-0.14.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:774c77e841cc6e046fc3e91623ce0903d1cd07e3a36b1a9fe79b81dab3de506b", size = 12663837, upload-time = "2026-01-15T20:15:18.921Z" }, + { url = "https://files.pythonhosted.org/packages/fe/bb/b920016ece7651fa7fcd335d9d199306665486694d4361547ccb19394c44/ruff-0.14.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:61f4e40077a1248436772bb6512db5fc4457fe4c49e7a94ea7c5088655dd21ae", size = 12805867, upload-time = "2026-01-15T20:14:59.272Z" }, + { url = "https://files.pythonhosted.org/packages/7d/b3/0bd909851e5696cd21e32a8fc25727e5f58f1934b3596975503e6e85415c/ruff-0.14.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6d02f1428357fae9e98ac7aa94b7e966fd24151088510d32cf6f902d6c09235e", size = 13208528, upload-time = "2026-01-15T20:15:03.732Z" }, + { url = "https://files.pythonhosted.org/packages/3b/3b/e2d94cb613f6bbd5155a75cbe072813756363eba46a3f2177a1fcd0cd670/ruff-0.14.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e399341472ce15237be0c0ae5fbceca4b04cd9bebab1a2b2c979e015455d8f0c", size = 13929242, upload-time = "2026-01-15T20:15:11.918Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c5/abd840d4132fd51a12f594934af5eba1d5d27298a6f5b5d6c3be45301caf/ruff-0.14.13-py3-none-win32.whl", hash = "sha256:ef720f529aec113968b45dfdb838ac8934e519711da53a0456038a0efecbd680", size = 12919024, upload-time = "2026-01-15T20:14:43.647Z" }, + { url = "https://files.pythonhosted.org/packages/c2/55/6384b0b8ce731b6e2ade2b5449bf07c0e4c31e8a2e68ea65b3bafadcecc5/ruff-0.14.13-py3-none-win_amd64.whl", hash = "sha256:6070bd026e409734b9257e03e3ef18c6e1a216f0435c6751d7a8ec69cb59abef", size = 14097887, upload-time = "2026-01-15T20:15:01.48Z" }, + { url = "https://files.pythonhosted.org/packages/4d/e1/7348090988095e4e39560cfc2f7555b1b2a7357deba19167b600fdf5215d/ruff-0.14.13-py3-none-win_arm64.whl", hash = "sha256:7ab819e14f1ad9fe39f246cfcc435880ef7a9390d81a2b6ac7e01039083dd247", size = 13080224, upload-time = "2026-01-15T20:14:45.853Z" }, +] + [[package]] name = "sse-starlette" version = "3.1.2"