diff --git a/.claude/agents/skill-reviewer/justfile b/.claude/agents/skill-reviewer/justfile index d210209..e8c93a4 100644 --- a/.claude/agents/skill-reviewer/justfile +++ b/.claude/agents/skill-reviewer/justfile @@ -10,6 +10,9 @@ default: # Agent directory (relative to repo root) agent_dir := ".claude/agents/skill-reviewer" +# Use uv for dependency management +python := "uv run python" + # Progress file for tracking progress_file := ".claude/agents/skill-reviewer/data/progress.json" @@ -20,17 +23,25 @@ progress_file := ".claude/agents/skill-reviewer/data/progress.json" # Review a single skill by issue number (skill extracted from title if not specified) review issue skill="": @if [ -n "{{skill}}" ]; then \ - python "{{ justfile_directory() }}/main.py" --issue {{issue}} --skill {{skill}} --verbose; \ + cd "{{ justfile_directory() }}" && {{python}} main.py --issue {{issue}} --skill {{skill}} --verbose; \ + else \ + cd "{{ justfile_directory() }}" && {{python}} main.py --issue {{issue}} --verbose; \ + fi + +# Review with --force (delete existing branch if it exists) +review-force issue skill="": + @if [ -n "{{skill}}" ]; then \ + cd "{{ justfile_directory() }}" && {{python}} main.py --issue {{issue}} --skill {{skill}} --verbose --force; \ else \ - python "{{ justfile_directory() }}/main.py" --issue {{issue}} --verbose; \ + cd "{{ justfile_directory() }}" && {{python}} main.py --issue {{issue}} --verbose --force; \ fi # Review a single skill (dry run - no GitHub changes) review-dry issue skill="": @if [ -n "{{skill}}" ]; then \ - python "{{ justfile_directory() }}/main.py" --issue {{issue}} --skill {{skill}} --dry-run; \ + cd "{{ justfile_directory() }}" && {{python}} main.py --issue {{issue}} --skill {{skill}} --dry-run; \ else \ - python "{{ justfile_directory() }}/main.py" --issue {{issue}} --dry-run; \ + cd "{{ justfile_directory() }}" && {{python}} main.py --issue {{issue}} --dry-run; \ fi # ───────────────────────────────────────────────────────────────────────────── @@ -39,19 +50,19 @@ review-dry issue skill="": # Process all backlog issues with 'review'+'skills' labels, assigned to you (sequential) batch: - python "{{ justfile_directory() }}/main.py" --batch + cd "{{ justfile_directory() }}" && {{python}} main.py --batch # Process backlog issues with parallelism batch-parallel n="3": - python "{{ justfile_directory() }}/main.py" --batch --max-parallel {{n}} + cd "{{ justfile_directory() }}" && {{python}} main.py --batch --max-parallel {{n}} # Batch review (dry run) batch-dry: - python "{{ justfile_directory() }}/main.py" --batch --dry-run + cd "{{ justfile_directory() }}" && {{python}} main.py --batch --dry-run # Batch review for any assignee (not just you) batch-all: - python "{{ justfile_directory() }}/main.py" --batch --assignee "" + cd "{{ justfile_directory() }}" && {{python}} main.py --batch --assignee "" # ───────────────────────────────────────────────────────────────────────────── # Session Management @@ -59,19 +70,87 @@ batch-all: # List all sessions sessions: - python "{{ justfile_directory() }}/main.py" --list-sessions + cd "{{ justfile_directory() }}" && {{python}} main.py --list-sessions # Resume an interrupted session resume session_id: - python "{{ justfile_directory() }}/main.py" --resume "{{session_id}}" + cd "{{ justfile_directory() }}" && {{python}} main.py --resume "{{session_id}}" # Show session details session-info session_id: cat "{{ justfile_directory() }}/data/sessions/{{session_id}}/session.json" | jq . -# Clean up a completed session's worktree -cleanup session_id: - python "{{ justfile_directory() }}/main.py" --resume "{{session_id}}" --cleanup +# ───────────────────────────────────────────────────────────────────────────── +# Worktree Management +# ───────────────────────────────────────────────────────────────────────────── +# NOTE: Worktrees are intentionally preserved after review completion. +# This allows skill-pr-addresser to iterate on PR feedback without recreating. +# Use these recipes to manually clean up after PRs are merged. + +# List all active worktrees for this project +worktrees: + @echo "Active worktrees:" + @git worktree list --porcelain | grep "^worktree " | cut -d' ' -f2- | while read wt; do \ + if [[ "$$wt" == *"/worktrees/"* ]]; then \ + branch=$$(git -C "$$wt" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown"); \ + status=$$(git -C "$$wt" status --porcelain 2>/dev/null | wc -l | tr -d ' '); \ + echo " $$wt"; \ + echo " branch: $$branch"; \ + echo " uncommitted: $$status files"; \ + fi \ + done + +# Clean up a specific session's worktree +clean-worktree session_id: + @session_file="{{ justfile_directory() }}/data/sessions/{{session_id}}/session.json"; \ + if [ ! -f "$$session_file" ]; then \ + echo "Session {{session_id}} not found"; \ + exit 1; \ + fi; \ + worktree_path=$$(jq -r '.worktree_path // empty' "$$session_file"); \ + if [ -z "$$worktree_path" ]; then \ + echo "No worktree path in session"; \ + exit 1; \ + fi; \ + if [ -d "$$worktree_path" ]; then \ + echo "Removing worktree: $$worktree_path"; \ + git worktree remove --force "$$worktree_path" 2>/dev/null || rm -rf "$$worktree_path"; \ + git worktree prune; \ + echo "Done."; \ + else \ + echo "Worktree already removed: $$worktree_path"; \ + fi + +# Clean up old worktrees (older than N days, default 7) +clean-worktrees days="7": + @echo "Cleaning worktrees older than {{days}} days..." + @cutoff=$$(date -v-{{days}}d +%s 2>/dev/null || date -d "{{days}} days ago" +%s); \ + git worktree list --porcelain | grep "^worktree " | cut -d' ' -f2- | while read wt; do \ + if [[ "$$wt" == *"/worktrees/"* ]]; then \ + if [ -d "$$wt" ]; then \ + mtime=$$(stat -f %m "$$wt" 2>/dev/null || stat -c %Y "$$wt"); \ + if [ "$$mtime" -lt "$$cutoff" ]; then \ + echo "Removing: $$wt (last modified: $$(date -r $$mtime 2>/dev/null || date -d @$$mtime))"; \ + git worktree remove --force "$$wt" 2>/dev/null || rm -rf "$$wt"; \ + fi \ + fi \ + fi \ + done; \ + git worktree prune; \ + echo "Done." + +# Clean ALL worktrees (use with caution!) +clean-all-worktrees: + @echo "This will remove ALL worktrees. Are you sure? (Ctrl+C to cancel)" + @read -p "Press Enter to continue..." + @git worktree list --porcelain | grep "^worktree " | cut -d' ' -f2- | while read wt; do \ + if [[ "$$wt" == *"/worktrees/"* ]]; then \ + echo "Removing: $$wt"; \ + git worktree remove --force "$$wt" 2>/dev/null || rm -rf "$$wt"; \ + fi \ + done; \ + git worktree prune; \ + echo "Done." # ───────────────────────────────────────────────────────────────────────────── # Progress Tracking @@ -129,20 +208,20 @@ estimate-batch: # Run only validation stage validate issue skill="": @if [ -n "{{skill}}" ]; then \ - python "{{ justfile_directory() }}/main.py" --issue {{issue}} --skill {{skill}} \ + cd "{{ justfile_directory() }}" && {{python}} main.py --issue {{issue}} --skill {{skill}} \ --stages validation --dry-run; \ else \ - python "{{ justfile_directory() }}/main.py" --issue {{issue}} \ + cd "{{ justfile_directory() }}" && {{python}} main.py --issue {{issue}} \ --stages validation --dry-run; \ fi # Run validation + complexity assessment assess issue skill="": @if [ -n "{{skill}}" ]; then \ - python "{{ justfile_directory() }}/main.py" --issue {{issue}} --skill {{skill}} \ + cd "{{ justfile_directory() }}" && {{python}} main.py --issue {{issue}} --skill {{skill}} \ --stages validation,complexity_assessment --dry-run; \ else \ - python "{{ justfile_directory() }}/main.py" --issue {{issue}} \ + cd "{{ justfile_directory() }}" && {{python}} main.py --issue {{issue}} \ --stages validation,complexity_assessment --dry-run; \ fi diff --git a/.claude/agents/skill-reviewer/main.py b/.claude/agents/skill-reviewer/main.py index d7a2419..533c6ed 100644 --- a/.claude/agents/skill-reviewer/main.py +++ b/.claude/agents/skill-reviewer/main.py @@ -110,7 +110,8 @@ def main(): print(f"Extracted skill path: {skill_path}") result = review_single_skill( - orchestrator, skill_path, args.issue, stages, args.verbose + orchestrator, skill_path, args.issue, stages, args.verbose, + force_recreate=args.force ) if args.cleanup and result.stage == Stage.COMPLETE: diff --git a/.claude/agents/skill-reviewer/pyproject.toml b/.claude/agents/skill-reviewer/pyproject.toml new file mode 100644 index 0000000..b4787bf --- /dev/null +++ b/.claude/agents/skill-reviewer/pyproject.toml @@ -0,0 +1,36 @@ +[project] +name = "skill-reviewer" +version = "0.1.0" +description = "Skill reviewer agent for reviewing and validating skills" +requires-python = ">=3.11" + +dependencies = [ + "pyyaml>=6.0", + "chevron>=0.14.0", + "skill-agents-common", +] + +[tool.uv.sources] +skill-agents-common = { path = "../skill-agents-common", editable = true } + +[project.optional-dependencies] +dev = [ + "pytest>=8.0.0", + "pytest-asyncio>=0.23.0", + "ruff>=0.2.0", +] + +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["."] +include = ["src*"] + +[tool.ruff] +line-length = 100 +target-version = "py311" + +[tool.ruff.lint] +select = ["E", "F", "I", "W"] diff --git a/.claude/agents/skill-reviewer/src/github_ops.py b/.claude/agents/skill-reviewer/src/github_ops.py index 6a198ed..aff82ae 100644 --- a/.claude/agents/skill-reviewer/src/github_ops.py +++ b/.claude/agents/skill-reviewer/src/github_ops.py @@ -1,262 +1,43 @@ -"""GitHub operations for skill reviewer.""" - -import subprocess -import json -from dataclasses import dataclass -from typing import Any - - -@dataclass -class Issue: - """GitHub issue data.""" - number: int - title: str - state: str - labels: list[str] - body: str | None = None - url: str | None = None - - -@dataclass -class PullRequest: - """GitHub pull request data.""" - number: int - title: str - url: str - state: str - branch: str - - -def find_review_issues( - owner: str, - repo: str, - labels: list[str], - state: str = "open", - limit: int = 100 -) -> list[Issue]: - """Find issues matching review criteria. - - Args: - owner: Repository owner - repo: Repository name - labels: Labels to filter by - state: Issue state (open, closed, all) - limit: Maximum issues to return - - Returns: - List of matching issues - """ - # TODOs: - # - labels: needs to default to 'review' && 'skills' - # - state: needs to default to 'open' - label_args = [f"--label={label}" for label in labels] - - result = subprocess.run( - ["gh", "issue", "list", - "--repo", f"{owner}/{repo}", - "--state", state, - "--limit", str(limit), - "--json", "number,title,state,labels,body,url", - *label_args], - capture_output=True, - text=True - ) - - if result.returncode != 0: - return [] - - issues = json.loads(result.stdout) - return [ - Issue( - number=i["number"], - title=i["title"], - state=i["state"], - labels=[l["name"] for l in i.get("labels", [])], - body=i.get("body"), - url=i.get("url") - ) - for i in issues - ] - - -def update_issue_labels( - owner: str, - repo: str, - issue_number: int, - add_labels: list[str] | None = None, - remove_labels: list[str] | None = None -) -> bool: - """Update labels on an issue. - - Args: - owner: Repository owner - repo: Repository name - issue_number: Issue number - add_labels: Labels to add - remove_labels: Labels to remove - - Returns: - True if successful - """ - success = True - - if add_labels: - for label in add_labels: - result = subprocess.run( - ["gh", "issue", "edit", str(issue_number), - "--repo", f"{owner}/{repo}", - "--add-label", label], - capture_output=True - ) - success = success and result.returncode == 0 - - if remove_labels: - for label in remove_labels: - result = subprocess.run( - ["gh", "issue", "edit", str(issue_number), - "--repo", f"{owner}/{repo}", - "--remove-label", label], - capture_output=True - ) - # Don't fail if label wasn't present - - return success - - -def add_issue_comment( - owner: str, - repo: str, - issue_number: int, - body: str -) -> bool: - """Add a comment to an issue. - - Args: - owner: Repository owner - repo: Repository name - issue_number: Issue number - body: Comment body (markdown) - - Returns: - True if successful - """ - result = subprocess.run( - ["gh", "issue", "comment", str(issue_number), - "--repo", f"{owner}/{repo}", - "--body", body], - capture_output=True - ) - - return result.returncode == 0 - - -def create_pull_request( - owner: str, - repo: str, - title: str, - body: str, - head: str, - base: str = "main" -) -> PullRequest | None: - """Create a pull request. - - Args: - owner: Repository owner - repo: Repository name - title: PR title - body: PR body (markdown) - head: Source branch - base: Target branch - - Returns: - PullRequest if created, None on failure - """ - # TODO: when created PRs should default to 'Draft' status - # TODO: when created PRs must get the 'relevant issue' from the session.json/database; Must have 'closes #' in PR body - result = subprocess.run( - ["gh", "pr", "create", - "--repo", f"{owner}/{repo}", - "--title", title, - "--body", body, - "--head", head, - "--base", base, - "--json", "number,title,url,state,headRefName"], - capture_output=True, - text=True - ) - - if result.returncode != 0: - return None - - data = json.loads(result.stdout) - return PullRequest( - number=data["number"], - title=data["title"], - url=data["url"], - state=data["state"], - branch=data["headRefName"] - ) - - -def close_issue( - owner: str, - repo: str, - issue_number: int, - reason: str = "completed" -) -> bool: - """Close an issue. - - Args: - owner: Repository owner - repo: Repository name - issue_number: Issue number - reason: Close reason (completed, not_planned, duplicate) - - Returns: - True if successful - """ - result = subprocess.run( - ["gh", "issue", "close", str(issue_number), - "--repo", f"{owner}/{repo}", - "--reason", reason], - capture_output=True - ) - - return result.returncode == 0 - - -def get_issue_details( - owner: str, - repo: str, - issue_number: int -) -> Issue | None: - """Get details of a specific issue. - - Args: - owner: Repository owner - repo: Repository name - issue_number: Issue number - - Returns: - Issue if found, None otherwise - """ - result = subprocess.run( - ["gh", "issue", "view", str(issue_number), - "--repo", f"{owner}/{repo}", - "--json", "number,title,state,labels,body,url"], - capture_output=True, - text=True - ) - - if result.returncode != 0: - return None - - data = json.loads(result.stdout) - return Issue( - number=data["number"], - title=data["title"], - state=data["state"], - labels=[l["name"] for l in data.get("labels", [])], - body=data.get("body"), - url=data.get("url") - ) +"""GitHub operations for skill reviewer. + +Re-exports from skill-agents-common shared library. +""" + +# Re-export everything from the shared library +import sys +from pathlib import Path + +# Add parent directory to path for shared library import +_agents_dir = Path(__file__).parent.parent.parent +if str(_agents_dir) not in sys.path: + sys.path.insert(0, str(_agents_dir)) + +from skill_agents_common.github_ops import ( + Issue, + PullRequest, + find_review_issues, + update_issue_labels, + add_issue_comment, + create_pull_request, + close_issue, + get_issue_details, + update_pr_from_issue, + mark_pr_ready, + get_pr_review_status, + request_rereview, +) + +__all__ = [ + "Issue", + "PullRequest", + "find_review_issues", + "update_issue_labels", + "add_issue_comment", + "create_pull_request", + "close_issue", + "get_issue_details", + "update_pr_from_issue", + "mark_pr_ready", + "get_pr_review_status", + "request_rereview", +] diff --git a/.claude/agents/skill-reviewer/src/models.py b/.claude/agents/skill-reviewer/src/models.py index b463409..0a131f6 100644 --- a/.claude/agents/skill-reviewer/src/models.py +++ b/.claude/agents/skill-reviewer/src/models.py @@ -1,215 +1,20 @@ -"""Data models for skill reviewer agent.""" +"""Data models for skill reviewer agent. -from dataclasses import dataclass, field -from datetime import datetime -from enum import Enum -from pathlib import Path -from typing import Any -import json - - -class Model(Enum): - """Available Claude models.""" - HAIKU_35 = "claude-3-5-haiku-20241022" - SONNET_4 = "claude-sonnet-4-20250514" - OPUS_45 = "claude-opus-4-5-20251101" - - @classmethod - def from_string(cls, s: str) -> "Model": - """Parse model from string, handling 'dynamic' specially.""" - if s == "dynamic": - return None # Orchestrator will determine - for model in cls: - if model.value == s: - return model - raise ValueError(f"Unknown model: {s}") - - -class Stage(Enum): - """Pipeline stages.""" - INIT = "init" - SETUP = "setup" # Deterministic: worktree, status, estimate - VALIDATION = "validation" # LLM: validator sub-agent - COMPLEXITY_ASSESSMENT = "complexity_assessment" # LLM: complexity-assessor - ANALYSIS = "analysis" # LLM: analyzer sub-agent - FIXING = "fixing" # LLM: fixer sub-agent - TEARDOWN = "teardown" # Deterministic: commit, push, PR, status - COMPLETE = "complete" - FAILED = "failed" - - -@dataclass -class SubagentConfig: - """Configuration for a sub-agent.""" - name: str - description: str - model: str # String to allow "dynamic" - allowed_tools: list[str] - included_skills: list[str] = field(default_factory=list) - max_input_tokens: int = 50000 - max_output_tokens: int = 10000 - output_format: str = "text" - output_schema: dict | None = None - - @classmethod - def load(cls, config_path: Path) -> "SubagentConfig": - """Load config from YAML file.""" - import yaml - with open(config_path) as f: - data = yaml.safe_load(f) - return cls(**data) - - def get_model(self, override: Model | None = None) -> Model: - """Get the model to use, with optional override.""" - if override: - return override - if self.model == "dynamic": - return Model.SONNET_4 # Default for dynamic - return Model.from_string(self.model) - - -@dataclass -class SubagentResult: - """Result from a sub-agent execution.""" - name: str - model: Model - output: str - exit_code: int - duration_seconds: float - subagent_id: str | None = None # UUIDv4 for tracking - input_tokens: int = 0 # If tracking available - output_tokens: int = 0 - parsed_output: dict | None = None - error: str | None = None - - @property - def success(self) -> bool: - return self.exit_code == 0 and self.error is None - - -@dataclass -class AgentSession: - """Shared context across all sub-agents in a pipeline run.""" - session_id: str - skill_path: str - issue_number: int - repo_owner: str = "aRustyDev" - repo_name: str = "ai" - - # Runtime state - stage: Stage = Stage.INIT - worktree_path: str | None = None - branch_name: str | None = None - - # Timestamps - started_at: str = field(default_factory=lambda: datetime.utcnow().isoformat()) - updated_at: str | None = None - completed_at: str | None = None - - # Accumulated results from sub-agents - results: dict[str, Any] = field(default_factory=dict) - - # Token tracking - total_input_tokens: int = 0 - total_output_tokens: int = 0 - estimated_cost_usd: float = 0.0 - - # Error tracking - errors: list[str] = field(default_factory=list) - - def update_stage(self, stage: Stage): - """Update the current stage and timestamp.""" - self.stage = stage - self.updated_at = datetime.utcnow().isoformat() - - def add_result(self, result: SubagentResult): - """Add a sub-agent result to the session.""" - self.results[result.name] = { - "output": result.output, - "parsed": result.parsed_output, - "model": result.model.value if result.model else None, - "subagent_id": result.subagent_id, - "duration": result.duration_seconds, - "success": result.success, - } - self.total_input_tokens += result.input_tokens - self.total_output_tokens += result.output_tokens - self._update_cost_estimate(result) - self.updated_at = datetime.utcnow().isoformat() - - def _update_cost_estimate(self, result: SubagentResult): - """Update estimated cost based on model and tokens.""" - if not result.model: - return - - # Pricing per million tokens - pricing = { - Model.HAIKU_35: (0.25, 1.25), - Model.SONNET_4: (3.0, 15.0), - Model.OPUS_45: (15.0, 75.0), - } - - if result.model in pricing: - input_rate, output_rate = pricing[result.model] - cost = (result.input_tokens * input_rate / 1_000_000 + - result.output_tokens * output_rate / 1_000_000) - self.estimated_cost_usd += cost - - def add_error(self, error: str): - """Record an error.""" - self.errors.append(f"[{datetime.utcnow().isoformat()}] {error}") - - def save(self, sessions_dir: Path): - """Save session to disk.""" - session_dir = sessions_dir / self.session_id - session_dir.mkdir(parents=True, exist_ok=True) - - with open(session_dir / "session.json", "w") as f: - json.dump(self._to_dict(), f, indent=2) - - def _to_dict(self) -> dict: - """Convert to dictionary for serialization.""" - return { - "session_id": self.session_id, - "skill_path": self.skill_path, - "issue_number": self.issue_number, - "repo_owner": self.repo_owner, - "repo_name": self.repo_name, - "stage": self.stage.value, - "worktree_path": self.worktree_path, - "branch_name": self.branch_name, - "started_at": self.started_at, - "updated_at": self.updated_at, - "completed_at": self.completed_at, - "results": self.results, - "total_input_tokens": self.total_input_tokens, - "total_output_tokens": self.total_output_tokens, - "estimated_cost_usd": self.estimated_cost_usd, - "errors": self.errors, - } - - @classmethod - def load(cls, session_file: Path) -> "AgentSession": - """Load session from disk.""" - with open(session_file) as f: - data = json.load(f) - - # Convert stage string back to enum - data["stage"] = Stage(data["stage"]) - - return cls(**data) - - def get_context_for_subagent(self) -> str: - """Generate context string for sub-agents.""" - return f"""## Session Context -- Session ID: {self.session_id} -- Skill: {self.skill_path} -- Issue: #{self.issue_number} -- Repository: {self.repo_owner}/{self.repo_name} -- Current Stage: {self.stage.value} -- Worktree: {self.worktree_path or 'Not created'} -- Branch: {self.branch_name or 'Not created'} - -## Previous Results -{json.dumps(self.results, indent=2)} +Re-exports from skill-agents-common shared library. """ + +from skill_agents_common.models import ( + Model, + Stage, + SubagentConfig, + SubagentResult, + AgentSession, +) + +__all__ = [ + "Model", + "Stage", + "SubagentConfig", + "SubagentResult", + "AgentSession", +] diff --git a/.claude/agents/skill-reviewer/src/orchestrator.py b/.claude/agents/skill-reviewer/src/orchestrator.py index 2170cbf..f9b5958 100644 --- a/.claude/agents/skill-reviewer/src/orchestrator.py +++ b/.claude/agents/skill-reviewer/src/orchestrator.py @@ -7,6 +7,7 @@ import re from datetime import datetime from pathlib import Path +from typing import Callable from .models import ( AgentSession, @@ -103,6 +104,7 @@ def run_subagent( "claude", "--model", model.value, "--print", + "--output-format", "json", # Get token usage in response "-p", full_prompt, ] @@ -133,19 +135,47 @@ def run_subagent( ) duration = time.time() - start_time - output = result.stdout + raw_output = result.stdout error = result.stderr if result.returncode != 0 else None - # Try to parse JSON output - parsed = self._extract_json(output) + # Parse JSON response from claude CLI + input_tokens = 0 + output_tokens = 0 + actual_cost = 0.0 + text_output = raw_output + parsed = None + + try: + response = json.loads(raw_output) + # Extract text result from JSON response + text_output = response.get("result", "") + + # Extract token usage + usage = response.get("usage", {}) + input_tokens = ( + usage.get("input_tokens", 0) + + usage.get("cache_creation_input_tokens", 0) + + usage.get("cache_read_input_tokens", 0) + ) + output_tokens = usage.get("output_tokens", 0) + actual_cost = response.get("total_cost_usd", 0.0) + + # Try to extract structured JSON from the result text + parsed = self._extract_json(text_output) + + except json.JSONDecodeError: + # Fallback: output wasn't JSON, use as-is + parsed = self._extract_json(raw_output) return SubagentResult( name=name, model=model, - output=output, + output=text_output, exit_code=result.returncode, duration_seconds=duration, subagent_id=headers.subagent_id, + input_tokens=input_tokens, + output_tokens=output_tokens, parsed_output=parsed, error=error ) @@ -201,13 +231,17 @@ def _create_pipeline_context(self, session: AgentSession) -> PipelineContext: def run_pipeline( self, session: AgentSession, - stages: list[Stage] | None = None + stages: list[Stage] | None = None, + progress_callback: Callable | None = None, + force_recreate: bool = False, ) -> AgentSession: """Run the full review pipeline. Args: session: Session to run pipeline for stages: Optional subset of stages to run + progress_callback: Optional callback(stage, message) for progress updates + force_recreate: If True, delete existing branch before creating Returns: Updated session @@ -222,14 +256,21 @@ def run_pipeline( ] stages_to_run = stages or all_stages + total_stages = len(stages_to_run) + + def emit_progress(stage: Stage, message: str, step: int = 0): + if progress_callback: + progress_callback(stage, message, step, total_stages) # Create pipeline context ctx = self._create_pipeline_context(session) try: - for stage in stages_to_run: + for i, stage in enumerate(stages_to_run, 1): + emit_progress(stage, f"Starting {stage.value}...", i) + if stage == Stage.SETUP: - self._run_setup(session, ctx) + self._run_setup(session, ctx, force_recreate) elif stage == Stage.VALIDATION: self._run_validation(session) elif stage == Stage.COMPLEXITY_ASSESSMENT: @@ -241,6 +282,12 @@ def run_pipeline( elif stage == Stage.TEARDOWN: self._run_teardown(session, ctx) + # Emit completion for this stage + if session.stage == Stage.FAILED: + emit_progress(stage, f"Failed at {stage.value}", i) + else: + emit_progress(stage, f"Completed {stage.value}", i) + # Save after each stage session.save(self.sessions_dir) @@ -251,20 +298,27 @@ def run_pipeline( if session.stage != Stage.FAILED: session.update_stage(Stage.COMPLETE) session.save(self.sessions_dir) + emit_progress(Stage.COMPLETE, "Pipeline complete", total_stages) except Exception as e: session.add_error(f"Pipeline failed: {e}") session.update_stage(Stage.FAILED) session.save(self.sessions_dir) + emit_progress(Stage.FAILED, f"Pipeline error: {e}", 0) return session - def _run_setup(self, session: AgentSession, ctx: PipelineContext): + def _run_setup( + self, + session: AgentSession, + ctx: PipelineContext, + force_recreate: bool = False + ): """Run deterministic setup (worktree, status update, token estimate).""" session.update_stage(Stage.SETUP) # Run deterministic setup - success = self.pipeline.setup(ctx) + success = self.pipeline.setup(ctx, force_recreate=force_recreate) if not success: for error in ctx.errors or []: @@ -411,6 +465,7 @@ def _run_teardown(self, session: AgentSession, ctx: PipelineContext): if not success: for error in ctx.errors or []: session.add_error(error) + session.update_stage(Stage.FAILED) # Update session with PR info if ctx.pr_url: diff --git a/.claude/agents/skill-reviewer/src/parse.py b/.claude/agents/skill-reviewer/src/parse.py index 20b3b8e..2ed5c08 100644 --- a/.claude/agents/skill-reviewer/src/parse.py +++ b/.claude/agents/skill-reviewer/src/parse.py @@ -60,6 +60,10 @@ def parse_args(): parser.add_argument( "--cleanup", action="store_true", help="Clean up worktree after completion" ) + parser.add_argument( + "--force", "-f", action="store_true", + help="Force recreate branch if it already exists (deletes existing branch)" + ) parser.add_argument("--config", help="Path to config file") parser.add_argument("--verbose", "-v", action="store_true", help="Verbose output") diff --git a/.claude/agents/skill-reviewer/src/pipeline.py b/.claude/agents/skill-reviewer/src/pipeline.py index 94d257b..a9b84f1 100644 --- a/.claude/agents/skill-reviewer/src/pipeline.py +++ b/.claude/agents/skill-reviewer/src/pipeline.py @@ -18,6 +18,7 @@ - Applying fixes (generating content) """ +import re import subprocess from dataclasses import dataclass from datetime import datetime @@ -29,6 +30,8 @@ Issue, get_issue_details, add_issue_comment, + update_pr_from_issue, + mark_pr_ready, ) from .github_projects import ( get_project_id, @@ -46,6 +49,7 @@ remove_worktree, get_worktree_status, WorktreeInfo, + BranchExistsError, ) @@ -258,11 +262,16 @@ def _set_status_via_labels(self, ctx: PipelineContext, label: str) -> bool: # Worktree Management # ========================================================================= - def create_worktree(self, ctx: PipelineContext) -> bool: + def create_worktree( + self, + ctx: PipelineContext, + force_recreate: bool = False + ) -> bool: """Create worktree for the review. Args: ctx: Pipeline context + force_recreate: If True, delete existing branch before creating Returns: True if successful @@ -277,13 +286,24 @@ def create_worktree(self, ctx: PipelineContext) -> bool: worktree_base=Path(self.config.worktree_base), branch_name=branch_name, base_branch=self.config.base_branch, - identifier=identifier + identifier=identifier, + force_recreate=force_recreate ) ctx.worktree = worktree ctx.branch_name = branch_name return True + except BranchExistsError as e: + ctx.add_error( + f"Branch '{e.branch_name}' already exists" + + (" (pushed to remote)" if e.has_remote else "") + + ". Use --force to recreate, or manually delete with: " + + f"git branch -D {e.branch_name}" + + (f" && git push origin --delete {e.branch_name}" if e.has_remote else "") + ) + return False + except subprocess.CalledProcessError as e: ctx.add_error(f"Failed to create worktree: {e}") return False @@ -514,6 +534,7 @@ def create_draft_pr( if f"#{ctx.issue_number}" not in body: body += f"\n\nCloses #{ctx.issue_number}" + # Create PR (gh pr create outputs the PR URL on success) result = subprocess.run( ["gh", "pr", "create", "--repo", f"{self.config.repo_owner}/{self.config.repo_name}", @@ -521,8 +542,7 @@ def create_draft_pr( "--body", body, "--head", ctx.branch_name, "--base", self.config.base_branch, - "--draft", - "--json", "number,url"], + "--draft"], capture_output=True, text=True ) @@ -531,18 +551,24 @@ def create_draft_pr( ctx.add_error(f"Failed to create PR: {result.stderr}") return False - import json - data = json.loads(result.stdout) - ctx.pr_number = data["number"] - ctx.pr_url = data["url"] - - return True + # Parse the URL from stdout (gh pr create prints the URL) + pr_url = result.stdout.strip() + if pr_url and "github.com" in pr_url: + ctx.pr_url = pr_url + # Extract PR number from URL (e.g., https://github.com/owner/repo/pull/123) + match = re.search(r'/pull/(\d+)', pr_url) + if match: + ctx.pr_number = int(match.group(1)) + return True + else: + ctx.add_error(f"Unexpected PR creation output: {result.stdout}") + return False # ========================================================================= # Full Deterministic Setup # ========================================================================= - def setup(self, ctx: PipelineContext) -> bool: + def setup(self, ctx: PipelineContext, force_recreate: bool = False) -> bool: """Run all deterministic setup steps. This includes: @@ -555,6 +581,7 @@ def setup(self, ctx: PipelineContext) -> bool: Args: ctx: Pipeline context + force_recreate: If True, delete existing branch before creating Returns: True if all steps succeeded @@ -579,7 +606,7 @@ def setup(self, ctx: PipelineContext) -> bool: return False # 4. Create worktree - if not self.create_worktree(ctx): + if not self.create_worktree(ctx, force_recreate=force_recreate): return False # 5. Set issue to in-progress @@ -608,7 +635,11 @@ def teardown( 3. Create draft PR 4. Set issue to in-review 5. Post complete comment - 6. Optionally cleanup worktree + + NOTE: Worktrees are intentionally NOT cleaned up automatically. + They are preserved for skill-pr-addresser to iterate on PR feedback. + Use `just clean-worktree ` or `just clean-worktrees` to + manually clean up after PR is merged. Args: ctx: Pipeline context @@ -619,6 +650,7 @@ def teardown( True if all steps succeeded """ ctx.completed_at = datetime.utcnow() + teardown_success = True if not success: # Just post error comment and cleanup @@ -644,13 +676,17 @@ def teardown( }) if not self.config.dry_run: - self.commit_changes(ctx, commit_msg) + if not self.commit_changes(ctx, commit_msg): + ctx.add_error("Failed to commit changes") + teardown_success = False - # 2. Push branch - if not self.config.dry_run: - self.push_branch(ctx) + # 2. Push branch (only if commit succeeded) + if not self.config.dry_run and teardown_success: + if not self.push_branch(ctx): + ctx.add_error("Failed to push branch") + teardown_success = False - # 3. Create draft PR + # 3. Create draft PR (only if push succeeded) pr_body = render_inline("pr_body", { "summary": results.get("summary", ["Improved skill documentation"]), "added": results.get("added", []), @@ -658,19 +694,46 @@ def teardown( "issues": [ctx.issue_number], }) - if not self.config.dry_run: - self.create_draft_pr( + if not self.config.dry_run and teardown_success: + if not self.create_draft_pr( ctx, f"feat({Path(ctx.skill_path).name}): {results.get('description', 'improve documentation')}", pr_body - ) + ): + # PR creation failed - log error but continue to post comment + ctx.add_error("Failed to create PR") + teardown_success = False + + # 3b. Copy labels, milestone, and project from issue to PR + if not self.config.dry_run and ctx.pr_number and ctx.issue: + if not update_pr_from_issue( + self.config.repo_owner, + self.config.repo_name, + ctx.pr_number, + ctx.issue + ): + ctx.add_error("Failed to copy issue properties to PR") + # Non-fatal, continue - # 4. Set issue to in-review - if not self.config.dry_run: + # 4. Set issue to in-review (only if PR created) + if not self.config.dry_run and ctx.pr_url: self.set_issue_in_review(ctx) - # 5. Post complete comment + # 5. Mark PR as ready for review (if teardown succeeded) + if not self.config.dry_run and ctx.pr_number and teardown_success: + if not mark_pr_ready( + self.config.repo_owner, + self.config.repo_name, + ctx.pr_number + ): + ctx.add_error("Failed to mark PR as ready") + # Non-fatal, continue + + # 6. Post complete comment (always, with status) if not self.config.dry_run: + # Include errors in results if any + if ctx.errors: + results["teardown_errors"] = ctx.errors self.post_complete_comment(ctx, results) - return True + return teardown_success diff --git a/.claude/agents/skill-reviewer/src/review.py b/.claude/agents/skill-reviewer/src/review.py index 1790065..b785529 100644 --- a/.claude/agents/skill-reviewer/src/review.py +++ b/.claude/agents/skill-reviewer/src/review.py @@ -209,6 +209,49 @@ def _review_with_tracking( raise +def _create_progress_callback(verbose: bool): + """Create a progress callback for the pipeline. + + Args: + verbose: If True, print detailed progress + + Returns: + Callback function or None + """ + if not verbose: + return None + + import sys + from datetime import datetime + + stage_start_times = {} + + def callback(stage, message: str, step: int, total: int): + now = datetime.now() + + # Track timing + if message.startswith("Starting"): + stage_start_times[stage] = now + elapsed = "" + elif message.startswith("Completed") and stage in stage_start_times: + delta = now - stage_start_times[stage] + elapsed = f" ({delta.total_seconds():.1f}s)" + else: + elapsed = "" + + # Progress bar + progress = f"[{step}/{total}]" if total > 0 else "" + + # Format output + timestamp = now.strftime("%H:%M:%S") + print(f"{timestamp} {progress} {message}{elapsed}", flush=True) + + # Force flush for real-time output + sys.stdout.flush() + + return callback + + def review_single_skill( orchestrator: Orchestrator, skill_path: str, @@ -216,6 +259,7 @@ def review_single_skill( stages: list[Stage] | None = None, verbose: bool = False, session_id: str | None = None, + force_recreate: bool = False, ) -> AgentSession: """Review a single skill. @@ -226,6 +270,7 @@ def review_single_skill( stages: Optional subset of stages to run verbose: Print verbose output session_id: Optional session ID (generated if not provided) + force_recreate: If True, delete existing branch before creating Returns: AgentSession with results @@ -242,8 +287,14 @@ def review_single_skill( print(f"Starting session {session.session_id}") print(f" Skill: {skill_path}") print(f" Issue: #{issue_number}") + print() - result = orchestrator.run_pipeline(session, stages) + # Create progress callback + progress_callback = _create_progress_callback(verbose) + + result = orchestrator.run_pipeline( + session, stages, progress_callback, force_recreate=force_recreate + ) if verbose: print(f"\nSession {session.session_id} complete") diff --git a/.claude/agents/skill-reviewer/src/session.py b/.claude/agents/skill-reviewer/src/session.py index 7b3da56..edfa4d5 100644 --- a/.claude/agents/skill-reviewer/src/session.py +++ b/.claude/agents/skill-reviewer/src/session.py @@ -1,7 +1,27 @@ +"""Session management for skill-reviewer agent. + +Combines agent-specific functions with shared utilities from skill-agents-common. +""" + import json import sys -import uuid - +from pathlib import Path + +# Add parent directory to path for shared library import +_agents_dir = Path(__file__).parent.parent.parent +if str(_agents_dir) not in sys.path: + sys.path.insert(0, str(_agents_dir)) + +# Re-export shared utilities +from skill_agents_common.session import ( + generate_session_id, + find_session_by_issue, + find_session_by_pr, + extract_linked_issues, + create_session_from_pr, +) + +# Local imports from .models import AgentSession, Stage from .orchestrator import Orchestrator @@ -30,13 +50,8 @@ def resume_session( return result -def generate_session_id() -> str: - """Generate a short unique session ID.""" - return str(uuid.uuid4())[:8] - - def list_sessions(orchestrator: Orchestrator): - """List all sessions.""" + """List all sessions with formatted output.""" sessions_dir = orchestrator.sessions_dir if not sessions_dir.exists(): @@ -66,3 +81,16 @@ def list_sessions(orchestrator: Orchestrator): print( f"{s['session_id']:<10} {s['stage']:<20} {s['skill_path']:<40} ${s['estimated_cost_usd']:.4f}" ) + + +__all__ = [ + # Shared utilities + "generate_session_id", + "find_session_by_issue", + "find_session_by_pr", + "extract_linked_issues", + "create_session_from_pr", + # Agent-specific functions + "resume_session", + "list_sessions", +] diff --git a/.claude/agents/skill-reviewer/src/worktree.py b/.claude/agents/skill-reviewer/src/worktree.py index 80f89d9..f055e3f 100644 --- a/.claude/agents/skill-reviewer/src/worktree.py +++ b/.claude/agents/skill-reviewer/src/worktree.py @@ -1,279 +1,44 @@ """Git worktree management for isolated skill reviews. -Worktree Pattern: - /private/tmp/worktrees/// - -Example: - /private/tmp/worktrees/abc123-def4-5678/fix-lang-rust-dev-456/ +Re-exports from skill-agents-common shared library. """ -import subprocess -import shutil +import sys from pathlib import Path -from dataclasses import dataclass - - -@dataclass -class WorktreeInfo: - """Information about a git worktree.""" - path: Path - branch: str - commit: str | None = None - project_id: str | None = None - - -def get_project_id(repo_path: Path) -> str | None: - """Get the project ID from git config or notes. - - Checks in order: - 1. git config project.id - 2. git notes refs/notes/project.id HEAD - - Args: - repo_path: Path to the repository - - Returns: - Project ID or None - """ - # Try git config first - result = subprocess.run( - ["git", "config", "--get", "project.id"], - cwd=repo_path, - capture_output=True, - text=True - ) - if result.returncode == 0 and result.stdout.strip(): - return result.stdout.strip() - - # Try git notes - result = subprocess.run( - ["git", "notes", "--ref=project.id", "show", "HEAD"], - cwd=repo_path, - capture_output=True, - text=True - ) - if result.returncode == 0 and result.stdout.strip(): - return result.stdout.strip() - - return None - - -def set_project_id(repo_path: Path, project_id: str) -> bool: - """Set the project ID in git config. - - Args: - repo_path: Path to the repository - project_id: Project ID to set - - Returns: - True if successful - """ - result = subprocess.run( - ["git", "config", "project.id", project_id], - cwd=repo_path, - capture_output=True - ) - return result.returncode == 0 - - -def get_worktree_path( - worktree_base: Path, - project_id: str | None, - identifier: str -) -> Path: - """Get the worktree path following the new pattern. - - Pattern: /// - - Args: - worktree_base: Base directory for worktrees - project_id: Project UUID (or 'default' if not set) - identifier: Issue/branch identifier - - Returns: - Full path for the worktree - """ - project_dir = project_id or "default" - # Sanitize identifier for filesystem - safe_id = identifier.replace("/", "-").replace(" ", "-") - return worktree_base / project_dir / safe_id - - -def create_worktree( - repo_path: Path, - worktree_base: Path, - branch_name: str, - base_branch: str = "main", - identifier: str | None = None -) -> WorktreeInfo: - """Create a new git worktree for isolated work. - - Args: - repo_path: Path to the main repository - worktree_base: Base directory for worktrees - branch_name: Name of the new branch - base_branch: Branch to base the new branch on - identifier: Optional identifier for worktree path (defaults to branch_name) - - Returns: - WorktreeInfo with path and branch details - """ - # Get project ID for the new worktree pattern - project_id = get_project_id(repo_path) - - # Determine identifier - worktree_id = identifier or branch_name - - # Build worktree path: /base/// - worktree_path = get_worktree_path(worktree_base, project_id, worktree_id) - - # Ensure worktree base exists - worktree_base.mkdir(parents=True, exist_ok=True) - - # Remove existing worktree if present - if worktree_path.exists(): - remove_worktree(repo_path, worktree_path) - - # Fetch latest from origin - subprocess.run( - ["git", "fetch", "origin"], - cwd=repo_path, - check=True, - capture_output=True - ) - - # Create worktree with new branch - result = subprocess.run( - ["git", "worktree", "add", "-b", branch_name, str(worktree_path), f"origin/{base_branch}"], - cwd=repo_path, - check=True, - capture_output=True, - text=True - ) - - # Get current commit - commit_result = subprocess.run( - ["git", "rev-parse", "HEAD"], - cwd=worktree_path, - capture_output=True, - text=True - ) - commit = commit_result.stdout.strip() if commit_result.returncode == 0 else None - - return WorktreeInfo( - path=worktree_path, - branch=branch_name, - commit=commit, - project_id=project_id - ) - - -def remove_worktree(repo_path: Path, worktree_path: Path): - """Remove a git worktree. - - Args: - repo_path: Path to the main repository - worktree_path: Path to the worktree to remove - """ - # First try git worktree remove - result = subprocess.run( - ["git", "worktree", "remove", "--force", str(worktree_path)], - cwd=repo_path, - capture_output=True - ) - - # If that fails, manually clean up - if result.returncode != 0 and worktree_path.exists(): - shutil.rmtree(worktree_path, ignore_errors=True) - - # Prune worktree references - subprocess.run( - ["git", "worktree", "prune"], - cwd=repo_path, - capture_output=True - ) - - -def list_worktrees(repo_path: Path) -> list[WorktreeInfo]: - """List all worktrees for a repository. - - Args: - repo_path: Path to the main repository - - Returns: - List of WorktreeInfo for each worktree - """ - result = subprocess.run( - ["git", "worktree", "list", "--porcelain"], - cwd=repo_path, - capture_output=True, - text=True - ) - - if result.returncode != 0: - return [] - - worktrees = [] - current = {} - - for line in result.stdout.strip().split("\n"): - if not line: - if current: - worktrees.append(WorktreeInfo( - path=Path(current.get("worktree", "")), - branch=current.get("branch", "").replace("refs/heads/", ""), - commit=current.get("HEAD") - )) - current = {} - elif line.startswith("worktree "): - current["worktree"] = line[9:] - elif line.startswith("HEAD "): - current["HEAD"] = line[5:] - elif line.startswith("branch "): - current["branch"] = line[7:] - - # Don't forget the last one - if current: - worktrees.append(WorktreeInfo( - path=Path(current.get("worktree", "")), - branch=current.get("branch", "").replace("refs/heads/", ""), - commit=current.get("HEAD") - )) - - return worktrees - - -def get_worktree_status(worktree_path: Path) -> dict: - """Get the git status of a worktree. - - Args: - worktree_path: Path to the worktree - - Returns: - Dict with status information - """ - # Get status - status_result = subprocess.run( - ["git", "status", "--porcelain"], - cwd=worktree_path, - capture_output=True, - text=True - ) - - # Get diff stats - diff_result = subprocess.run( - ["git", "diff", "--stat", "HEAD"], - cwd=worktree_path, - capture_output=True, - text=True - ) - - # Count changes - lines = status_result.stdout.strip().split("\n") if status_result.stdout.strip() else [] - return { - "files_changed": len([l for l in lines if l]), - "status_lines": lines, - "diff_stat": diff_result.stdout.strip() if diff_result.returncode == 0 else "", - "clean": len(lines) == 0 or (len(lines) == 1 and lines[0] == "") - } +# Add parent directory to path for shared library import +_agents_dir = Path(__file__).parent.parent.parent +if str(_agents_dir) not in sys.path: + sys.path.insert(0, str(_agents_dir)) + +from skill_agents_common.worktree import ( + WorktreeInfo, + WorktreeError, + BranchExistsError, + get_project_id, + set_project_id, + get_worktree_path, + branch_exists, + delete_branch, + create_worktree, + remove_worktree, + list_worktrees, + get_worktree_status, + get_or_create_worktree, +) + +__all__ = [ + "WorktreeInfo", + "WorktreeError", + "BranchExistsError", + "get_project_id", + "set_project_id", + "get_worktree_path", + "branch_exists", + "delete_branch", + "create_worktree", + "remove_worktree", + "list_worktrees", + "get_worktree_status", + "get_or_create_worktree", +] diff --git a/.claude/agents/skill-reviewer/uv.lock b/.claude/agents/skill-reviewer/uv.lock new file mode 100644 index 0000000..7c97581 --- /dev/null +++ b/.claude/agents/skill-reviewer/uv.lock @@ -0,0 +1,209 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "chevron" +version = "0.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/1f/ca74b65b19798895d63a6e92874162f44233467c9e7c1ed8afd19016ebe9/chevron-0.14.0.tar.gz", hash = "sha256:87613aafdf6d77b6a90ff073165a61ae5086e21ad49057aa0e53681601800ebf", size = 11440, upload-time = "2021-01-02T22:47:59.233Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/93/342cc62a70ab727e093ed98e02a725d85b746345f05d2b5e5034649f4ec8/chevron-0.14.0-py3-none-any.whl", hash = "sha256:fbf996a709f8da2e745ef763f482ce2d311aa817d287593a5b990d6d6e4f0443", size = 11595, upload-time = "2021-01-02T22:47:57.847Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +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 = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" }, + { url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" }, + { url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" }, + { url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" }, + { url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" }, + { url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" }, + { url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" }, + { url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" }, + { url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" }, + { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, +] + +[[package]] +name = "skill-agents-common" +version = "0.1.0" +source = { editable = "../skill-agents-common" } + +[[package]] +name = "skill-reviewer" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "chevron" }, + { name = "pyyaml" }, + { name = "skill-agents-common" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "chevron", specifier = ">=0.14.0" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0.0" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.23.0" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.2.0" }, + { name = "skill-agents-common", editable = "../skill-agents-common" }, +] +provides-extras = ["dev"] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +]