Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29d6c8cb14 | |||
| 01295eff99 | |||
| a41e5a0eba | |||
| 5ed85e86ab | |||
| 071379a109 |
+54
-40
@@ -1,36 +1,52 @@
|
||||
#!/bin/sh
|
||||
|
||||
# =============================================================================
|
||||
# GIT WORKTREE ENVIRONMENT CLEANUP
|
||||
# GIT WORKTREE CONTEXT HANDLING
|
||||
# =============================================================================
|
||||
# Git automatically sets GIT_DIR (and CWD to the working tree root) before
|
||||
# running hooks -- even in worktrees. We do NOT need to manually parse .git
|
||||
# files or export GIT_DIR/GIT_WORK_TREE.
|
||||
# When running in a worktree, we need to preserve git context to prevent HEAD
|
||||
# corruption. However, we must also CLEAR these variables when NOT in a worktree
|
||||
# to prevent cross-worktree contamination (files leaking between worktrees).
|
||||
#
|
||||
# However, external tools (IDEs, agents, parent shells) may leave stale
|
||||
# GIT_DIR/GIT_WORK_TREE values in the environment. If these point to a
|
||||
# different repo or worktree, git commands in this hook would target the
|
||||
# wrong repository. Unsetting them lets git re-resolve the correct values
|
||||
# from the working directory.
|
||||
# The bug: If GIT_DIR/GIT_WORK_TREE are set from a previous worktree session
|
||||
# and this hook runs in the main repo (where .git is a directory, not a file),
|
||||
# git commands will target the wrong repository, causing files to appear as
|
||||
# untracked in the wrong location.
|
||||
#
|
||||
# Fix: Explicitly unset these variables when NOT in a worktree context.
|
||||
# =============================================================================
|
||||
unset GIT_DIR
|
||||
unset GIT_WORK_TREE
|
||||
|
||||
if [ -f ".git" ]; then
|
||||
# We're in a worktree (.git is a file pointing to the actual git dir)
|
||||
# Use -n with /p to only print lines that match the gitdir: prefix, head -1 for safety
|
||||
WORKTREE_GIT_DIR=$(sed -n 's/^gitdir: //p' .git | head -1)
|
||||
if [ -n "$WORKTREE_GIT_DIR" ] && [ -d "$WORKTREE_GIT_DIR" ]; then
|
||||
export GIT_DIR="$WORKTREE_GIT_DIR"
|
||||
export GIT_WORK_TREE="$(pwd)"
|
||||
else
|
||||
# .git file exists but is malformed or points to non-existent directory
|
||||
# CRITICAL: Clear any inherited GIT_DIR/GIT_WORK_TREE to prevent cross-worktree leakage
|
||||
unset GIT_DIR
|
||||
unset GIT_WORK_TREE
|
||||
fi
|
||||
else
|
||||
# We're in the main repo (.git is a directory)
|
||||
# CRITICAL: Clear any inherited GIT_DIR/GIT_WORK_TREE to prevent cross-worktree leakage
|
||||
unset GIT_DIR
|
||||
unset GIT_WORK_TREE
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# SAFETY CHECK: Detect and fix corrupted core.worktree configuration
|
||||
# =============================================================================
|
||||
# core.worktree lives in the SHARED .git/config (not per-worktree). If any
|
||||
# process accidentally writes it (e.g., running `git init` with a leaked
|
||||
# GIT_WORK_TREE), ALL repos and worktrees see the wrong working tree root,
|
||||
# causing files from one worktree to "leak" into others.
|
||||
#
|
||||
# This check runs from both main repo and worktree contexts since the config
|
||||
# is shared and corruption can happen from either.
|
||||
CORE_WORKTREE=$(git config --get core.worktree 2>/dev/null || true)
|
||||
if [ -n "$CORE_WORKTREE" ]; then
|
||||
echo "Warning: Detected corrupted core.worktree setting ('$CORE_WORKTREE'), removing it..."
|
||||
if ! git config --unset core.worktree 2>/dev/null; then
|
||||
echo "Warning: Failed to unset core.worktree. Manual intervention may be needed."
|
||||
# If core.worktree is set in the main repo's config (pointing to a worktree),
|
||||
# this indicates previous corruption. Fix it automatically.
|
||||
if [ ! -f ".git" ]; then
|
||||
CORE_WORKTREE=$(git config --get core.worktree 2>/dev/null || true)
|
||||
if [ -n "$CORE_WORKTREE" ]; then
|
||||
echo "Warning: Detected corrupted core.worktree setting, removing it..."
|
||||
if ! git config --unset core.worktree 2>/dev/null; then
|
||||
echo "Warning: Failed to unset core.worktree. Manual intervention may be needed."
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -162,39 +178,37 @@ if git diff --cached --name-only | grep -q "^apps/backend/.*\.py$"; then
|
||||
fi
|
||||
|
||||
# Run pytest (skip slow/integration tests and Windows-incompatible tests for pre-commit speed)
|
||||
# Run from repo root (not apps/backend) so tests that use Path.resolve() get correct CWD.
|
||||
# PYTHONPATH includes apps/backend so imports resolve correctly.
|
||||
# Use subshell to isolate directory changes and prevent worktree corruption
|
||||
echo "Running Python tests..."
|
||||
(
|
||||
cd apps/backend
|
||||
# Tests to skip: graphiti (external deps), merge_file_tracker/service_orchestrator/worktree/workspace (Windows path/git issues)
|
||||
# Also skip tests that require optional dependencies (pydantic structured outputs)
|
||||
# Also skip gitlab_e2e (e2e test sensitive to test-ordering env contamination, validated by CI)
|
||||
IGNORE_TESTS="--ignore=tests/test_graphiti.py --ignore=tests/test_merge_file_tracker.py --ignore=tests/test_service_orchestrator.py --ignore=tests/test_worktree.py --ignore=tests/test_workspace.py --ignore=tests/test_finding_validation.py --ignore=tests/test_sdk_structured_output.py --ignore=tests/test_structured_outputs.py --ignore=tests/test_gitlab_e2e.py"
|
||||
IGNORE_TESTS="--ignore=../../tests/test_graphiti.py --ignore=../../tests/test_merge_file_tracker.py --ignore=../../tests/test_service_orchestrator.py --ignore=../../tests/test_worktree.py --ignore=../../tests/test_workspace.py --ignore=../../tests/test_finding_validation.py --ignore=../../tests/test_sdk_structured_output.py --ignore=../../tests/test_structured_outputs.py"
|
||||
|
||||
# Determine Python executable from venv
|
||||
VENV_PYTHON=""
|
||||
if [ -f "apps/backend/.venv/bin/python" ]; then
|
||||
VENV_PYTHON="apps/backend/.venv/bin/python"
|
||||
elif [ -f "apps/backend/.venv/Scripts/python.exe" ]; then
|
||||
VENV_PYTHON="apps/backend/.venv/Scripts/python.exe"
|
||||
if [ -f ".venv/bin/python" ]; then
|
||||
VENV_PYTHON=".venv/bin/python"
|
||||
elif [ -f ".venv/Scripts/python.exe" ]; then
|
||||
VENV_PYTHON=".venv/Scripts/python.exe"
|
||||
fi
|
||||
|
||||
# -k "not windows_path": skip tests using fake Windows paths that break
|
||||
# Path.resolve() on macOS/Linux. These are validated by CI on all platforms.
|
||||
|
||||
if [ -n "$VENV_PYTHON" ]; then
|
||||
# Check if pytest is installed in venv
|
||||
if $VENV_PYTHON -c "import pytest" 2>/dev/null; then
|
||||
PYTHONPATH=apps/backend $VENV_PYTHON -m pytest tests/ -v --tb=short -x -m "not slow and not integration" -k "not windows_path" $IGNORE_TESTS
|
||||
PYTHONPATH=. $VENV_PYTHON -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
|
||||
else
|
||||
echo "Warning: pytest not installed in venv. Installing test dependencies..."
|
||||
$VENV_PYTHON -m pip install -q -r tests/requirements-test.txt
|
||||
PYTHONPATH=apps/backend $VENV_PYTHON -m pytest tests/ -v --tb=short -x -m "not slow and not integration" -k "not windows_path" $IGNORE_TESTS
|
||||
$VENV_PYTHON -m pip install -q -r ../../tests/requirements-test.txt
|
||||
PYTHONPATH=. $VENV_PYTHON -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
|
||||
fi
|
||||
elif [ -d "apps/backend/.venv" ]; then
|
||||
elif [ -d ".venv" ]; then
|
||||
echo "Warning: venv exists but Python not found in it, using system Python"
|
||||
PYTHONPATH=apps/backend python -m pytest tests/ -v --tb=short -x -m "not slow and not integration" -k "not windows_path" $IGNORE_TESTS
|
||||
PYTHONPATH=. python -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
|
||||
else
|
||||
echo "Warning: No .venv found in apps/backend, using system Python"
|
||||
PYTHONPATH=apps/backend python -m pytest tests/ -v --tb=short -x -m "not slow and not integration" -k "not windows_path" $IGNORE_TESTS
|
||||
PYTHONPATH=. python -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
|
||||
fi
|
||||
)
|
||||
if [ $? -ne 0 ]; then
|
||||
|
||||
@@ -74,8 +74,6 @@
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Fixed task logs disappearing after app restart in development mode (issue #1657)
|
||||
|
||||
- Fixed Kanban board status flip-flopping and multi-location task deletion
|
||||
|
||||
- Fixed Windows CLI detection and version selection UX issues
|
||||
|
||||
@@ -40,6 +40,17 @@ Auto Claude is a desktop application (+ CLI) where users describe a goal and AI
|
||||
|
||||
**PR target** — Always target the `develop` branch for PRs to AndyMik90/Auto-Claude, NOT `main`.
|
||||
|
||||
## Memory (claude-mem)
|
||||
|
||||
This project uses claude-mem for persistent memory across sessions. MCP search tools are available — use them proactively:
|
||||
|
||||
- **Before modifying code** — Search for past work on the same files/features: `search(query="<file or feature>")`. Check if there are known bugs, decisions, or patterns to follow.
|
||||
- **When debugging** — Search for prior encounters with the same error or symptom: `search(query="<error message>", type="bugfix")`.
|
||||
- **When making architectural decisions** — Check for past decisions: `search(query="<topic>", type="decision")`.
|
||||
- **When resuming work** — Use `timeline(anchor=<recent_id>)` to understand where things left off.
|
||||
|
||||
Follow the 3-layer workflow: `search` (cheap index) → `timeline` (context) → `get_observations` (full details only for relevant IDs). Never fetch full details without filtering first.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
# Test Optimization Plan
|
||||
|
||||
## Problem Summary
|
||||
- 3 test directories timeout after 600 seconds
|
||||
- Main culprit: `test_spec_pipeline_orchestrator.py` - integration tests that run real code
|
||||
- Autouse fixture adding overhead to every test
|
||||
|
||||
## Optimization Strategies
|
||||
|
||||
### 1. Use Pytest Markers (Immediate Impact)
|
||||
|
||||
Already defined in `pytest.ini`:
|
||||
```ini
|
||||
markers =
|
||||
slow: marks tests as slow (deselect with '-m "not slow"')
|
||||
integration: marks tests as integration tests
|
||||
```
|
||||
|
||||
**Action:** Mark slow integration tests:
|
||||
|
||||
```python
|
||||
@pytest.mark.slow
|
||||
@pytest.mark.integration
|
||||
async def test_run_with_auto_approve(self, tmp_path):
|
||||
...
|
||||
```
|
||||
|
||||
**Run fast tests only:**
|
||||
```bash
|
||||
pytest -m "not slow" -v
|
||||
pytest -m "not integration" -v
|
||||
```
|
||||
|
||||
**Run all tests when needed:**
|
||||
```bash
|
||||
pytest -v # or explicitly: pytest -m "slow or integration" -v
|
||||
```
|
||||
|
||||
### 2. Fix Autouse Fixture Overhead
|
||||
|
||||
The `ensure_modules_not_mocked()` fixture runs before EVERY test.
|
||||
|
||||
**Action:** Remove autouse or make it conditional:
|
||||
|
||||
```python
|
||||
@pytest.fixture(autouse=True, scope="function")
|
||||
def ensure_modules_not_mocked():
|
||||
"""Only run for tests that need real claude_agent_sdk"""
|
||||
# Skip for tests that explicitly mock everything
|
||||
if os.environ.get("PYTEST_SKIP_MODULE_CLEANUP"):
|
||||
return
|
||||
# ... existing code ...
|
||||
```
|
||||
|
||||
### 3. Separate Unit and Integration Tests
|
||||
|
||||
**Directory structure:**
|
||||
```
|
||||
tests/
|
||||
├── unit/ # Fast tests (seconds)
|
||||
│ ├── test_*.py
|
||||
├── integration/ # Slow tests (minutes)
|
||||
│ ├── test_*.py
|
||||
└── e2e/ # Very slow tests (10+ minutes)
|
||||
├── test_*.py
|
||||
```
|
||||
|
||||
**pytest.ini:**
|
||||
```ini
|
||||
[pytest]
|
||||
testpaths = tests/unit # Default to fast tests
|
||||
# Override with: pytest --testpaths=integration
|
||||
```
|
||||
|
||||
### 4. Optimize Async Tests
|
||||
|
||||
Async tests have overhead. Use sync tests where possible:
|
||||
|
||||
```python
|
||||
# SLOW (async overhead)
|
||||
@pytest.mark.asyncio
|
||||
async def test_something():
|
||||
result = await some_async_function()
|
||||
assert result
|
||||
|
||||
# FASTER (mock the async call)
|
||||
def test_something():
|
||||
with patch('module.some_async_function', return_value=expected):
|
||||
result = some_function_that_calls_async()
|
||||
assert result
|
||||
```
|
||||
|
||||
### 5. Reduce Test Data Creation
|
||||
|
||||
Many tests create large temporary directories. Use shared fixtures:
|
||||
|
||||
```python
|
||||
@pytest.fixture(scope="session")
|
||||
def shared_test_data():
|
||||
"""Create test data once per session, not per test"""
|
||||
data = create_expensive_test_data()
|
||||
yield data
|
||||
# cleanup runs at session end
|
||||
```
|
||||
|
||||
### 6. Disable Coverage by Default
|
||||
|
||||
Coverage collection adds overhead.
|
||||
|
||||
**pytest.ini:**
|
||||
```ini
|
||||
[pytest]
|
||||
addopts = -q --tb=short # No --cov
|
||||
# Enable coverage explicitly: pytest --cov=apps/backend
|
||||
```
|
||||
|
||||
### 7. Increase Timeout for Integration Tests
|
||||
|
||||
For tests that must be slow:
|
||||
|
||||
```python
|
||||
@pytest.mark.timeout(300) # 5 minutes for this specific test
|
||||
async def test_slow_integration():
|
||||
...
|
||||
```
|
||||
|
||||
### 8. Use Test Profiles
|
||||
|
||||
Create pytest configs for different scenarios:
|
||||
|
||||
**pytest.unit.ini:**
|
||||
```ini
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
addopts = -m "not slow and not integration" -q
|
||||
```
|
||||
|
||||
**pytest.all.ini:**
|
||||
```ini
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
addopts = -v
|
||||
timeout = 1200 # 20 minutes
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
pytest -c pytest.unit.ini # Fast PR checks
|
||||
pytest -c pytest.all.ini # Full test suite
|
||||
```
|
||||
|
||||
### 9. Mock Expensive Operations
|
||||
|
||||
Identify what's actually slow in the tests:
|
||||
|
||||
```python
|
||||
# SLOW - Real file I/O
|
||||
async def test_orchestrator():
|
||||
orchestrator = SpecOrchestrator(project_dir)
|
||||
await orchestrator.run() # Does real file operations
|
||||
|
||||
# FASTER - Mock the expensive parts
|
||||
async def test_orchestrator():
|
||||
with patch('spec.pipeline.orchestrator.discover_project'):
|
||||
with patch('spec.pipeline.orchestrator.write_files'):
|
||||
orchestrator = SpecOrchestrator(project_dir)
|
||||
await orchestrator.run() # Just tests the logic
|
||||
```
|
||||
|
||||
### 10. Run Tests in Parallel (Already Done)
|
||||
|
||||
Use the scripts we created:
|
||||
```bash
|
||||
./scripts/run-parallel-tests.sh # Directory-level
|
||||
./scripts/run-tests-per-file.sh # File-level
|
||||
```
|
||||
|
||||
## Implementation Priority
|
||||
|
||||
1. **HIGH** - Mark slow/integration tests (5 minutes)
|
||||
- Add `@pytest.mark.slow` to `test_spec_pipeline_orchestrator.py`
|
||||
- Add `@pytest.mark.integration` to integration tests
|
||||
|
||||
2. **HIGH** - Update CI to run fast tests by default (10 minutes)
|
||||
- CI: `pytest -m "not slow"` for PR checks
|
||||
- CI: `pytest` (full) for main branch
|
||||
|
||||
3. **MEDIUM** - Fix autouse fixture (30 minutes)
|
||||
- Remove autouse or make it conditional
|
||||
|
||||
4. **MEDIUM** - Create test profiles (1 hour)
|
||||
- Separate unit/integration configs
|
||||
|
||||
5. **LOW** - Refactor slow tests (ongoing)
|
||||
- Better mocking
|
||||
- Shared fixtures
|
||||
|
||||
## Quick Win Script
|
||||
|
||||
Add to Makefile or scripts:
|
||||
|
||||
```bash
|
||||
# makefile or script
|
||||
test-fast:
|
||||
pytest -m "not slow" -v
|
||||
|
||||
test-all:
|
||||
pytest -v
|
||||
|
||||
test-integration:
|
||||
pytest -m "integration" -v
|
||||
```
|
||||
@@ -32,8 +32,8 @@
|
||||
# API_TIMEOUT_MS=600000
|
||||
|
||||
# Model override (OPTIONAL)
|
||||
# Default: claude-opus-4-6
|
||||
# AUTO_BUILD_MODEL=claude-opus-4-6
|
||||
# Default: claude-opus-4-5-20251101
|
||||
# AUTO_BUILD_MODEL=claude-opus-4-5-20251101
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -269,9 +269,9 @@ GRAPHITI_ENABLED=true
|
||||
# OpenRouter Base URL (default: https://openrouter.ai/api/v1)
|
||||
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||
|
||||
# OpenRouter LLM Model (default: anthropic/claude-3.5-sonnet)
|
||||
# Popular choices: anthropic/claude-3.5-sonnet, openai/gpt-4o, google/gemini-2.0-flash
|
||||
# OPENROUTER_LLM_MODEL=anthropic/claude-3.5-sonnet
|
||||
# OpenRouter LLM Model (default: anthropic/claude-sonnet-4)
|
||||
# Popular choices: anthropic/claude-sonnet-4, openai/gpt-4o, google/gemini-2.0-flash
|
||||
# OPENROUTER_LLM_MODEL=anthropic/claude-sonnet-4
|
||||
|
||||
# OpenRouter Embedding Model (default: openai/text-embedding-3-small)
|
||||
# OPENROUTER_EMBEDDING_MODEL=openai/text-embedding-3-small
|
||||
@@ -368,5 +368,5 @@ GRAPHITI_ENABLED=true
|
||||
# GRAPHITI_LLM_PROVIDER=openrouter
|
||||
# GRAPHITI_EMBEDDER_PROVIDER=openrouter
|
||||
# OPENROUTER_API_KEY=sk-or-xxxxxxxx
|
||||
# OPENROUTER_LLM_MODEL=anthropic/claude-3.5-sonnet
|
||||
# OPENROUTER_LLM_MODEL=anthropic/claude-sonnet-4
|
||||
# OPENROUTER_EMBEDDING_MODEL=openai/text-embedding-3-small
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Backward compatibility shim - import from core.agent instead."""
|
||||
|
||||
from core.agent import * # noqa: F401, F403
|
||||
from core.agent import * # noqa: F403
|
||||
|
||||
@@ -12,24 +12,11 @@ This module provides:
|
||||
- Utility functions for git and plan management
|
||||
|
||||
Uses lazy imports to avoid circular dependencies.
|
||||
|
||||
Note: Module-level placeholders are defined to satisfy CodeQL static analysis.
|
||||
These trigger the actual import on first access through __getattr__.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .base import AUTO_CONTINUE_DELAY_SECONDS, HUMAN_INTERVENTION_FILE
|
||||
from .utils import (
|
||||
find_phase_for_subtask,
|
||||
find_subtask_in_plan,
|
||||
get_commit_count,
|
||||
get_latest_commit,
|
||||
load_implementation_plan,
|
||||
sync_spec_to_source,
|
||||
)
|
||||
# Explicit import required by CodeQL static analysis
|
||||
# (CodeQL doesn't recognize __getattr__ dynamic exports)
|
||||
from .utils import sync_spec_to_source
|
||||
|
||||
__all__ = [
|
||||
# Main API
|
||||
@@ -55,67 +42,55 @@ __all__ = [
|
||||
"HUMAN_INTERVENTION_FILE",
|
||||
]
|
||||
|
||||
# Module cache for lazy imports
|
||||
_module_cache = {}
|
||||
|
||||
def __getattr__(name):
|
||||
"""Lazy imports to avoid circular dependencies."""
|
||||
if name in ("AUTO_CONTINUE_DELAY_SECONDS", "HUMAN_INTERVENTION_FILE"):
|
||||
from .base import AUTO_CONTINUE_DELAY_SECONDS, HUMAN_INTERVENTION_FILE
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
"""Lazy imports to avoid circular dependencies.
|
||||
return locals()[name]
|
||||
elif name == "run_autonomous_agent":
|
||||
from .coder import run_autonomous_agent
|
||||
|
||||
Python 3.7+ calls this for attributes that don't exist at module level.
|
||||
"""
|
||||
if name == "run_autonomous_agent":
|
||||
from . import coder # Ensure agents.coder is registered in sys.modules
|
||||
|
||||
return coder.run_autonomous_agent
|
||||
elif name == "debug_memory_system_status":
|
||||
from . import (
|
||||
memory_manager, # Ensure agents.memory_manager is registered in sys.modules
|
||||
return run_autonomous_agent
|
||||
elif name in (
|
||||
"debug_memory_system_status",
|
||||
"get_graphiti_context",
|
||||
"save_session_memory",
|
||||
"save_session_to_graphiti",
|
||||
):
|
||||
from .memory_manager import (
|
||||
debug_memory_system_status,
|
||||
get_graphiti_context,
|
||||
save_session_memory,
|
||||
save_session_to_graphiti,
|
||||
)
|
||||
|
||||
return memory_manager.debug_memory_system_status
|
||||
elif name == "get_graphiti_context":
|
||||
from . import (
|
||||
memory_manager, # Ensure agents.memory_manager is registered in sys.modules
|
||||
)
|
||||
|
||||
return memory_manager.get_graphiti_context
|
||||
elif name == "save_session_memory":
|
||||
from . import (
|
||||
memory_manager, # Ensure agents.memory_manager is registered in sys.modules
|
||||
)
|
||||
|
||||
return memory_manager.save_session_memory
|
||||
elif name == "save_session_to_graphiti":
|
||||
from . import (
|
||||
memory_manager, # Ensure agents.memory_manager is registered in sys.modules
|
||||
)
|
||||
|
||||
return memory_manager.save_session_to_graphiti
|
||||
return locals()[name]
|
||||
elif name == "run_followup_planner":
|
||||
from . import planner # Ensure agents.planner is registered in sys.modules
|
||||
from .planner import run_followup_planner
|
||||
|
||||
return planner.run_followup_planner
|
||||
elif name == "post_session_processing":
|
||||
from . import session # Ensure agents.session is registered in sys.modules
|
||||
return run_followup_planner
|
||||
elif name in ("post_session_processing", "run_agent_session"):
|
||||
from .session import post_session_processing, run_agent_session
|
||||
|
||||
return session.post_session_processing
|
||||
elif name == "run_agent_session":
|
||||
from . import session # Ensure agents.session is registered in sys.modules
|
||||
return locals()[name]
|
||||
elif name in (
|
||||
"find_phase_for_subtask",
|
||||
"find_subtask_in_plan",
|
||||
"get_commit_count",
|
||||
"get_latest_commit",
|
||||
"load_implementation_plan",
|
||||
"sync_spec_to_source",
|
||||
):
|
||||
from .utils import (
|
||||
find_phase_for_subtask,
|
||||
find_subtask_in_plan,
|
||||
get_commit_count,
|
||||
get_latest_commit,
|
||||
load_implementation_plan,
|
||||
sync_spec_to_source,
|
||||
)
|
||||
|
||||
return session.run_agent_session
|
||||
return locals()[name]
|
||||
raise AttributeError(f"module 'agents' has no attribute '{name}'")
|
||||
|
||||
|
||||
def __dir__():
|
||||
"""Return list of module attributes for autocomplete and dir()."""
|
||||
return __all__ + [
|
||||
"__all__",
|
||||
"__doc__",
|
||||
"__file__",
|
||||
"__getattr__",
|
||||
"__name__",
|
||||
"__package__",
|
||||
"__loader__",
|
||||
"__spec__",
|
||||
]
|
||||
|
||||
@@ -15,9 +15,6 @@ logger = logging.getLogger(__name__)
|
||||
AUTO_CONTINUE_DELAY_SECONDS = 3
|
||||
HUMAN_INTERVENTION_FILE = "PAUSE"
|
||||
|
||||
# Retry configuration for subtask execution
|
||||
MAX_SUBTASK_RETRIES = 5 # Maximum attempts before marking subtask as stuck
|
||||
|
||||
# Retry configuration for 400 tool concurrency errors
|
||||
MAX_CONCURRENCY_RETRIES = 5 # Maximum number of retries for tool concurrency errors
|
||||
INITIAL_RETRY_DELAY_SECONDS = (
|
||||
|
||||
+17
-130
@@ -69,7 +69,6 @@ from .base import (
|
||||
MAX_CONCURRENCY_RETRIES,
|
||||
MAX_RATE_LIMIT_WAIT_SECONDS,
|
||||
MAX_RETRY_DELAY_SECONDS,
|
||||
MAX_SUBTASK_RETRIES,
|
||||
RATE_LIMIT_CHECK_INTERVAL_SECONDS,
|
||||
RATE_LIMIT_PAUSE_FILE,
|
||||
RESUME_FILE,
|
||||
@@ -88,60 +87,6 @@ from .utils import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FILE VALIDATION UTILITIES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def validate_subtask_files(subtask: dict, project_dir: Path) -> dict:
|
||||
"""
|
||||
Validate all files_to_modify exist before subtask execution.
|
||||
|
||||
Args:
|
||||
subtask: Subtask dictionary containing files_to_modify array
|
||||
project_dir: Root directory of the project
|
||||
|
||||
Returns:
|
||||
dict with:
|
||||
- success (bool): True if all files exist
|
||||
- error (str): Error message if validation fails
|
||||
- missing_files (list): List of missing file paths
|
||||
- invalid_paths (list): List of paths that resolve outside the project
|
||||
- suggestion (str): Actionable suggestion for resolution
|
||||
"""
|
||||
missing_files = []
|
||||
invalid_paths = []
|
||||
|
||||
resolved_project = Path(project_dir).resolve()
|
||||
for file_path in subtask.get("files_to_modify", []):
|
||||
full_path = (resolved_project / file_path).resolve()
|
||||
if not full_path.is_relative_to(resolved_project):
|
||||
invalid_paths.append(file_path)
|
||||
continue
|
||||
if not full_path.exists():
|
||||
missing_files.append(file_path)
|
||||
|
||||
if invalid_paths:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Paths resolve outside project boundary: {', '.join(invalid_paths)}",
|
||||
"missing_files": missing_files,
|
||||
"invalid_paths": invalid_paths,
|
||||
"suggestion": "Update implementation plan to use paths within the project directory",
|
||||
}
|
||||
|
||||
if missing_files:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Planned files do not exist: {', '.join(missing_files)}",
|
||||
"missing_files": missing_files,
|
||||
"invalid_paths": [],
|
||||
"suggestion": "Update implementation plan with correct filenames or create missing files",
|
||||
}
|
||||
|
||||
return {"success": True, "missing_files": [], "invalid_paths": []}
|
||||
|
||||
|
||||
def _check_and_clear_resume_file(
|
||||
resume_file: Path,
|
||||
pause_file: Path,
|
||||
@@ -581,16 +526,18 @@ async def run_autonomous_agent(
|
||||
phase_model = get_phase_model(spec_dir, current_phase, model)
|
||||
phase_thinking_budget = get_phase_thinking_budget(spec_dir, current_phase)
|
||||
|
||||
# Create client (fresh context) with phase-specific model and thinking
|
||||
# Use appropriate agent_type for correct tool permissions and thinking budget
|
||||
client = create_client(
|
||||
project_dir,
|
||||
spec_dir,
|
||||
phase_model,
|
||||
agent_type="planner" if first_run else "coder",
|
||||
max_thinking_tokens=phase_thinking_budget,
|
||||
)
|
||||
|
||||
# Generate appropriate prompt
|
||||
if first_run:
|
||||
# Create client for planning phase
|
||||
client = create_client(
|
||||
project_dir,
|
||||
spec_dir,
|
||||
phase_model,
|
||||
agent_type="planner",
|
||||
max_thinking_tokens=phase_thinking_budget,
|
||||
)
|
||||
prompt = generate_planner_prompt(spec_dir, project_dir)
|
||||
if planning_retry_context:
|
||||
prompt += "\n\n" + planning_retry_context
|
||||
@@ -647,13 +594,15 @@ async def run_autonomous_agent(
|
||||
"Waiting for implementation plan to be ready...", "progress"
|
||||
)
|
||||
for retry_attempt in range(3):
|
||||
await asyncio.sleep((retry_attempt + 1) * 2) # 2s, 4s, 6s
|
||||
delay = (retry_attempt + 1) * 2 # 2s, 4s, 6s
|
||||
await asyncio.sleep(delay)
|
||||
next_subtask = get_next_subtask(spec_dir)
|
||||
if next_subtask:
|
||||
# Successfully found subtask after retry
|
||||
delay = (retry_attempt + 1) * 2
|
||||
# Update subtask_id and phase_name after successful retry
|
||||
subtask_id = next_subtask.get("id")
|
||||
phase_name = next_subtask.get("phase_name")
|
||||
print_status(
|
||||
f"Found subtask {next_subtask.get('id')} after {delay}s delay",
|
||||
f"Found subtask {subtask_id} after {delay}s delay",
|
||||
"success",
|
||||
)
|
||||
break
|
||||
@@ -666,68 +615,6 @@ async def run_autonomous_agent(
|
||||
print("No pending subtasks found - build may be complete!")
|
||||
break
|
||||
|
||||
# Validate that all files_to_modify exist before attempting execution
|
||||
# This prevents infinite retry loops when implementation plan references non-existent files
|
||||
validation_result = validate_subtask_files(next_subtask, project_dir)
|
||||
if not validation_result["success"]:
|
||||
# File validation failed - record error and skip session
|
||||
error_msg = validation_result["error"]
|
||||
suggestion = validation_result.get("suggestion", "")
|
||||
|
||||
print()
|
||||
print_status(f"File validation failed: {error_msg}", "error")
|
||||
if suggestion:
|
||||
print(muted(f"Suggestion: {suggestion}"))
|
||||
print()
|
||||
|
||||
# Record the validation failure in recovery manager
|
||||
recovery_manager.record_attempt(
|
||||
subtask_id=subtask_id,
|
||||
session=iteration,
|
||||
success=False,
|
||||
approach="File validation failed before execution",
|
||||
error=error_msg,
|
||||
)
|
||||
|
||||
# Log the validation failure
|
||||
if task_logger:
|
||||
task_logger.log_error(
|
||||
f"File validation failed: {error_msg}", LogPhase.CODING
|
||||
)
|
||||
|
||||
# Check if subtask has exceeded max retries
|
||||
attempt_count = recovery_manager.get_attempt_count(subtask_id)
|
||||
if attempt_count >= MAX_SUBTASK_RETRIES:
|
||||
recovery_manager.mark_subtask_stuck(
|
||||
subtask_id,
|
||||
f"File validation failed after {attempt_count} attempts: {error_msg}",
|
||||
)
|
||||
print_status(
|
||||
f"Subtask {subtask_id} marked as STUCK after {attempt_count} failed validation attempts",
|
||||
"error",
|
||||
)
|
||||
print(
|
||||
muted(
|
||||
"Consider: update implementation plan with correct filenames"
|
||||
)
|
||||
)
|
||||
|
||||
# Update status
|
||||
status_manager.update(state=BuildState.ERROR)
|
||||
|
||||
# Small delay before retry
|
||||
await asyncio.sleep(AUTO_CONTINUE_DELAY_SECONDS)
|
||||
continue # Skip to next iteration
|
||||
|
||||
# Create client for coding phase (after file validation passes)
|
||||
client = create_client(
|
||||
project_dir,
|
||||
spec_dir,
|
||||
phase_model,
|
||||
agent_type="coder",
|
||||
max_thinking_tokens=phase_thinking_budget,
|
||||
)
|
||||
|
||||
# Get attempt count for recovery context
|
||||
attempt_count = recovery_manager.get_attempt_count(subtask_id)
|
||||
recovery_hints = (
|
||||
@@ -847,7 +734,7 @@ async def run_autonomous_agent(
|
||||
|
||||
# Check for stuck subtasks
|
||||
attempt_count = recovery_manager.get_attempt_count(subtask_id)
|
||||
if not success and attempt_count >= MAX_SUBTASK_RETRIES:
|
||||
if not success and attempt_count >= 3:
|
||||
recovery_manager.mark_subtask_stuck(
|
||||
subtask_id, f"Failed after {attempt_count} attempts"
|
||||
)
|
||||
|
||||
@@ -247,7 +247,7 @@ async def get_graphiti_context(
|
||||
if memory is not None:
|
||||
try:
|
||||
await memory.close()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Failed to close Graphiti memory connection", exc_info=True
|
||||
)
|
||||
@@ -417,9 +417,9 @@ async def save_session_memory(
|
||||
if memory is not None:
|
||||
try:
|
||||
await memory.close()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Failed to close Graphiti memory connection", exc_info=True
|
||||
"Failed to close Graphiti memory connection", exc_info=e
|
||||
)
|
||||
else:
|
||||
if is_debug_enabled():
|
||||
|
||||
@@ -8,46 +8,9 @@ This script verifies that:
|
||||
3. Backwards compatibility is maintained
|
||||
"""
|
||||
|
||||
import io
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Configure safe encoding on Windows to handle Unicode characters in output
|
||||
if sys.platform == "win32":
|
||||
for _stream_name in ("stdout", "stderr"):
|
||||
_stream = getattr(sys, _stream_name)
|
||||
# Method 1: Try reconfigure (works for TTY)
|
||||
if hasattr(_stream, "reconfigure"):
|
||||
try:
|
||||
_stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
continue
|
||||
except (
|
||||
AttributeError,
|
||||
io.UnsupportedOperation,
|
||||
OSError,
|
||||
): # Stream doesn't support reconfigure
|
||||
pass # no-op
|
||||
# Method 2: Wrap with TextIOWrapper for piped output
|
||||
try:
|
||||
if hasattr(_stream, "buffer"):
|
||||
_new_stream = io.TextIOWrapper(
|
||||
_stream.buffer,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
line_buffering=True,
|
||||
)
|
||||
setattr(sys, _stream_name, _new_stream)
|
||||
except (
|
||||
AttributeError,
|
||||
io.UnsupportedOperation,
|
||||
OSError,
|
||||
): # Stream doesn't support wrapper
|
||||
pass # no-op
|
||||
# Clean up temporary variables
|
||||
del _stream_name, _stream
|
||||
if "_new_stream" in dir():
|
||||
del _new_stream
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ TOOL_UPDATE_QA_STATUS = "mcp__auto-claude__update_qa_status"
|
||||
# Context7 MCP tools for documentation lookup (always enabled)
|
||||
CONTEXT7_TOOLS = [
|
||||
"mcp__context7__resolve-library-id",
|
||||
"mcp__context7__query-docs",
|
||||
"mcp__context7__get-library-docs",
|
||||
]
|
||||
|
||||
# Linear MCP tools for project management (when LINEAR_API_KEY is set)
|
||||
@@ -158,7 +158,7 @@ AGENT_CONFIGS = {
|
||||
"tools": BASE_READ_TOOLS,
|
||||
"mcp_servers": [], # Self-critique, no external tools
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
"thinking_default": "ultrathink",
|
||||
},
|
||||
"spec_discovery": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
@@ -210,7 +210,7 @@ AGENT_CONFIGS = {
|
||||
TOOL_RECORD_GOTCHA,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
],
|
||||
"thinking_default": "low", # Coding uses minimal thinking (effort: low for Opus, 1024 tokens for Sonnet/Haiku)
|
||||
"thinking_default": "none", # Coding doesn't use extended thinking
|
||||
},
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# QA PHASES (Read + test + browser + Graphiti memory)
|
||||
@@ -247,9 +247,9 @@ AGENT_CONFIGS = {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
# Note: Default to "low" for minimal thinking overhead
|
||||
# Haiku doesn't support thinking; create_simple_client() handles this
|
||||
"thinking_default": "low",
|
||||
# Note: Default to "none" because insight_extractor uses Haiku which doesn't support thinking
|
||||
# If using Sonnet/Opus models, override max_thinking_tokens in create_simple_client()
|
||||
"thinking_default": "none",
|
||||
},
|
||||
"merge_resolver": {
|
||||
"tools": [], # Text-only analysis
|
||||
@@ -524,7 +524,7 @@ def get_default_thinking_level(agent_type: str) -> str:
|
||||
agent_type: The agent type identifier
|
||||
|
||||
Returns:
|
||||
Thinking level string (low, medium, high)
|
||||
Thinking level string (none, low, medium, high, ultrathink)
|
||||
"""
|
||||
config = get_agent_config(agent_type)
|
||||
return config.get("thinking_default", "medium")
|
||||
|
||||
@@ -79,7 +79,7 @@ async def _save_to_graphiti_async(
|
||||
# Always close the memory connection (swallow exceptions to avoid overriding)
|
||||
try:
|
||||
await memory.close()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Failed to close Graphiti memory connection", exc_info=True
|
||||
)
|
||||
@@ -312,8 +312,8 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
for path, info in list(discoveries.items())[:20]: # Limit to 20
|
||||
desc = info.get("description", "No description")
|
||||
result_parts.append(f"- `{path}`: {desc}")
|
||||
except Exception: # Non-critical error; continue
|
||||
pass # no-op: memory files are optional
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Load gotchas
|
||||
gotchas_file = memory_dir / "gotchas.md"
|
||||
@@ -326,8 +326,8 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
result_parts.append(
|
||||
content[-1000:] if len(content) > 1000 else content
|
||||
)
|
||||
except Exception: # Non-critical error; continue
|
||||
pass # no-op: memory files are optional
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Load patterns
|
||||
patterns_file = memory_dir / "patterns.md"
|
||||
@@ -339,8 +339,8 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
result_parts.append(
|
||||
content[-1000:] if len(content) > 1000 else content
|
||||
)
|
||||
except Exception: # Non-critical error; continue
|
||||
pass # no-op: memory files are optional
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not result_parts:
|
||||
return {
|
||||
|
||||
@@ -29,47 +29,9 @@ All actual implementation is in focused submodules for better maintainability.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Configure safe encoding on Windows to handle Unicode characters in output
|
||||
if sys.platform == "win32":
|
||||
for _stream_name in ("stdout", "stderr"):
|
||||
_stream = getattr(sys, _stream_name)
|
||||
# Method 1: Try reconfigure (works for TTY)
|
||||
if hasattr(_stream, "reconfigure"):
|
||||
try:
|
||||
_stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
continue
|
||||
except (
|
||||
AttributeError,
|
||||
io.UnsupportedOperation,
|
||||
OSError,
|
||||
): # Stream doesn't support reconfigure
|
||||
pass # no-op
|
||||
# Method 2: Wrap with TextIOWrapper for piped output
|
||||
try:
|
||||
if hasattr(_stream, "buffer"):
|
||||
_new_stream = io.TextIOWrapper(
|
||||
_stream.buffer,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
line_buffering=True,
|
||||
)
|
||||
setattr(sys, _stream_name, _new_stream)
|
||||
except (
|
||||
AttributeError,
|
||||
io.UnsupportedOperation,
|
||||
OSError,
|
||||
): # Stream doesn't support wrapper
|
||||
pass # no-op
|
||||
# Clean up temporary variables
|
||||
del _stream_name, _stream
|
||||
if "_new_stream" in dir():
|
||||
del _new_stream
|
||||
|
||||
# Import from the new modular structure
|
||||
from .analyzers import (
|
||||
ProjectAnalyzer,
|
||||
|
||||
@@ -14,6 +14,7 @@ Main exports:
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .project_analyzer_module import ProjectAnalyzer
|
||||
from .service_analyzer import ServiceAnalyzer
|
||||
|
||||
@@ -362,8 +362,9 @@ class FrameworkAnalyzer(BaseAnalyzer):
|
||||
dependencies = self._detect_spm_dependencies()
|
||||
if dependencies:
|
||||
self.analysis["spm_dependencies"] = dependencies
|
||||
except Exception: # Silently fail if Swift detection has issues
|
||||
pass # no-op
|
||||
except Exception:
|
||||
# Silently fail if Swift detection has issues
|
||||
pass
|
||||
|
||||
def _detect_spm_dependencies(self) -> list[str]:
|
||||
"""Detect Swift Package Manager dependencies."""
|
||||
|
||||
@@ -115,14 +115,6 @@ class CIDiscovery:
|
||||
CIConfig if CI found, None otherwise
|
||||
"""
|
||||
project_dir = Path(project_dir)
|
||||
|
||||
# Return None if project directory doesn't exist or is inaccessible
|
||||
try:
|
||||
if not project_dir.exists():
|
||||
return None
|
||||
except (OSError, PermissionError):
|
||||
return None
|
||||
|
||||
cache_key = str(project_dir.resolve())
|
||||
|
||||
if cache_key in self._cache:
|
||||
@@ -307,8 +299,8 @@ class CIDiscovery:
|
||||
if isinstance(variables, dict):
|
||||
result.environment_variables.extend(variables.keys())
|
||||
|
||||
except Exception: # Non-critical error; continue
|
||||
pass # no-op: CI parsing is best-effort
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
@@ -365,8 +357,8 @@ class CIDiscovery:
|
||||
)
|
||||
)
|
||||
|
||||
except Exception: # Non-critical error; continue
|
||||
pass # no-op: CI parsing is best-effort
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
@@ -385,11 +377,17 @@ class CIDiscovery:
|
||||
matches = sh_pattern.findall(content)
|
||||
|
||||
steps = []
|
||||
test_related = False
|
||||
|
||||
for cmd in matches:
|
||||
steps.append(cmd)
|
||||
self._extract_test_commands(cmd, result)
|
||||
|
||||
if any(
|
||||
kw in cmd.lower() for kw in ["test", "pytest", "jest", "coverage"]
|
||||
):
|
||||
test_related = True
|
||||
|
||||
# Extract stage names
|
||||
stage_pattern = re.compile(r'stage\s*\([\'"]([^\'"]+)[\'"]\)')
|
||||
stages = stage_pattern.findall(content)
|
||||
@@ -404,8 +402,8 @@ class CIDiscovery:
|
||||
)
|
||||
)
|
||||
|
||||
except Exception: # Non-critical error; continue
|
||||
pass # no-op: CI parsing is best-effort
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -22,11 +22,13 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Check for Claude SDK availability
|
||||
try:
|
||||
from claude_agent_sdk import ClaudeSDKClient # noqa: F401 (re-export)
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
|
||||
SDK_AVAILABLE = True
|
||||
except ImportError:
|
||||
SDK_AVAILABLE = False
|
||||
ClaudeAgentOptions = None
|
||||
ClaudeSDKClient = None
|
||||
|
||||
from core.auth import ensure_claude_code_oauth_token, get_auth_token
|
||||
|
||||
|
||||
@@ -31,11 +31,12 @@ from typing import Any
|
||||
|
||||
# Import the existing secrets scanner
|
||||
try:
|
||||
from security.scan_secrets import get_all_tracked_files, scan_files
|
||||
from security.scan_secrets import SecretMatch, get_all_tracked_files, scan_files
|
||||
|
||||
HAS_SECRETS_SCANNER = True
|
||||
except ImportError:
|
||||
HAS_SECRETS_SCANNER = False
|
||||
SecretMatch = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -107,20 +108,6 @@ class SecurityScanner:
|
||||
self._bandit_available: bool | None = None
|
||||
self._npm_available: bool | None = None
|
||||
|
||||
def _is_accessible_directory(self, path: Path) -> bool:
|
||||
"""Check if a directory is accessible for scanning."""
|
||||
try:
|
||||
return path.is_dir()
|
||||
except (OSError, PermissionError):
|
||||
return False
|
||||
|
||||
def _safe_exists(self, path: Path) -> bool:
|
||||
"""Safely check if a path exists, handling permission errors."""
|
||||
try:
|
||||
return path.exists()
|
||||
except (OSError, PermissionError):
|
||||
return False
|
||||
|
||||
def scan(
|
||||
self,
|
||||
project_dir: Path,
|
||||
@@ -242,8 +229,9 @@ class SecurityScanner:
|
||||
src_dirs = []
|
||||
for candidate in ["src", "app", project_dir.name, "."]:
|
||||
candidate_path = project_dir / candidate
|
||||
if self._safe_exists(candidate_path) and self._safe_exists(
|
||||
candidate_path / "__init__.py"
|
||||
if (
|
||||
candidate_path.exists()
|
||||
and (candidate_path / "__init__.py").exists()
|
||||
):
|
||||
src_dirs.append(str(candidate_path))
|
||||
|
||||
@@ -310,7 +298,7 @@ class SecurityScanner:
|
||||
) -> None:
|
||||
"""Run dependency vulnerability audits."""
|
||||
# npm audit for JavaScript projects
|
||||
if self._safe_exists(project_dir / "package.json"):
|
||||
if (project_dir / "package.json").exists():
|
||||
self._run_npm_audit(project_dir, result)
|
||||
|
||||
# pip-audit for Python projects (if available)
|
||||
@@ -401,15 +389,15 @@ class SecurityScanner:
|
||||
else None,
|
||||
)
|
||||
)
|
||||
except json.JSONDecodeError: # Invalid JSON; skip
|
||||
pass # no-op: invalid audit output, skip
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
except FileNotFoundError:
|
||||
pass # pip-audit not available
|
||||
except subprocess.TimeoutExpired: # Non-critical error; continue
|
||||
pass # no-op: timeout is non-critical
|
||||
except Exception: # Non-critical error; continue
|
||||
pass # no-op: pip-audit is optional
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _is_python_project(self, project_dir: Path) -> bool:
|
||||
"""Check if this is a Python project."""
|
||||
@@ -419,7 +407,7 @@ class SecurityScanner:
|
||||
project_dir / "setup.py",
|
||||
project_dir / "setup.cfg",
|
||||
]
|
||||
return any(self._safe_exists(p) for p in indicators)
|
||||
return any(p.exists() for p in indicators)
|
||||
|
||||
def _check_bandit_available(self) -> bool:
|
||||
"""Check if Bandit is available."""
|
||||
@@ -439,20 +427,8 @@ class SecurityScanner:
|
||||
"""Redact a secret for safe logging."""
|
||||
if len(text) <= 8:
|
||||
return "*" * len(text)
|
||||
# Show only first 4 and last 4 characters, redact the middle
|
||||
return text[:4] + "*" * (len(text) - 8) + text[-4:]
|
||||
|
||||
def _redact_log_message(self, message: str) -> str:
|
||||
"""Redact potentially sensitive information from log messages."""
|
||||
# Remove common secret patterns from log messages
|
||||
import re
|
||||
|
||||
# Redact things that look like API keys, tokens, passwords
|
||||
redacted = re.sub(r"([a-zA-Z0-9_-]{32,})", "[REDACTED]", message)
|
||||
# Redact things that look like base64
|
||||
redacted = re.sub(r"[A-Za-z0-9+/]{64,}={0,2}", "[REDACTED]", redacted)
|
||||
return redacted[:500] # Also limit length
|
||||
|
||||
def _save_results(self, spec_dir: Path, result: SecurityScanResult) -> None:
|
||||
"""Save scan results to spec directory."""
|
||||
spec_dir = Path(spec_dir)
|
||||
@@ -465,12 +441,7 @@ class SecurityScanner:
|
||||
json.dump(output_data, f, indent=2)
|
||||
|
||||
def to_dict(self, result: SecurityScanResult) -> dict[str, Any]:
|
||||
"""Convert result to dictionary for JSON serialization.
|
||||
|
||||
Note: All secret values are redacted via _redact_secret() before being
|
||||
included in the result dict. Only first 4 and last 4 chars are shown.
|
||||
"""
|
||||
# lgtm[py/clear-text-logging-of-sensitive-data] - secrets are redacted
|
||||
"""Convert result to dictionary for JSON serialization."""
|
||||
return {
|
||||
"secrets": result.secrets,
|
||||
"vulnerabilities": [
|
||||
@@ -599,7 +570,6 @@ def main() -> None:
|
||||
)
|
||||
|
||||
if args.json:
|
||||
# lgtm[py/clear-text-logging-of-sensitive-data] - secrets are redacted
|
||||
print(json.dumps(scanner.to_dict(result), indent=2))
|
||||
else:
|
||||
print(f"Secrets Found: {len(result.secrets)}")
|
||||
@@ -610,12 +580,7 @@ def main() -> None:
|
||||
if result.secrets:
|
||||
print("\nSecrets Detected:")
|
||||
for secret in result.secrets:
|
||||
# Only log safe fields (pattern name, file path, line number)
|
||||
# The actual secret value is already redacted in matched_text
|
||||
pattern = secret.get("pattern", "unknown")
|
||||
file_path = secret.get("file", "unknown")
|
||||
line_num = secret.get("line", "?")
|
||||
print(f" - {pattern} in {file_path}:{line_num}")
|
||||
print(f" - {secret['pattern']} in {secret['file']}:{secret['line']}")
|
||||
|
||||
if result.vulnerabilities:
|
||||
print(f"\nVulnerabilities ({len(result.vulnerabilities)}):")
|
||||
|
||||
@@ -140,17 +140,16 @@ def handle_batch_status_command(project_dir: str) -> bool:
|
||||
spec_name = spec_dir.name
|
||||
req_file = spec_dir / "requirements.json"
|
||||
|
||||
# Get title from requirements file, default to spec name
|
||||
status = "unknown"
|
||||
title = spec_name
|
||||
|
||||
if req_file.exists():
|
||||
try:
|
||||
with open(req_file, encoding="utf-8") as f:
|
||||
req = json.load(f)
|
||||
title = req.get("task_description", spec_name)
|
||||
title = req.get("task_description", title)
|
||||
except json.JSONDecodeError:
|
||||
# Invalid JSON; use default title from directory name
|
||||
title = spec_name
|
||||
else:
|
||||
title = spec_name
|
||||
pass
|
||||
|
||||
# Determine status
|
||||
if (spec_dir / "spec.md").exists():
|
||||
|
||||
@@ -179,10 +179,10 @@ def handle_build_command(
|
||||
continue_existing = check_existing_build(project_dir, spec_dir.name)
|
||||
if continue_existing:
|
||||
# Continue with existing worktree
|
||||
pass # no-op
|
||||
pass
|
||||
else:
|
||||
# User chose to start fresh or merged existing
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
# Choose workspace (skip for parallel mode - it always uses worktrees)
|
||||
working_dir = project_dir
|
||||
@@ -297,6 +297,7 @@ def handle_build_command(
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nQA validation paused.")
|
||||
print(f"Resume: python auto-claude/run.py --spec {spec_dir.name} --qa")
|
||||
qa_approved = False
|
||||
|
||||
# Post-build finalization (only for isolated sequential mode)
|
||||
# This happens AFTER QA validation so the worktree still exists
|
||||
@@ -448,7 +449,7 @@ def _handle_build_interrupt(
|
||||
if choice == "skip":
|
||||
print()
|
||||
print_status("Resuming build...", "info")
|
||||
status_manager.update(state=BuildState.BUILDING)
|
||||
status_manager.update(state=BuildState.RUNNING)
|
||||
asyncio.run(
|
||||
run_autonomous_agent(
|
||||
project_dir=working_dir,
|
||||
@@ -468,8 +469,9 @@ def _handle_build_interrupt(
|
||||
status_manager = StatusManager(project_dir)
|
||||
status_manager.set_inactive()
|
||||
sys.exit(0)
|
||||
except EOFError: # stdin closed
|
||||
pass # no-op
|
||||
except EOFError:
|
||||
# stdin closed
|
||||
pass
|
||||
|
||||
# Resume instructions (shown when user provided instructions or chose file/type/paste)
|
||||
print()
|
||||
|
||||
@@ -12,48 +12,11 @@ Usage:
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
# Configure safe encoding on Windows to handle Unicode characters in output
|
||||
if sys.platform == "win32":
|
||||
for _stream_name in ("stdout", "stderr"):
|
||||
_stream = getattr(sys, _stream_name)
|
||||
# Method 1: Try reconfigure (works for TTY)
|
||||
if hasattr(_stream, "reconfigure"):
|
||||
try:
|
||||
_stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
continue
|
||||
except (
|
||||
AttributeError,
|
||||
io.UnsupportedOperation,
|
||||
OSError,
|
||||
): # Stream doesn't support reconfigure
|
||||
pass # no-op
|
||||
# Method 2: Wrap with TextIOWrapper for piped output
|
||||
try:
|
||||
if hasattr(_stream, "buffer"):
|
||||
_new_stream = io.TextIOWrapper(
|
||||
_stream.buffer,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
line_buffering=True,
|
||||
)
|
||||
setattr(sys, _stream_name, _new_stream)
|
||||
except (
|
||||
AttributeError,
|
||||
io.UnsupportedOperation,
|
||||
OSError,
|
||||
): # Stream doesn't support wrapper
|
||||
pass # no-op
|
||||
# Clean up temporary variables
|
||||
del _stream_name, _stream
|
||||
if "_new_stream" in dir():
|
||||
del _new_stream
|
||||
|
||||
from cli.utils import find_specs_dir
|
||||
|
||||
|
||||
|
||||
@@ -325,32 +325,42 @@ def _detect_parallel_task_conflicts(
|
||||
try:
|
||||
from debug import (
|
||||
debug,
|
||||
debug_detailed,
|
||||
debug_error,
|
||||
debug_section,
|
||||
debug_success,
|
||||
debug_verbose,
|
||||
is_debug_enabled,
|
||||
)
|
||||
except ImportError:
|
||||
|
||||
def debug(*args, **kwargs):
|
||||
"""Fallback debug function when debug module is not available."""
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
def debug_error(*args, **kwargs):
|
||||
"""Fallback debug_error function when debug module is not available."""
|
||||
pass # no-op
|
||||
|
||||
def debug_section(*args, **kwargs):
|
||||
"""Fallback debug_section function when debug module is not available."""
|
||||
pass # no-op
|
||||
|
||||
def debug_success(*args, **kwargs):
|
||||
"""Fallback debug_success function when debug module is not available."""
|
||||
pass # no-op
|
||||
def debug_detailed(*args, **kwargs):
|
||||
"""Fallback debug_detailed function when debug module is not available."""
|
||||
pass
|
||||
|
||||
def debug_verbose(*args, **kwargs):
|
||||
"""Fallback debug_verbose function when debug module is not available."""
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
def debug_success(*args, **kwargs):
|
||||
"""Fallback debug_success function when debug module is not available."""
|
||||
pass
|
||||
|
||||
def debug_error(*args, **kwargs):
|
||||
"""Fallback debug_error function when debug module is not available."""
|
||||
pass
|
||||
|
||||
def debug_section(*args, **kwargs):
|
||||
"""Fallback debug_section function when debug module is not available."""
|
||||
pass
|
||||
|
||||
def is_debug_enabled():
|
||||
"""Fallback is_debug_enabled function when debug module is not available."""
|
||||
return False
|
||||
|
||||
|
||||
MODULE = "cli.workspace_commands"
|
||||
@@ -807,7 +817,7 @@ def _check_git_merge_conflicts(
|
||||
import re
|
||||
|
||||
match = re.search(
|
||||
r"Merge conflict in\s+(.+?)\s*$",
|
||||
r"(?:Merge conflict in|CONFLICT.*?:)\s*(.+?)(?:\s*$|\s+\()",
|
||||
line,
|
||||
)
|
||||
if match:
|
||||
|
||||
@@ -5,10 +5,6 @@ Provides Claude API client utilities.
|
||||
Uses lazy imports to avoid circular dependencies.
|
||||
"""
|
||||
|
||||
# Explicit import required for CodeQL static analysis
|
||||
# (CodeQL doesn't recognize __getattr__ dynamic exports)
|
||||
from core.client import create_client as _create_client_impl
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
"""Lazy import to avoid circular imports with auto_claude_tools."""
|
||||
@@ -19,10 +15,11 @@ def __getattr__(name):
|
||||
|
||||
def create_client(*args, **kwargs):
|
||||
"""Create a Claude client instance."""
|
||||
return _create_client_impl(*args, **kwargs)
|
||||
from core.client import create_client as _create_client
|
||||
|
||||
return _create_client(*args, **kwargs)
|
||||
|
||||
|
||||
# Export list must come after all exports are defined to satisfy CodeQL
|
||||
__all__ = [
|
||||
"create_client",
|
||||
]
|
||||
|
||||
@@ -13,7 +13,6 @@ Features:
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
@@ -21,46 +20,8 @@ import sys
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
# Configure safe encoding on Windows to handle Unicode characters in output
|
||||
# This is needed because this module may be imported in contexts that log
|
||||
# Unicode content, and the logging system uses stdout/stderr.
|
||||
if sys.platform == "win32":
|
||||
for _stream_name in ("stdout", "stderr"):
|
||||
_stream = getattr(sys, _stream_name)
|
||||
# Method 1: Try reconfigure (works for TTY)
|
||||
if hasattr(_stream, "reconfigure"):
|
||||
try:
|
||||
_stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
continue
|
||||
except (
|
||||
AttributeError,
|
||||
io.UnsupportedOperation,
|
||||
OSError,
|
||||
): # Stream doesn't support reconfigure
|
||||
pass # no-op
|
||||
# Method 2: Wrap with TextIOWrapper for piped output
|
||||
try:
|
||||
if hasattr(_stream, "buffer"):
|
||||
_new_stream = io.TextIOWrapper(
|
||||
_stream.buffer,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
line_buffering=True,
|
||||
)
|
||||
setattr(sys, _stream_name, _new_stream)
|
||||
except (
|
||||
AttributeError,
|
||||
io.UnsupportedOperation,
|
||||
OSError,
|
||||
): # Stream doesn't support wrapper
|
||||
pass # no-op
|
||||
# Clean up temporary variables
|
||||
del _stream_name, _stream
|
||||
if "_new_stream" in dir():
|
||||
del _new_stream
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -40,12 +40,9 @@ class ContextBuilder:
|
||||
try:
|
||||
with open(index_file, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (
|
||||
OSError,
|
||||
json.JSONDecodeError,
|
||||
UnicodeDecodeError,
|
||||
): # Corrupted or legacy-encoded file, regenerate
|
||||
pass # no-op
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
# Corrupted or legacy-encoded file, regenerate
|
||||
pass
|
||||
|
||||
# Try to create one
|
||||
from analyzer import analyze_project
|
||||
@@ -117,7 +114,7 @@ class ContextBuilder:
|
||||
try:
|
||||
# Run the async function in a new event loop if necessary
|
||||
try:
|
||||
_ = asyncio.get_running_loop()
|
||||
loop = asyncio.get_running_loop()
|
||||
# We're already in an async context - this shouldn't happen in CLI
|
||||
# but handle it gracefully
|
||||
graph_hints = []
|
||||
|
||||
@@ -26,47 +26,9 @@ The context builder will:
|
||||
4. Output focused context for AI agents
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Configure safe encoding on Windows to handle Unicode characters in output
|
||||
if sys.platform == "win32":
|
||||
for _stream_name in ("stdout", "stderr"):
|
||||
_stream = getattr(sys, _stream_name)
|
||||
# Method 1: Try reconfigure (works for TTY)
|
||||
if hasattr(_stream, "reconfigure"):
|
||||
try:
|
||||
_stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
continue
|
||||
except (
|
||||
AttributeError,
|
||||
io.UnsupportedOperation,
|
||||
OSError,
|
||||
): # Stream doesn't support reconfigure
|
||||
pass # no-op
|
||||
# Method 2: Wrap with TextIOWrapper for piped output
|
||||
try:
|
||||
if hasattr(_stream, "buffer"):
|
||||
_new_stream = io.TextIOWrapper(
|
||||
_stream.buffer,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
line_buffering=True,
|
||||
)
|
||||
setattr(sys, _stream_name, _new_stream)
|
||||
except (
|
||||
AttributeError,
|
||||
io.UnsupportedOperation,
|
||||
OSError,
|
||||
): # Stream doesn't support wrapper
|
||||
pass # no-op
|
||||
# Clean up temporary variables
|
||||
del _stream_name, _stream
|
||||
if "_new_stream" in dir():
|
||||
del _new_stream
|
||||
|
||||
from context import (
|
||||
ContextBuilder,
|
||||
FileMatch,
|
||||
|
||||
@@ -3,63 +3,40 @@ Core Framework Module
|
||||
=====================
|
||||
|
||||
Core components for the Auto Claude autonomous coding framework.
|
||||
|
||||
Note: We use lazy imports here because the full agent module has many dependencies
|
||||
that may not be needed for basic operations.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
# Module-level placeholders for CodeQL static analysis.
|
||||
# The actual exported names trigger __getattr__ for lazy loading.
|
||||
# Use list placeholder to satisfy CodeQL's "defined but not set to None" check.
|
||||
run_autonomous_agent: Any = []
|
||||
run_followup_planner: Any = []
|
||||
WorktreeManager: Any = []
|
||||
# Note: We use lazy imports here because the full agent module has many dependencies
|
||||
# that may not be needed for basic operations like workspace management.
|
||||
|
||||
__all__ = [
|
||||
"run_autonomous_agent",
|
||||
"run_followup_planner",
|
||||
"WorkspaceManager",
|
||||
"WorktreeManager",
|
||||
"create_claude_client",
|
||||
"ClaudeClient",
|
||||
"ProgressTracker",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
def __getattr__(name):
|
||||
"""Lazy imports to avoid circular dependencies and heavy imports."""
|
||||
if name == "run_autonomous_agent":
|
||||
from .agent import run_autonomous_agent
|
||||
if name in ("run_autonomous_agent", "run_followup_planner"):
|
||||
from .agent import run_autonomous_agent, run_followup_planner
|
||||
|
||||
return run_autonomous_agent
|
||||
elif name == "run_followup_planner":
|
||||
from .agent import run_followup_planner
|
||||
return locals()[name]
|
||||
elif name == "WorkspaceManager":
|
||||
from .workspace import WorkspaceManager
|
||||
|
||||
return run_followup_planner
|
||||
return WorkspaceManager
|
||||
elif name == "WorktreeManager":
|
||||
from .worktree import WorktreeManager
|
||||
|
||||
return WorktreeManager
|
||||
elif name == "ProgressTracker":
|
||||
from .progress import ProgressTracker
|
||||
|
||||
return ProgressTracker
|
||||
elif name in ("create_claude_client", "ClaudeClient"):
|
||||
from . import client as _client
|
||||
|
||||
return getattr(_client, name)
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
def _do_lazy_import(name: str) -> Any:
|
||||
"""Perform the actual lazy import for a given name."""
|
||||
if name == "run_autonomous_agent":
|
||||
from .agent import run_autonomous_agent
|
||||
|
||||
return run_autonomous_agent
|
||||
if name == "run_followup_planner":
|
||||
from .agent import run_followup_planner
|
||||
|
||||
return run_followup_planner
|
||||
if name == "WorktreeManager":
|
||||
from .worktree import WorktreeManager
|
||||
|
||||
return WorktreeManager
|
||||
raise AssertionError(f"Unknown lazy import name: {name}")
|
||||
|
||||
@@ -466,6 +466,7 @@ def _get_token_from_macos_keychain(config_dir: str | None = None) -> str | None:
|
||||
if not (token.startswith("sk-ant-oat01-") or token.startswith("enc:")):
|
||||
return None
|
||||
|
||||
logger.debug(f"Found token in keychain service '{service_name}'")
|
||||
return token
|
||||
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError, KeyError, Exception):
|
||||
@@ -500,6 +501,7 @@ def _get_token_from_windows_credential_files(
|
||||
token.startswith("sk-ant-oat01-")
|
||||
or token.startswith("enc:")
|
||||
):
|
||||
logger.debug(f"Found token in {cred_path}")
|
||||
return token
|
||||
# If config_dir provided but no token found, don't fall back to default
|
||||
return None
|
||||
@@ -595,6 +597,9 @@ def _get_token_from_linux_secret_service(config_dir: str | None = None) -> str |
|
||||
if token and (
|
||||
token.startswith("sk-ant-oat01-") or token.startswith("enc:")
|
||||
):
|
||||
logger.debug(
|
||||
f"Found token in secret service with label '{target_label}'"
|
||||
)
|
||||
return token
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
@@ -655,6 +660,7 @@ def _get_token_from_config_dir(config_dir: str) -> str | None:
|
||||
if token and (
|
||||
token.startswith("sk-ant-oat01-") or token.startswith("enc:")
|
||||
):
|
||||
logger.debug(f"Found token in {cred_path}")
|
||||
return token
|
||||
except (json.JSONDecodeError, KeyError, Exception) as e:
|
||||
logger.debug(f"Failed to read {cred_path}: {e}")
|
||||
@@ -861,12 +867,9 @@ def _find_git_bash_path() -> str | None:
|
||||
git_paths = result.stdout.strip().splitlines()
|
||||
if git_paths:
|
||||
git_path = git_paths[0].strip()
|
||||
except (
|
||||
subprocess.TimeoutExpired,
|
||||
FileNotFoundError,
|
||||
subprocess.SubprocessError,
|
||||
): # Intentionally suppress errors - best-effort detection with fallback to common paths
|
||||
pass # no-op
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
|
||||
# Intentionally suppress errors - best-effort detection with fallback to common paths
|
||||
pass
|
||||
|
||||
# Method 2: Check common installation paths if 'where' didn't work
|
||||
if not git_path:
|
||||
|
||||
@@ -449,9 +449,6 @@ def create_client(
|
||||
max_thinking_tokens: int | None = None,
|
||||
output_format: dict | None = None,
|
||||
agents: dict | None = None,
|
||||
betas: list[str] | None = None,
|
||||
effort_level: str | None = None,
|
||||
fast_mode: bool = False,
|
||||
) -> ClaudeSDKClient:
|
||||
"""
|
||||
Create a Claude Agent SDK client with multi-layered security.
|
||||
@@ -467,9 +464,10 @@ def create_client(
|
||||
agent_type: Agent type identifier from AGENT_CONFIGS
|
||||
(e.g., 'coder', 'planner', 'qa_reviewer', 'spec_gatherer')
|
||||
max_thinking_tokens: Token budget for extended thinking (None = disabled)
|
||||
- high: 16384 (spec creation, QA review)
|
||||
- medium: 4096 (planning, validation)
|
||||
- low: 1024 (coding)
|
||||
- ultrathink: 16000 (spec creation)
|
||||
- high: 10000 (QA review)
|
||||
- medium: 5000 (planning, validation)
|
||||
- None: disabled (coding)
|
||||
output_format: Optional structured output format for validated JSON responses.
|
||||
Use {"type": "json_schema", "schema": Model.model_json_schema()}
|
||||
See: https://platform.claude.com/docs/en/agent-sdk/structured-outputs
|
||||
@@ -477,15 +475,6 @@ def create_client(
|
||||
Format: {"agent-name": {"description": "...", "prompt": "...",
|
||||
"tools": [...], "model": "inherit"}}
|
||||
See: https://platform.claude.com/docs/en/agent-sdk/subagents
|
||||
betas: Optional list of SDK beta header strings (e.g., ["context-1m-2025-08-07"]
|
||||
for 1M context window). Use get_phase_model_betas() to compute from config.
|
||||
effort_level: Optional effort level for adaptive thinking models (e.g., "low",
|
||||
"medium", "high"). When set, injected as CLAUDE_CODE_EFFORT_LEVEL
|
||||
env var for the SDK subprocess. Only meaningful for models that
|
||||
support adaptive thinking (e.g., Opus 4.6).
|
||||
fast_mode: Enable Fast Mode for faster Opus 4.6 output. When True, injected
|
||||
as CLAUDE_CODE_FAST_MODE=true env var. Requires extra usage enabled
|
||||
on Claude subscription; falls back to standard speed automatically.
|
||||
|
||||
Returns:
|
||||
Configured ClaudeSDKClient
|
||||
@@ -513,24 +502,6 @@ def create_client(
|
||||
if config_dir:
|
||||
logger.info(f"Using CLAUDE_CONFIG_DIR for profile: {config_dir}")
|
||||
|
||||
# Get the config dir for profile-specific credential lookup
|
||||
# CLAUDE_CONFIG_DIR enables per-profile Keychain entries with SHA256-hashed service names
|
||||
config_dir = sdk_env.get("CLAUDE_CONFIG_DIR")
|
||||
|
||||
# Configure SDK authentication (OAuth or API profile mode)
|
||||
configure_sdk_authentication(config_dir)
|
||||
|
||||
if config_dir:
|
||||
logger.info(f"Using CLAUDE_CONFIG_DIR for profile: {config_dir}")
|
||||
|
||||
# Inject effort level for adaptive thinking models (e.g., Opus 4.6)
|
||||
if effort_level:
|
||||
sdk_env["CLAUDE_CODE_EFFORT_LEVEL"] = effort_level
|
||||
|
||||
# Inject fast mode for faster Opus 4.6 output
|
||||
if fast_mode:
|
||||
sdk_env["CLAUDE_CODE_FAST_MODE"] = "true"
|
||||
|
||||
# Debug: Log git-bash path detection on Windows
|
||||
if "CLAUDE_CODE_GIT_BASH_PATH" in sdk_env:
|
||||
logger.info(f"Git Bash path found: {sdk_env['CLAUDE_CODE_GIT_BASH_PATH']}")
|
||||
@@ -694,12 +665,7 @@ def create_client(
|
||||
print(" - Worktree permissions: granted for original project directories")
|
||||
print(" - Bash commands restricted to allowlist")
|
||||
if max_thinking_tokens:
|
||||
thinking_info = f"{max_thinking_tokens:,} tokens"
|
||||
if effort_level:
|
||||
thinking_info += f" + effort={effort_level}"
|
||||
if fast_mode:
|
||||
thinking_info += " + fast mode"
|
||||
print(f" - Extended thinking: {thinking_info}")
|
||||
print(f" - Extended thinking: {max_thinking_tokens:,} tokens")
|
||||
else:
|
||||
print(" - Extended thinking: disabled")
|
||||
|
||||
@@ -868,8 +834,4 @@ def create_client(
|
||||
if agents:
|
||||
options_kwargs["agents"] = agents
|
||||
|
||||
# Add beta headers if specified (e.g., for 1M context window)
|
||||
if betas:
|
||||
options_kwargs["betas"] = betas
|
||||
|
||||
return ClaudeSDKClient(options=ClaudeAgentOptions(**options_kwargs))
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
"""
|
||||
Shared Error Utilities
|
||||
======================
|
||||
|
||||
Common error detection and classification functions used across
|
||||
agent sessions, QA, and other modules.
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def is_tool_concurrency_error(error: Exception) -> bool:
|
||||
"""
|
||||
Check if an error is a 400 tool concurrency error from Claude API.
|
||||
|
||||
Tool concurrency errors occur when too many tools are used simultaneously
|
||||
in a single API request, hitting Claude's concurrent tool use limit.
|
||||
|
||||
Args:
|
||||
error: The exception to check
|
||||
|
||||
Returns:
|
||||
True if this is a tool concurrency error, False otherwise
|
||||
"""
|
||||
error_str = str(error).lower()
|
||||
# Check for 400 status AND tool concurrency keywords
|
||||
return "400" in error_str and (
|
||||
("tool" in error_str and "concurrency" in error_str)
|
||||
or "too many tools" in error_str
|
||||
or "concurrent tool" in error_str
|
||||
)
|
||||
|
||||
|
||||
def is_rate_limit_error(error: Exception) -> bool:
|
||||
"""
|
||||
Check if an error is a rate limit error (429 or similar).
|
||||
|
||||
Rate limit errors occur when the API usage quota is exceeded,
|
||||
either for session limits or weekly limits.
|
||||
|
||||
Args:
|
||||
error: The exception to check
|
||||
|
||||
Returns:
|
||||
True if this is a rate limit error, False otherwise
|
||||
"""
|
||||
error_str = str(error).lower()
|
||||
|
||||
# Check for HTTP 429 with word boundaries to avoid false positives
|
||||
if re.search(r"\b429\b", error_str):
|
||||
return True
|
||||
|
||||
# Check for other rate limit indicators
|
||||
return any(
|
||||
p in error_str
|
||||
for p in [
|
||||
"limit reached",
|
||||
"rate limit",
|
||||
"too many requests",
|
||||
"usage limit",
|
||||
"quota exceeded",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def is_authentication_error(error: Exception) -> bool:
|
||||
"""
|
||||
Check if an error is an authentication error (401, token expired, etc.).
|
||||
|
||||
Authentication errors occur when OAuth tokens are invalid, expired,
|
||||
or have been revoked (e.g., after token refresh on another process).
|
||||
|
||||
Validation approach:
|
||||
- HTTP 401 status code is checked with word boundaries to minimize false positives
|
||||
- Additional string patterns are validated against lowercase error messages
|
||||
- Patterns are designed to match known Claude API and OAuth error formats
|
||||
|
||||
Known false positive risks:
|
||||
- Generic error messages containing "unauthorized" or "access denied" may match
|
||||
even if not related to authentication (e.g., file permission errors)
|
||||
- Error messages containing these keywords in user-provided content could match
|
||||
- Mitigation: HTTP 401 check provides strong signal; string patterns are secondary
|
||||
|
||||
Real-world validation:
|
||||
- Pattern matching has been tested against actual Claude API error responses
|
||||
- False positive rate is acceptable given the recovery mechanism (prompt user to re-auth)
|
||||
- If false positive occurs, user can simply resume without re-authenticating
|
||||
|
||||
Args:
|
||||
error: The exception to check
|
||||
|
||||
Returns:
|
||||
True if this is an authentication error, False otherwise
|
||||
"""
|
||||
error_str = str(error).lower()
|
||||
|
||||
# Check for HTTP 401 with word boundaries to avoid false positives
|
||||
if re.search(r"\b401\b", error_str):
|
||||
return True
|
||||
|
||||
# Check for other authentication indicators
|
||||
# NOTE: "authentication failed" and "authentication error" are more specific patterns
|
||||
# to reduce false positives from generic "authentication" mentions
|
||||
return any(
|
||||
p in error_str
|
||||
for p in [
|
||||
"authentication failed",
|
||||
"authentication error",
|
||||
"unauthorized",
|
||||
"invalid token",
|
||||
"token expired",
|
||||
"authentication_error",
|
||||
"invalid_token",
|
||||
"token_expired",
|
||||
"not authenticated",
|
||||
"http 401",
|
||||
]
|
||||
)
|
||||
@@ -68,11 +68,9 @@ def _run_where_command() -> str | None:
|
||||
and _verify_gh_executable(found_path)
|
||||
):
|
||||
return found_path
|
||||
except (
|
||||
subprocess.TimeoutExpired,
|
||||
OSError,
|
||||
): # 'where' command failed or timed out - fall through to return None
|
||||
pass # no-op
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
# 'where' command failed or timed out - fall through to return None
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -15,13 +15,6 @@ import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
__all__ = [
|
||||
"get_git_executable",
|
||||
"get_isolated_git_env",
|
||||
"run_git",
|
||||
"GIT_ENV_VARS_TO_CLEAR",
|
||||
]
|
||||
|
||||
# Git environment variables that can interfere with worktree operations
|
||||
# when set by pre-commit hooks or other git configurations.
|
||||
# These must be cleared to prevent cross-worktree contamination.
|
||||
@@ -86,8 +79,9 @@ def get_git_executable() -> str:
|
||||
if _cached_git_path is not None:
|
||||
return _cached_git_path
|
||||
|
||||
_cached_git_path = _find_git_executable()
|
||||
return _cached_git_path
|
||||
git_path = _find_git_executable()
|
||||
_cached_git_path = git_path
|
||||
return git_path
|
||||
|
||||
|
||||
def _find_git_executable() -> str:
|
||||
|
||||
@@ -68,11 +68,9 @@ def _run_where_command() -> str | None:
|
||||
and _verify_glab_executable(found_path)
|
||||
):
|
||||
return found_path
|
||||
except (
|
||||
subprocess.TimeoutExpired,
|
||||
OSError,
|
||||
): # 'where' command failed or timed out - fall through to return None
|
||||
pass # no-op
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
# 'where' command failed or timed out - fall through to return None
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ cases gracefully.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -21,9 +20,6 @@ logger = logging.getLogger(__name__)
|
||||
# Track if pipe is broken to avoid repeated failed writes
|
||||
_pipe_broken = False
|
||||
|
||||
# Check if running in tests to prevent closing stdout
|
||||
_IN_TESTS = os.environ.get("AUTO_CLAUDE_TESTS", "0") == "1"
|
||||
|
||||
|
||||
def safe_print(message: str, flush: bool = True) -> None:
|
||||
"""
|
||||
@@ -49,12 +45,10 @@ def safe_print(message: str, flush: bool = True) -> None:
|
||||
# Pipe closed by parent process - this is expected during shutdown
|
||||
_pipe_broken = True
|
||||
# Quietly close stdout to prevent further errors
|
||||
# Skip closing stdout during tests to avoid pytest capture issues
|
||||
if not _IN_TESTS:
|
||||
try:
|
||||
sys.stdout.close()
|
||||
except Exception: # stdout may already be closed
|
||||
pass # no-op
|
||||
try:
|
||||
sys.stdout.close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("Output pipe closed by parent process")
|
||||
except ValueError as e:
|
||||
# Handle writes to closed file (can happen after stdout.close())
|
||||
@@ -68,12 +62,10 @@ def safe_print(message: str, flush: bool = True) -> None:
|
||||
# Handle other pipe-related errors (EPIPE, etc.)
|
||||
if e.errno == 32: # EPIPE - Broken pipe
|
||||
_pipe_broken = True
|
||||
# Skip closing stdout during tests to avoid pytest capture issues
|
||||
if not _IN_TESTS:
|
||||
try:
|
||||
sys.stdout.close()
|
||||
except Exception: # stdout may already be closed
|
||||
pass # no-op
|
||||
try:
|
||||
sys.stdout.close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("Output pipe closed (EPIPE)")
|
||||
else:
|
||||
# Re-raise unexpected OS errors
|
||||
|
||||
@@ -248,8 +248,8 @@ def init_sentry(
|
||||
parsed = float(env_rate)
|
||||
if 0 <= parsed <= 1:
|
||||
traces_sample_rate = parsed
|
||||
except Exception: # Sentry not configured or unavailable
|
||||
pass # no-op
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Configure logging integration to capture errors and warnings
|
||||
logging_integration = LoggingIntegration(
|
||||
|
||||
@@ -44,9 +44,6 @@ def create_simple_client(
|
||||
cwd: Path | None = None,
|
||||
max_turns: int = 1,
|
||||
max_thinking_tokens: int | None = None,
|
||||
betas: list[str] | None = None,
|
||||
effort_level: str | None = None,
|
||||
fast_mode: bool = False,
|
||||
) -> ClaudeSDKClient:
|
||||
"""
|
||||
Create a minimal Claude SDK client for single-turn utility operations.
|
||||
@@ -68,11 +65,6 @@ def create_simple_client(
|
||||
max_turns: Maximum conversation turns (default: 1 for single-turn)
|
||||
max_thinking_tokens: Override thinking budget (None = use agent default from
|
||||
AGENT_CONFIGS, converted using phase_config.THINKING_BUDGET_MAP)
|
||||
betas: Optional list of SDK beta header strings (e.g., ["context-1m-2025-08-07"])
|
||||
effort_level: Optional effort level for adaptive thinking models (e.g., "low",
|
||||
"medium", "high"). Injected as CLAUDE_CODE_EFFORT_LEVEL env var.
|
||||
fast_mode: Enable Fast Mode for faster Opus 4.6 output. Injected as
|
||||
CLAUDE_CODE_FAST_MODE=true env var.
|
||||
|
||||
Returns:
|
||||
Configured ClaudeSDKClient for single-turn operations
|
||||
@@ -116,10 +108,6 @@ def create_simple_client(
|
||||
if max_thinking_tokens is not None:
|
||||
options_kwargs["max_thinking_tokens"] = max_thinking_tokens
|
||||
|
||||
# Add beta headers if specified (e.g., for 1M context window)
|
||||
if betas:
|
||||
options_kwargs["betas"] = betas
|
||||
|
||||
# Optional: Allow CLI path override via environment variable
|
||||
env_cli_path = os.environ.get("CLAUDE_CLI_PATH")
|
||||
if env_cli_path and validate_cli_path(env_cli_path):
|
||||
|
||||
@@ -44,23 +44,30 @@ try:
|
||||
debug_success,
|
||||
debug_verbose,
|
||||
debug_warning,
|
||||
is_debug_enabled,
|
||||
)
|
||||
except ImportError:
|
||||
|
||||
def debug(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
def debug_detailed(*args, **kwargs):
|
||||
pass
|
||||
|
||||
def debug_verbose(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
def debug_success(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
def debug_error(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
def debug_warning(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
def is_debug_enabled():
|
||||
return False
|
||||
|
||||
|
||||
# Import merge system
|
||||
@@ -978,6 +985,10 @@ def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict:
|
||||
debug_warning(MODULE, "Could not find merge base")
|
||||
return result
|
||||
|
||||
_merge_base = (
|
||||
merge_base_result.stdout.strip()
|
||||
) # Reserved for future conflict detection
|
||||
|
||||
# Get commit hashes
|
||||
main_commit_result = run_git(
|
||||
["rev-parse", result["base_branch"]],
|
||||
|
||||
@@ -15,19 +15,11 @@ from ui import (
|
||||
)
|
||||
from worktree import WorktreeManager
|
||||
|
||||
__all__ = [
|
||||
"print_merge_success",
|
||||
"print_conflict_info",
|
||||
"_print_merge_success",
|
||||
"_print_conflict_info",
|
||||
"show_build_summary",
|
||||
"show_changed_files",
|
||||
]
|
||||
|
||||
|
||||
def show_build_summary(manager: WorktreeManager, spec_name: str) -> None:
|
||||
"""Show a summary of what was built."""
|
||||
summary = manager.get_change_summary(spec_name)
|
||||
files = manager.get_changed_files(spec_name)
|
||||
|
||||
total = summary["new_files"] + summary["modified_files"] + summary["deleted_files"]
|
||||
|
||||
@@ -233,5 +225,5 @@ def print_conflict_info(result: dict) -> None:
|
||||
|
||||
|
||||
# Export private names for backward compatibility
|
||||
_print_merge_success = print_merge_success # noqa: F811 (intentional re-export)
|
||||
_print_conflict_info = print_conflict_info # noqa: F811 (intentional re-export)
|
||||
_print_merge_success = print_merge_success
|
||||
_print_conflict_info = print_conflict_info
|
||||
|
||||
@@ -88,6 +88,7 @@ def finalize_workspace(
|
||||
|
||||
# Get the worktree path for test instructions
|
||||
worktree_info = manager.get_worktree_info(spec_name)
|
||||
staging_path = worktree_info.path if worktree_info else None
|
||||
|
||||
# Enhanced menu for post-build options
|
||||
options = [
|
||||
|
||||
@@ -503,7 +503,7 @@ def validate_merged_syntax(
|
||||
return True, "" # Timeout = assume ok
|
||||
except FileNotFoundError:
|
||||
return True, "" # No esbuild = skip validation
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
return True, "" # Other errors = skip validation
|
||||
|
||||
# Python validation
|
||||
@@ -589,7 +589,7 @@ def create_conflict_file_with_git(
|
||||
Path(wt_path).unlink(missing_ok=True)
|
||||
Path(base_path).unlink(missing_ok=True)
|
||||
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
return None, False
|
||||
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ class ParallelMergeResult:
|
||||
class MergeLockError(Exception):
|
||||
"""Raised when a merge lock cannot be acquired."""
|
||||
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
|
||||
class MergeLock:
|
||||
@@ -88,14 +88,12 @@ class MergeLock:
|
||||
fd = os.open(
|
||||
str(self.lock_file),
|
||||
os.O_CREAT | os.O_EXCL | os.O_WRONLY,
|
||||
0o600,
|
||||
0o644,
|
||||
)
|
||||
# Ensure permissions are correct (override umask)
|
||||
os.fchmod(fd, 0o600)
|
||||
# Write PID directly to the file descriptor
|
||||
os.write(fd, str(os.getpid()).encode("utf-8"))
|
||||
os.close(fd)
|
||||
|
||||
# Write our PID to the lock file
|
||||
self.lock_file.write_text(str(os.getpid()), encoding="utf-8")
|
||||
self.acquired = True
|
||||
return self
|
||||
|
||||
@@ -142,7 +140,7 @@ class MergeLock:
|
||||
class SpecNumberLockError(Exception):
|
||||
"""Raised when a spec number lock cannot be acquired."""
|
||||
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
|
||||
class SpecNumberLock:
|
||||
@@ -180,14 +178,12 @@ class SpecNumberLock:
|
||||
fd = os.open(
|
||||
str(self.lock_file),
|
||||
os.O_CREAT | os.O_EXCL | os.O_WRONLY,
|
||||
0o600,
|
||||
0o644,
|
||||
)
|
||||
# Ensure permissions are correct (override umask)
|
||||
os.fchmod(fd, 0o600)
|
||||
# Write PID directly to the file descriptor
|
||||
os.write(fd, str(os.getpid()).encode("utf-8"))
|
||||
os.close(fd)
|
||||
|
||||
# Write our PID to the lock file
|
||||
self.lock_file.write_text(str(os.getpid()), encoding="utf-8")
|
||||
self.acquired = True
|
||||
return self
|
||||
|
||||
@@ -273,7 +269,7 @@ class SpecNumberLock:
|
||||
try:
|
||||
num = int(folder.name[:3])
|
||||
max_num = max(max_num, num)
|
||||
except ValueError: # Folder name doesn't start with a number; skip it
|
||||
pass # no-op: skip invalid folder names
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return max_num
|
||||
|
||||
@@ -31,26 +31,16 @@ from worktree import WorktreeManager
|
||||
from .git_utils import has_uncommitted_changes
|
||||
from .models import WorkspaceMode
|
||||
|
||||
__all__ = [
|
||||
"setup_workspace",
|
||||
"copy_spec_to_worktree",
|
||||
"choose_workspace",
|
||||
"ensure_timeline_hook_installed",
|
||||
"initialize_timeline_tracking",
|
||||
"_ensure_timeline_hook_installed",
|
||||
"_initialize_timeline_tracking",
|
||||
]
|
||||
|
||||
# Import debug utilities
|
||||
try:
|
||||
from debug import debug, debug_warning
|
||||
except ImportError:
|
||||
|
||||
def debug(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
def debug_warning(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
|
||||
# Track if we've already tried to install the git hook this session
|
||||
@@ -585,5 +575,5 @@ def initialize_timeline_tracking(
|
||||
|
||||
|
||||
# Export private functions for backward compatibility
|
||||
_ensure_timeline_hook_installed = ensure_timeline_hook_installed # noqa: F811 (intentional re-export)
|
||||
_initialize_timeline_tracking = initialize_timeline_tracking # noqa: F811 (intentional re-export)
|
||||
_ensure_timeline_hook_installed = ensure_timeline_hook_installed
|
||||
_initialize_timeline_tracking = initialize_timeline_tracking
|
||||
|
||||
@@ -153,7 +153,7 @@ class PushAndCreatePRResult(TypedDict, total=False):
|
||||
class WorktreeError(Exception):
|
||||
"""Error during worktree operations."""
|
||||
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -429,8 +429,8 @@ class WorktreeManager:
|
||||
if current_path.exists() and resolved_path.exists():
|
||||
if os.path.samefile(resolved_path, current_path):
|
||||
return line[len("branch refs/heads/") :]
|
||||
except OSError: # Files may not exist or be inaccessible; fall through to case comparison
|
||||
pass # no-op: fall through to case comparison
|
||||
except OSError:
|
||||
pass
|
||||
# Fallback to normalized case comparison
|
||||
if os.path.normcase(str(resolved_path)) == os.path.normcase(
|
||||
str(current_path)
|
||||
@@ -509,8 +509,8 @@ class WorktreeManager:
|
||||
if resolved_path.exists() and registered_path.exists():
|
||||
if os.path.samefile(resolved_path, registered_path):
|
||||
return True
|
||||
except OSError: # Files may not exist or be inaccessible; fall through to case comparison
|
||||
pass # no-op: fall through to case comparison
|
||||
except OSError:
|
||||
pass
|
||||
# Fallback to normalized case comparison for non-existent paths
|
||||
if os.path.normcase(str(resolved_path)) == os.path.normcase(
|
||||
str(registered_path)
|
||||
@@ -583,11 +583,9 @@ class WorktreeManager:
|
||||
stats["days_since_last_commit"] = (
|
||||
datetime.now() - last_commit_date
|
||||
).days
|
||||
except (
|
||||
ValueError,
|
||||
TypeError,
|
||||
): # If parsing fails, silently continue without date info
|
||||
pass # no-op
|
||||
except (ValueError, TypeError) as e:
|
||||
# If parsing fails, silently continue without date info
|
||||
pass
|
||||
|
||||
# Diff stats
|
||||
result = self._run_git(
|
||||
@@ -1796,8 +1794,9 @@ class WorktreeManager:
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
return data.get("web_url")
|
||||
except json.JSONDecodeError: # If JSON parsing fails, return None
|
||||
pass # no-op
|
||||
except json.JSONDecodeError:
|
||||
# If JSON parsing fails, return None
|
||||
pass
|
||||
except (
|
||||
subprocess.TimeoutExpired,
|
||||
FileNotFoundError,
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Backward compatibility shim - import from spec.critique instead."""
|
||||
|
||||
from spec.critique import * # noqa: F401, F403
|
||||
from spec.critique import * # noqa: F403
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Backward compatibility shim - import from integrations.graphiti.config instead."""
|
||||
|
||||
from integrations.graphiti.config import * # noqa: F401, F403
|
||||
from integrations.graphiti.config import * # noqa: F403
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Backward compatibility shim - import from integrations.graphiti.providers_pkg instead."""
|
||||
|
||||
from integrations.graphiti.providers_pkg import * # noqa: F401, F403
|
||||
from integrations.graphiti.providers_pkg import * # noqa: F403
|
||||
|
||||
@@ -53,22 +53,15 @@ class ProjectAnalyzer:
|
||||
try:
|
||||
with open(project_index_path, encoding="utf-8") as f:
|
||||
index = json.load(f)
|
||||
# Extract tech stack from services (only if index is a dict)
|
||||
if isinstance(index, dict):
|
||||
for service_name, service_info in index.get(
|
||||
"services", {}
|
||||
).items():
|
||||
if service_info.get("language"):
|
||||
context["tech_stack"].append(service_info["language"])
|
||||
if service_info.get("framework"):
|
||||
context["tech_stack"].append(service_info["framework"])
|
||||
context["tech_stack"] = list(set(context["tech_stack"]))
|
||||
except (
|
||||
json.JSONDecodeError,
|
||||
KeyError,
|
||||
AttributeError,
|
||||
): # Invalid JSON; skip
|
||||
pass # no-op: skip invalid files
|
||||
# Extract tech stack from services
|
||||
for service_name, service_info in index.get("services", {}).items():
|
||||
if service_info.get("language"):
|
||||
context["tech_stack"].append(service_info["language"])
|
||||
if service_info.get("framework"):
|
||||
context["tech_stack"].append(service_info["framework"])
|
||||
context["tech_stack"] = list(set(context["tech_stack"]))
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
# Get roadmap context if enabled
|
||||
if self.include_roadmap:
|
||||
@@ -85,8 +78,8 @@ class ProjectAnalyzer:
|
||||
# Get target audience
|
||||
audience = roadmap.get("target_audience", {})
|
||||
context["target_audience"] = audience.get("primary")
|
||||
except (json.JSONDecodeError, KeyError): # Invalid JSON; skip
|
||||
pass # no-op: skip invalid files
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
# Also check discovery for audience
|
||||
discovery_path = (
|
||||
@@ -104,8 +97,8 @@ class ProjectAnalyzer:
|
||||
context["existing_features"] = current_state.get(
|
||||
"existing_features", []
|
||||
)
|
||||
except (json.JSONDecodeError, KeyError): # Invalid JSON; skip
|
||||
pass # no-op: skip invalid files
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
# Get kanban context if enabled
|
||||
if self.include_kanban:
|
||||
|
||||
@@ -29,7 +29,6 @@ class IdeationConfigManager:
|
||||
thinking_level: str = "medium",
|
||||
refresh: bool = False,
|
||||
append: bool = False,
|
||||
fast_mode: bool = False,
|
||||
):
|
||||
"""Initialize configuration manager.
|
||||
|
||||
@@ -65,7 +64,6 @@ class IdeationConfigManager:
|
||||
self.model,
|
||||
self.thinking_level,
|
||||
self.max_ideas_per_type,
|
||||
fast_mode=fast_mode,
|
||||
)
|
||||
self.analyzer = ProjectAnalyzer(
|
||||
self.project_dir,
|
||||
|
||||
@@ -45,8 +45,8 @@ class IdeationFormatter:
|
||||
print_status(
|
||||
f"Preserving {len(existing_ideas)} existing ideas", "info"
|
||||
)
|
||||
except json.JSONDecodeError: # Invalid JSON; skip
|
||||
pass # no-op: treat as empty file
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Collect new ideas from the enabled types
|
||||
new_ideas = []
|
||||
@@ -61,8 +61,8 @@ class IdeationFormatter:
|
||||
ideas = data.get(ideation_type, [])
|
||||
new_ideas.extend(ideas)
|
||||
output_files.append(str(type_file))
|
||||
except (json.JSONDecodeError, KeyError): # Invalid JSON; skip
|
||||
pass # no-op: skip invalid files
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
# In append mode, filter out ideas from types we're regenerating
|
||||
# (to avoid duplicates) and keep ideas from other types
|
||||
@@ -141,6 +141,6 @@ class IdeationFormatter:
|
||||
try:
|
||||
with open(context_file, encoding="utf-8") as f:
|
||||
context_data = json.load(f)
|
||||
except json.JSONDecodeError: # Invalid JSON; skip
|
||||
pass # no-op: treat as empty file
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return context_data
|
||||
|
||||
@@ -17,12 +17,7 @@ from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from client import create_client
|
||||
from phase_config import (
|
||||
get_model_betas,
|
||||
get_thinking_budget,
|
||||
get_thinking_kwargs_for_model,
|
||||
resolve_model_id,
|
||||
)
|
||||
from phase_config import get_thinking_budget, resolve_model_id
|
||||
from ui import print_status
|
||||
|
||||
# Ideation types
|
||||
@@ -64,7 +59,6 @@ class IdeationGenerator:
|
||||
model: str = "sonnet", # Changed from "opus" (fix #433)
|
||||
thinking_level: str = "medium",
|
||||
max_ideas_per_type: int = 5,
|
||||
fast_mode: bool = False,
|
||||
):
|
||||
self.project_dir = Path(project_dir)
|
||||
self.output_dir = Path(output_dir)
|
||||
@@ -72,7 +66,6 @@ class IdeationGenerator:
|
||||
self.thinking_level = thinking_level
|
||||
self.thinking_budget = get_thinking_budget(thinking_level)
|
||||
self.max_ideas_per_type = max_ideas_per_type
|
||||
self.fast_mode = fast_mode
|
||||
self.prompts_dir = Path(__file__).parent.parent / "prompts"
|
||||
|
||||
async def run_agent(
|
||||
|
||||
@@ -302,7 +302,7 @@ Generate up to {self.max_ideas_per_type} {self.generator.get_type_label(ideation
|
||||
Avoid duplicating features that are already planned (see ideation_context.json).
|
||||
Output your ideas to {output_file.name}.
|
||||
"""
|
||||
await self.generator.run_agent(
|
||||
success, output = await self.generator.run_agent(
|
||||
prompt_file,
|
||||
additional_context=context,
|
||||
)
|
||||
|
||||
@@ -46,7 +46,6 @@ class IdeationOrchestrator:
|
||||
thinking_level: str = "medium",
|
||||
refresh: bool = False,
|
||||
append: bool = False,
|
||||
fast_mode: bool = False,
|
||||
):
|
||||
"""Initialize the ideation orchestrator.
|
||||
|
||||
@@ -61,7 +60,6 @@ class IdeationOrchestrator:
|
||||
thinking_level: Thinking level for extended reasoning
|
||||
refresh: Force regeneration of existing files
|
||||
append: Preserve existing ideas when merging
|
||||
fast_mode: Enable Fast Mode for faster Opus 4.6 output
|
||||
"""
|
||||
# Initialize configuration manager
|
||||
self.config_manager = IdeationConfigManager(
|
||||
@@ -75,7 +73,6 @@ class IdeationOrchestrator:
|
||||
thinking_level=thinking_level,
|
||||
refresh=refresh,
|
||||
append=append,
|
||||
fast_mode=fast_mode,
|
||||
)
|
||||
|
||||
# Expose configuration for convenience
|
||||
|
||||
@@ -207,7 +207,7 @@ class ImplementationPlan:
|
||||
# (spec is complete, waiting for user to approve before coding starts)
|
||||
if self.status == "human_review" and self.planStatus == "review":
|
||||
# Keep the plan approval status - don't reset to backlog
|
||||
pass # no-op
|
||||
pass
|
||||
else:
|
||||
self.status = "backlog"
|
||||
self.planStatus = "pending"
|
||||
|
||||
@@ -3,14 +3,9 @@ Integrations Module
|
||||
===================
|
||||
|
||||
External service integrations for Auto Claude.
|
||||
|
||||
Submodules:
|
||||
- linear: Linear issue tracking integration
|
||||
- graphiti: Graphiti knowledge graph integration
|
||||
"""
|
||||
|
||||
# Submodules are accessible via standard Python import mechanism:
|
||||
# from integrations.linear import ...
|
||||
# from integrations.graphiti import ...
|
||||
|
||||
__all__ = []
|
||||
__all__ = [
|
||||
"linear",
|
||||
"graphiti",
|
||||
]
|
||||
|
||||
@@ -3,23 +3,12 @@ Graphiti Integration
|
||||
====================
|
||||
|
||||
Integration with Graphiti knowledge graph for semantic memory.
|
||||
|
||||
Module-level placeholders (with _ prefix) are defined for CodeQL static
|
||||
analysis. The actual exported names (without _ prefix) trigger __getattr__
|
||||
for lazy loading.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
# Config imports don't require graphiti package
|
||||
from .config import GraphitiConfig, validate_graphiti_config
|
||||
|
||||
# Module-level placeholders for CodeQL static analysis.
|
||||
# Use list placeholder to satisfy CodeQL's "defined but not set to None" check.
|
||||
GraphitiMemory: Any = []
|
||||
create_llm_client: Any = []
|
||||
create_embedder: Any = []
|
||||
|
||||
# Lazy imports for components that require graphiti package
|
||||
__all__ = [
|
||||
"GraphitiConfig",
|
||||
"validate_graphiti_config",
|
||||
@@ -29,34 +18,18 @@ __all__ = [
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
def __getattr__(name):
|
||||
"""Lazy import to avoid requiring graphiti package for config-only imports."""
|
||||
private_map = {
|
||||
"GraphitiMemory": "_GraphitiMemory",
|
||||
"create_llm_client": "_create_llm_client",
|
||||
"create_embedder": "_create_embedder",
|
||||
}
|
||||
|
||||
if name in private_map:
|
||||
private_name = private_map[name]
|
||||
globals()[private_name] = _do_lazy_import(name)
|
||||
return globals()[private_name]
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
def _do_lazy_import(name: str) -> Any:
|
||||
"""Perform the actual lazy import for a given name."""
|
||||
if name == "GraphitiMemory":
|
||||
from .memory import GraphitiMemory
|
||||
|
||||
return GraphitiMemory
|
||||
if name == "create_llm_client":
|
||||
elif name == "create_llm_client":
|
||||
from .providers import create_llm_client
|
||||
|
||||
return create_llm_client
|
||||
if name == "create_embedder":
|
||||
elif name == "create_embedder":
|
||||
from .providers import create_embedder
|
||||
|
||||
return create_embedder
|
||||
raise AssertionError(f"Unknown lazy import name: {name}")
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
@@ -145,7 +145,7 @@ class GraphitiConfig:
|
||||
|
||||
# OpenRouter settings (multi-provider aggregator)
|
||||
openrouter_api_key: str = ""
|
||||
openrouter_base_url: str = "https://openrouter.ai/api"
|
||||
openrouter_base_url: str = "https://openrouter.ai/api/v1"
|
||||
openrouter_llm_model: str = "anthropic/claude-sonnet-4"
|
||||
openrouter_embedding_model: str = "openai/text-embedding-3-small"
|
||||
|
||||
@@ -207,10 +207,10 @@ class GraphitiConfig:
|
||||
# OpenRouter settings
|
||||
openrouter_api_key = os.environ.get("OPENROUTER_API_KEY", "")
|
||||
openrouter_base_url = os.environ.get(
|
||||
"OPENROUTER_BASE_URL", "https://openrouter.ai/api"
|
||||
"OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1"
|
||||
)
|
||||
openrouter_llm_model = os.environ.get(
|
||||
"OPENROUTER_LLM_MODEL", "anthropic/claude-3.5-sonnet"
|
||||
"OPENROUTER_LLM_MODEL", "anthropic/claude-sonnet-4"
|
||||
)
|
||||
openrouter_embedding_model = os.environ.get(
|
||||
"OPENROUTER_EMBEDDING_MODEL", "openai/text-embedding-3-small"
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
"""Fixtures for graphiti integration tests."""
|
||||
|
||||
from importlib.util import find_spec
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _check_graphiti_available() -> bool:
|
||||
"""Check if graphiti dependencies are available."""
|
||||
return find_spec("kuzu") is not None or find_spec("real_ladybug") is not None
|
||||
|
||||
|
||||
# Skip all graphiti integration tests if dependencies are not available
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not _check_graphiti_available(),
|
||||
reason="graphiti dependencies (kuzu or real_ladybug) not available",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_path(tmp_path: Path) -> str:
|
||||
"""Provide a temporary database path for testing.
|
||||
|
||||
Args:
|
||||
tmp_path: pytest's built-in temporary directory fixture
|
||||
|
||||
Returns:
|
||||
str: Path to temporary database directory
|
||||
"""
|
||||
db_dir = tmp_path / "graphiti_db"
|
||||
db_dir.mkdir(exist_ok=True)
|
||||
return str(db_dir)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def database() -> str:
|
||||
"""Provide a test database name.
|
||||
|
||||
Returns:
|
||||
str: Test database name
|
||||
"""
|
||||
return "test_db"
|
||||
@@ -22,7 +22,7 @@ see graphiti/graphiti.py.
|
||||
from pathlib import Path
|
||||
|
||||
# Import config utilities
|
||||
from integrations.graphiti.config import (
|
||||
from graphiti_config import (
|
||||
GraphitiConfig,
|
||||
is_graphiti_enabled,
|
||||
)
|
||||
|
||||
@@ -9,10 +9,10 @@ Exception classes for provider-related errors.
|
||||
class ProviderError(Exception):
|
||||
"""Raised when a provider cannot be initialized."""
|
||||
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
|
||||
class ProviderNotInstalled(ProviderError):
|
||||
"""Raised when required packages for a provider are not installed."""
|
||||
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
@@ -35,7 +35,7 @@ def create_openrouter_llm_client(config: "GraphitiConfig") -> Any:
|
||||
>>> from auto_claude.integrations.graphiti.config import GraphitiConfig
|
||||
>>> config = GraphitiConfig(
|
||||
... openrouter_api_key="sk-or-...",
|
||||
... openrouter_llm_model="anthropic/claude-3.5-sonnet"
|
||||
... openrouter_llm_model="anthropic/claude-sonnet-4"
|
||||
... )
|
||||
>>> client = create_openrouter_llm_client(config)
|
||||
"""
|
||||
|
||||
@@ -80,7 +80,7 @@ async def test_llm_connection(config: "GraphitiConfig") -> tuple[bool, str]:
|
||||
from .factory import create_llm_client
|
||||
|
||||
try:
|
||||
_llm_client = create_llm_client(config)
|
||||
llm_client = create_llm_client(config)
|
||||
# Most clients don't have a ping method, so just verify creation succeeded
|
||||
return (
|
||||
True,
|
||||
@@ -112,7 +112,7 @@ async def test_embedder_connection(config: "GraphitiConfig") -> tuple[bool, str]
|
||||
return False, msg
|
||||
|
||||
try:
|
||||
_embedder = create_embedder(config)
|
||||
embedder = create_embedder(config)
|
||||
return (
|
||||
True,
|
||||
f"Embedder created successfully for provider: {config.embedder_provider}",
|
||||
@@ -139,11 +139,6 @@ async def test_ollama_connection(
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
# Normalize URL first (used in both paths)
|
||||
url = base_url.rstrip("/")
|
||||
if url.endswith("/v1"):
|
||||
url = url[:-3]
|
||||
|
||||
try:
|
||||
import aiohttp
|
||||
except ImportError:
|
||||
@@ -152,12 +147,16 @@ async def test_ollama_connection(
|
||||
import urllib.request
|
||||
|
||||
try:
|
||||
# Normalize URL (remove /v1 suffix if present)
|
||||
url = base_url.rstrip("/")
|
||||
if url.endswith("/v1"):
|
||||
url = url[:-3]
|
||||
|
||||
req = urllib.request.Request(f"{url}/api/tags", method="GET")
|
||||
with urllib.request.urlopen(req, timeout=5) as response:
|
||||
status = response.status
|
||||
if status == 200:
|
||||
if response.status == 200:
|
||||
return True, f"Ollama is running at {url}"
|
||||
return False, f"Ollama returned status {status}"
|
||||
return False, f"Ollama returned status {response.status}"
|
||||
except urllib.error.URLError as e:
|
||||
return False, f"Cannot connect to Ollama at {url}: {e.reason}"
|
||||
except Exception as e:
|
||||
@@ -165,20 +164,21 @@ async def test_ollama_connection(
|
||||
|
||||
# Use aiohttp if available
|
||||
try:
|
||||
# Normalize URL
|
||||
url = base_url.rstrip("/")
|
||||
if url.endswith("/v1"):
|
||||
url = url[:-3]
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
try:
|
||||
async with session.get(
|
||||
f"{url}/api/tags", timeout=aiohttp.ClientTimeout(total=5)
|
||||
) as response:
|
||||
status = response.status
|
||||
if status == 200:
|
||||
return True, f"Ollama is running at {url}"
|
||||
return False, f"Ollama returned status {status}"
|
||||
except asyncio.TimeoutError:
|
||||
return False, f"Ollama connection timed out at {url}"
|
||||
except Exception:
|
||||
# Catch aiohttp.ClientError and any other exceptions from session.get
|
||||
return False, f"Cannot connect to Ollama at {url}"
|
||||
except Exception:
|
||||
# Catch any exceptions from ClientSession creation
|
||||
return False, f"Cannot connect to Ollama at {url}"
|
||||
async with session.get(
|
||||
f"{url}/api/tags", timeout=aiohttp.ClientTimeout(total=5)
|
||||
) as response:
|
||||
if response.status == 200:
|
||||
return True, f"Ollama is running at {url}"
|
||||
return False, f"Ollama returned status {response.status}"
|
||||
except asyncio.TimeoutError:
|
||||
return False, f"Ollama connection timed out at {url}"
|
||||
except aiohttp.ClientError as e:
|
||||
return False, f"Cannot connect to Ollama at {url}: {e}"
|
||||
except Exception as e:
|
||||
return False, f"Ollama connection error: {e}"
|
||||
|
||||
@@ -25,14 +25,6 @@ from .schema import (
|
||||
GroupIdMode,
|
||||
)
|
||||
|
||||
# Import kuzu_driver_patched for test mocking support
|
||||
# This module is used internally but needs to be accessible for patching
|
||||
try:
|
||||
from . import kuzu_driver_patched # noqa: F401
|
||||
except ImportError:
|
||||
# kuzu_driver_patched may not be available if real_ladybug is not installed
|
||||
kuzu_driver_patched = None # type: ignore
|
||||
|
||||
# Re-export for convenience
|
||||
__all__ = [
|
||||
"GraphitiMemory",
|
||||
|
||||
@@ -362,7 +362,7 @@ class GraphitiMemory:
|
||||
|
||||
if result and self.state:
|
||||
# Episode count updated in queries module
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
|
||||
@@ -117,8 +117,9 @@ def create_patched_kuzu_driver(db: str = ":memory:", max_concurrent_queries: int
|
||||
logger.debug(
|
||||
f"Dropped existing FTS index: {index_name}"
|
||||
)
|
||||
except Exception: # Index might not exist, that's fine
|
||||
pass # no-op
|
||||
except Exception:
|
||||
# Index might not exist, that's fine
|
||||
pass
|
||||
|
||||
# Create the FTS index
|
||||
conn.execute(query)
|
||||
@@ -172,7 +173,4 @@ def create_patched_kuzu_driver(db: str = ":memory:", max_concurrent_queries: int
|
||||
# Run the parent schema setup (creates tables)
|
||||
super().setup_schema()
|
||||
|
||||
# Export the class at module level for tests to import
|
||||
globals()["PatchedKuzuDriver"] = PatchedKuzuDriver
|
||||
|
||||
return PatchedKuzuDriver(db=db, max_concurrent_queries=max_concurrent_queries)
|
||||
|
||||
@@ -31,9 +31,6 @@ Usage:
|
||||
python integrations/graphiti/test_graphiti_memory.py --test ollama
|
||||
"""
|
||||
|
||||
# Exclude this file from pytest collection - this is a standalone test script
|
||||
__test__ = False
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
@@ -65,8 +62,8 @@ def apply_ladybug_monkeypatch():
|
||||
|
||||
sys.modules["kuzu"] = real_ladybug
|
||||
return True
|
||||
except Exception: # Test cleanup, ignore errors
|
||||
pass # no-op
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Try native kuzu as fallback
|
||||
try:
|
||||
@@ -122,15 +119,8 @@ async def test_ladybugdb_connection(db_path: str, database: str) -> bool:
|
||||
|
||||
# Test basic query
|
||||
result = conn.execute("RETURN 1 + 1 as test")
|
||||
query_result = result.get_as_df()
|
||||
|
||||
# Handle both list and DataFrame return types from get_as_df()
|
||||
if isinstance(query_result, list):
|
||||
# real-ladybug returns a list of dicts
|
||||
test_value = query_result[0].get("test") if query_result else None
|
||||
else:
|
||||
# pandas DataFrame
|
||||
test_value = query_result["test"].iloc[0] if len(query_result) > 0 else None
|
||||
df = result.get_as_df()
|
||||
test_value = df["test"].iloc[0] if len(df) > 0 else None
|
||||
|
||||
if test_value == 2:
|
||||
print_result("Connection", "SUCCESS - Database responds correctly", True)
|
||||
@@ -251,29 +241,15 @@ async def test_keyword_search(db_path: str, database: str) -> bool:
|
||||
|
||||
try:
|
||||
result = conn.execute(query)
|
||||
query_result = result.get_as_df()
|
||||
df = result.get_as_df()
|
||||
|
||||
# Handle both list and DataFrame return types from get_as_df()
|
||||
if isinstance(query_result, list):
|
||||
# real-ladybug returns a list of dicts
|
||||
print(f" Found {len(query_result)} results:")
|
||||
for row in query_result:
|
||||
name = row.get("name", "unknown")[:50]
|
||||
content = str(row.get("content", ""))[:60]
|
||||
print(f" - {name}: {content}...")
|
||||
print_result(
|
||||
"Keyword Search", f"Found {len(query_result)} results", True
|
||||
)
|
||||
else:
|
||||
# pandas DataFrame
|
||||
print(f" Found {len(query_result)} results:")
|
||||
for _, row in query_result.iterrows():
|
||||
name = row.get("name", "unknown")[:50]
|
||||
content = str(row.get("content", ""))[:60]
|
||||
print(f" - {name}: {content}...")
|
||||
print_result(
|
||||
"Keyword Search", f"Found {len(query_result)} results", True
|
||||
)
|
||||
print(f" Found {len(df)} results:")
|
||||
for _, row in df.iterrows():
|
||||
name = row.get("name", "unknown")[:50]
|
||||
content = str(row.get("content", ""))[:60]
|
||||
print(f" - {name}: {content}...")
|
||||
|
||||
print_result("Keyword Search", f"Found {len(df)} results", True)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
@@ -587,15 +563,8 @@ async def test_database_contents(db_path: str, database: str) -> bool:
|
||||
for table in tables_to_check:
|
||||
try:
|
||||
result = conn.execute(f"MATCH (n:{table}) RETURN count(n) as count")
|
||||
query_result = result.get_as_df()
|
||||
|
||||
# Handle both list and DataFrame return types from get_as_df()
|
||||
if isinstance(query_result, list):
|
||||
count = query_result[0].get("count", 0) if query_result else 0
|
||||
else:
|
||||
count = (
|
||||
query_result["count"].iloc[0] if len(query_result) > 0 else 0
|
||||
)
|
||||
df = result.get_as_df()
|
||||
count = df["count"].iloc[0] if len(df) > 0 else 0
|
||||
print(f" {table}: {count} nodes")
|
||||
except Exception as e:
|
||||
if "not exist" in str(e).lower() or "cannot" in str(e).lower():
|
||||
@@ -613,21 +582,13 @@ async def test_database_contents(db_path: str, database: str) -> bool:
|
||||
ORDER BY e.created_at DESC
|
||||
LIMIT 5
|
||||
""")
|
||||
query_result = result.get_as_df()
|
||||
df = result.get_as_df()
|
||||
|
||||
# Handle both list and DataFrame return types from get_as_df()
|
||||
if isinstance(query_result, list):
|
||||
if len(query_result) == 0:
|
||||
print(" (none)")
|
||||
else:
|
||||
for row in query_result:
|
||||
print(f" - {row.get('name', 'unknown')}")
|
||||
if len(df) == 0:
|
||||
print(" (none)")
|
||||
else:
|
||||
if len(query_result) == 0:
|
||||
print(" (none)")
|
||||
else:
|
||||
for _, row in query_result.iterrows():
|
||||
print(f" - {row.get('name', 'unknown')}")
|
||||
for _, row in df.iterrows():
|
||||
print(f" - {row.get('name', 'unknown')}")
|
||||
except Exception as e:
|
||||
if "Episodic" in str(e):
|
||||
print(" (table not created yet)")
|
||||
|
||||
@@ -45,9 +45,6 @@ Usage:
|
||||
python integrations/graphiti/test_ollama_embedding_memory.py --test full-cycle
|
||||
"""
|
||||
|
||||
# Exclude this file from pytest collection - this is a standalone test script
|
||||
__test__ = False
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
@@ -108,8 +105,8 @@ def apply_ladybug_monkeypatch():
|
||||
|
||||
sys.modules["kuzu"] = real_ladybug
|
||||
return True
|
||||
except Exception: # Test cleanup, ignore errors
|
||||
pass # no-op
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Try native kuzu as fallback
|
||||
try:
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Backward compatibility shim - import from integrations.linear.config instead."""
|
||||
|
||||
from integrations.linear.config import * # noqa: F401, F403
|
||||
from integrations.linear.config import * # noqa: F403
|
||||
|
||||
@@ -45,48 +45,9 @@ Usage Examples:
|
||||
from memory import is_graphiti_memory_enabled
|
||||
if is_graphiti_memory_enabled():
|
||||
# Graphiti will automatically store data alongside file-based memory
|
||||
pass # no-op
|
||||
pass
|
||||
"""
|
||||
|
||||
import io
|
||||
import sys
|
||||
|
||||
# Configure safe encoding on Windows to handle Unicode characters in output
|
||||
if sys.platform == "win32":
|
||||
for _stream_name in ("stdout", "stderr"):
|
||||
_stream = getattr(sys, _stream_name)
|
||||
# Method 1: Try reconfigure (works for TTY)
|
||||
if hasattr(_stream, "reconfigure"):
|
||||
try:
|
||||
_stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
continue
|
||||
except (
|
||||
AttributeError,
|
||||
io.UnsupportedOperation,
|
||||
OSError,
|
||||
): # Stream doesn't support reconfigure
|
||||
pass # no-op
|
||||
# Method 2: Wrap with TextIOWrapper for piped output
|
||||
try:
|
||||
if hasattr(_stream, "buffer"):
|
||||
_new_stream = io.TextIOWrapper(
|
||||
_stream.buffer,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
line_buffering=True,
|
||||
)
|
||||
setattr(sys, _stream_name, _new_stream)
|
||||
except (
|
||||
AttributeError,
|
||||
io.UnsupportedOperation,
|
||||
OSError,
|
||||
): # Stream doesn't support wrapper
|
||||
pass # no-op
|
||||
# Clean up temporary variables
|
||||
del _stream_name, _stream
|
||||
if "_new_stream" in dir():
|
||||
del _new_stream
|
||||
|
||||
# Re-export all public functions from the memory package
|
||||
from memory import (
|
||||
append_gotcha,
|
||||
|
||||
@@ -22,13 +22,15 @@ refactored ai_resolver package.
|
||||
from __future__ import annotations
|
||||
|
||||
# Re-export all public APIs from the ai_resolver package
|
||||
from .ai_resolver.resolver import (
|
||||
AICallFunction,
|
||||
from .ai_resolver import (
|
||||
AIResolver,
|
||||
ConflictContext,
|
||||
create_claude_resolver,
|
||||
)
|
||||
|
||||
# For backwards compatibility, also expose the AICallFunction type
|
||||
from .ai_resolver.resolver import AICallFunction
|
||||
|
||||
__all__ = [
|
||||
"AIResolver",
|
||||
"ConflictContext",
|
||||
|
||||
@@ -28,8 +28,7 @@ auto_merger module. The actual implementation has been split into:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Re-export for backward compatibility from the actual implementation modules
|
||||
from .auto_merger.context import MergeContext
|
||||
from .auto_merger.merger import AutoMerger
|
||||
# Re-export for backward compatibility
|
||||
from .auto_merger import AutoMerger, MergeContext
|
||||
|
||||
__all__ = ["AutoMerger", "MergeContext"]
|
||||
|
||||
@@ -27,4 +27,4 @@ class MergeStrategyHandler(ABC):
|
||||
Returns:
|
||||
MergeResult with merged content or error
|
||||
"""
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
@@ -44,7 +44,7 @@ class HooksStrategy(MergeStrategyHandler):
|
||||
for change in other_changes:
|
||||
if change.content_after:
|
||||
# This is a simplification - in production we'd need smarter merging
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
return MergeResult(
|
||||
decision=MergeDecision.AUTO_MERGED,
|
||||
|
||||
@@ -77,7 +77,7 @@ class OrderByTimeStrategy(MergeStrategyHandler):
|
||||
)
|
||||
elif change.content_after and not change.content_before:
|
||||
# Addition - handled by other strategies
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
return MergeResult(
|
||||
decision=MergeDecision.AUTO_MERGED,
|
||||
|
||||
@@ -32,13 +32,13 @@ try:
|
||||
except ImportError:
|
||||
|
||||
def debug(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
def debug_detailed(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
def debug_verbose(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -50,10 +50,10 @@ try:
|
||||
except ImportError:
|
||||
|
||||
def debug(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
def debug_success(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -13,9 +13,9 @@ The actual implementation has been modularized into:
|
||||
- file_evolution/tracker.py: Main FileEvolutionTracker class
|
||||
|
||||
For new code, prefer importing directly from the package:
|
||||
from .file_evolution.tracker import FileEvolutionTracker
|
||||
from .file_evolution import FileEvolutionTracker
|
||||
"""
|
||||
|
||||
from .file_evolution.tracker import FileEvolutionTracker
|
||||
from .file_evolution import FileEvolutionTracker
|
||||
|
||||
__all__ = ["FileEvolutionTracker"]
|
||||
|
||||
@@ -24,10 +24,10 @@ try:
|
||||
except ImportError:
|
||||
|
||||
def debug(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
def debug_success(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -25,10 +25,10 @@ try:
|
||||
except ImportError:
|
||||
|
||||
def debug(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
def debug_warning(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -27,10 +27,10 @@ try:
|
||||
except ImportError:
|
||||
|
||||
def debug(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
def debug_success(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -151,6 +151,8 @@ def combine_non_conflicting_changes(
|
||||
other.append(change)
|
||||
|
||||
# Apply in order: imports, then modifications, then functions, then other
|
||||
ext = Path(file_path).suffix.lower()
|
||||
|
||||
# Add imports
|
||||
if imports:
|
||||
# Content is already normalized to LF, so only check for \n
|
||||
@@ -206,15 +208,15 @@ def find_import_end(lines: list[str], file_path: str) -> int:
|
||||
Returns:
|
||||
Index where imports end (insert position for new imports)
|
||||
"""
|
||||
file_ext = Path(file_path).suffix.lower()
|
||||
ext = Path(file_path).suffix.lower()
|
||||
last_import = 0
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if file_ext == ".py":
|
||||
if ext == ".py":
|
||||
if stripped.startswith(("import ", "from ")):
|
||||
last_import = i + 1
|
||||
elif file_ext in {".js", ".jsx", ".ts", ".tsx"}:
|
||||
elif ext in {".js", ".jsx", ".ts", ".tsx"}:
|
||||
if stripped.startswith("import "):
|
||||
last_import = i + 1
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ class MergePipeline:
|
||||
Returns:
|
||||
MergeResult with merged content or conflict info
|
||||
"""
|
||||
task_ids = [s.task_id for s in task_snapshots]
|
||||
logger.info(f"Merging {file_path} with {len(task_snapshots)} task(s)")
|
||||
|
||||
if progress_callback:
|
||||
|
||||
@@ -51,29 +51,33 @@ try:
|
||||
debug_success,
|
||||
debug_verbose,
|
||||
debug_warning,
|
||||
is_debug_enabled,
|
||||
)
|
||||
except ImportError:
|
||||
|
||||
def debug(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
def debug_detailed(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
def debug_verbose(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
def debug_success(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
def debug_error(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
def debug_warning(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
def debug_section(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
def is_debug_enabled():
|
||||
return False
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -66,9 +66,7 @@ class MergeProgressCallback(Protocol):
|
||||
percent: int,
|
||||
message: str,
|
||||
details: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""no-op (abstract method)"""
|
||||
...
|
||||
) -> None: ...
|
||||
|
||||
|
||||
def emit_progress(
|
||||
|
||||
@@ -11,31 +11,35 @@ rather than line-level diffs.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from .types import FileAnalysis
|
||||
|
||||
__all__ = ["SemanticAnalyzer", "analyze_with_regex"]
|
||||
|
||||
# Import debug utilities
|
||||
try:
|
||||
from debug import (
|
||||
debug,
|
||||
debug_detailed,
|
||||
debug_success,
|
||||
debug_verbose,
|
||||
)
|
||||
except ImportError:
|
||||
# Fallback if debug module not available
|
||||
def debug(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
def debug_detailed(*args, **kwargs):
|
||||
pass
|
||||
|
||||
def debug_verbose(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
def debug_error(*args, **kwargs):
|
||||
pass # no-op
|
||||
def debug_success(*args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
MODULE = "merge.semantic_analyzer"
|
||||
|
||||
# Import regex-based analyzer
|
||||
|
||||
@@ -23,11 +23,17 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Import debug utilities
|
||||
try:
|
||||
from debug import debug_warning
|
||||
from debug import debug, debug_error, debug_warning
|
||||
except ImportError:
|
||||
|
||||
def debug(*args, **kwargs):
|
||||
pass
|
||||
|
||||
def debug_error(*args, **kwargs):
|
||||
pass
|
||||
|
||||
def debug_warning(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
|
||||
MODULE = "merge.timeline_git"
|
||||
@@ -168,8 +174,8 @@ class TimelineGitHelper:
|
||||
else None
|
||||
)
|
||||
|
||||
except Exception: # Non-critical error; continue
|
||||
pass # no-op: non-critical, skip error
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return info
|
||||
|
||||
@@ -300,8 +306,8 @@ class TimelineGitHelper:
|
||||
if "/" in upstream:
|
||||
return upstream.split("/", 1)[1]
|
||||
return upstream
|
||||
except Exception: # Non-critical error; continue
|
||||
pass # no-op: non-critical, skip error
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for branch in ["main", "master", "develop"]:
|
||||
try:
|
||||
|
||||
@@ -29,7 +29,7 @@ try:
|
||||
except ImportError:
|
||||
|
||||
def debug(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
|
||||
MODULE = "merge.timeline_persistence"
|
||||
|
||||
@@ -36,13 +36,13 @@ try:
|
||||
except ImportError:
|
||||
|
||||
def debug(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
def debug_success(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
def debug_warning(*args, **kwargs):
|
||||
pass # no-op
|
||||
pass
|
||||
|
||||
|
||||
MODULE = "merge.timeline_tracker"
|
||||
|
||||
@@ -12,46 +12,9 @@ Usage:
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Configure safe encoding on Windows to handle Unicode characters in output
|
||||
if sys.platform == "win32":
|
||||
for _stream_name in ("stdout", "stderr"):
|
||||
_stream = getattr(sys, _stream_name)
|
||||
# Method 1: Try reconfigure (works for TTY)
|
||||
if hasattr(_stream, "reconfigure"):
|
||||
try:
|
||||
_stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
continue
|
||||
except (
|
||||
AttributeError,
|
||||
io.UnsupportedOperation,
|
||||
OSError,
|
||||
): # Stream doesn't support reconfigure
|
||||
pass # no-op
|
||||
# Method 2: Wrap with TextIOWrapper for piped output
|
||||
try:
|
||||
if hasattr(_stream, "buffer"):
|
||||
_new_stream = io.TextIOWrapper(
|
||||
_stream.buffer,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
line_buffering=True,
|
||||
)
|
||||
setattr(sys, _stream_name, _new_stream)
|
||||
except (
|
||||
AttributeError,
|
||||
io.UnsupportedOperation,
|
||||
OSError,
|
||||
): # Stream doesn't support wrapper
|
||||
pass # no-op
|
||||
# Clean up temporary variables
|
||||
del _stream_name, _stream
|
||||
if "_new_stream" in dir():
|
||||
del _new_stream
|
||||
|
||||
from .file_timeline import FileTimelineTracker
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ Output:
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
@@ -23,42 +22,6 @@ import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
# Configure safe encoding on Windows to handle Unicode characters in output
|
||||
if sys.platform == "win32":
|
||||
for _stream_name in ("stdout", "stderr"):
|
||||
_stream = getattr(sys, _stream_name)
|
||||
# Method 1: Try reconfigure (works for TTY)
|
||||
if hasattr(_stream, "reconfigure"):
|
||||
try:
|
||||
_stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
continue
|
||||
except (
|
||||
AttributeError,
|
||||
io.UnsupportedOperation,
|
||||
OSError,
|
||||
): # Stream doesn't support reconfigure
|
||||
pass # no-op
|
||||
# Method 2: Wrap with TextIOWrapper for piped output
|
||||
try:
|
||||
if hasattr(_stream, "buffer"):
|
||||
_new_stream = io.TextIOWrapper(
|
||||
_stream.buffer,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
line_buffering=True,
|
||||
)
|
||||
setattr(sys, _stream_name, _new_stream)
|
||||
except (
|
||||
AttributeError,
|
||||
io.UnsupportedOperation,
|
||||
OSError,
|
||||
): # Stream doesn't support wrapper
|
||||
pass # no-op
|
||||
# Clean up temporary variables
|
||||
del _stream_name, _stream
|
||||
if "_new_stream" in dir():
|
||||
del _new_stream
|
||||
|
||||
DEFAULT_OLLAMA_URL = "http://localhost:11434"
|
||||
|
||||
# Minimum Ollama version required for newer embedding models (qwen3-embedding, etc.)
|
||||
@@ -214,7 +177,7 @@ def fetch_ollama_api(base_url: str, endpoint: str, timeout: int = 5) -> dict | N
|
||||
|
||||
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||||
return json.loads(response.read().decode())
|
||||
except urllib.error.URLError:
|
||||
except urllib.error.URLError as e:
|
||||
return None
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
@@ -541,7 +504,7 @@ def cmd_pull_model(args) -> None:
|
||||
)
|
||||
elif progress.get("status") == "success":
|
||||
# Download complete
|
||||
pass # no-op
|
||||
pass
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
|
||||
+19
-168
@@ -12,44 +12,30 @@ from pathlib import Path
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
# Model shorthand to full model ID mapping
|
||||
# Values must match apps/frontend/src/shared/constants/models.ts MODEL_ID_MAP
|
||||
MODEL_ID_MAP: dict[str, str] = {
|
||||
"opus": "claude-opus-4-6",
|
||||
"opus-1m": "claude-opus-4-6",
|
||||
"opus": "claude-opus-4-5-20251101",
|
||||
"sonnet": "claude-sonnet-4-5-20250929",
|
||||
"haiku": "claude-haiku-4-5-20251001",
|
||||
}
|
||||
|
||||
# Model shorthand to required SDK beta headers
|
||||
# Maps model shorthands that need special beta flags (e.g., 1M context window)
|
||||
MODEL_BETAS_MAP: dict[str, list[str]] = {
|
||||
"opus-1m": ["context-1m-2025-08-07"],
|
||||
}
|
||||
|
||||
# Thinking level to budget tokens mapping
|
||||
# Values must match apps/frontend/src/shared/constants/models.ts THINKING_BUDGET_MAP
|
||||
THINKING_BUDGET_MAP: dict[str, int] = {
|
||||
# Thinking level to budget tokens mapping (None = no extended thinking)
|
||||
# Values must match auto-claude-ui/src/shared/constants/models.ts THINKING_BUDGET_MAP
|
||||
THINKING_BUDGET_MAP: dict[str, int | None] = {
|
||||
"none": None,
|
||||
"low": 1024,
|
||||
"medium": 4096, # Moderate analysis
|
||||
"high": 16384, # Deep thinking for QA review
|
||||
"ultrathink": 63999, # Maximum reasoning depth (API requires max_tokens >= budget + 1, so 63999 + 1 = 64000 limit)
|
||||
}
|
||||
|
||||
# Effort level mapping for adaptive thinking models (e.g., Opus 4.6)
|
||||
# These models support CLAUDE_CODE_EFFORT_LEVEL env var for effort-based routing
|
||||
EFFORT_LEVEL_MAP: dict[str, str] = {"low": "low", "medium": "medium", "high": "high"}
|
||||
|
||||
# Models that support adaptive thinking via effort level (env var)
|
||||
# These models get both max_thinking_tokens AND effort_level
|
||||
ADAPTIVE_THINKING_MODELS: set[str] = {"claude-opus-4-6"}
|
||||
|
||||
# Spec runner phase-specific thinking levels
|
||||
# Heavy phases use high for deep analysis
|
||||
# Heavy phases use ultrathink for deep analysis
|
||||
# Light phases use medium after compaction
|
||||
SPEC_PHASE_THINKING_LEVELS: dict[str, str] = {
|
||||
# Heavy phases - high (discovery, spec creation, self-critique)
|
||||
"discovery": "high",
|
||||
"spec_writing": "high",
|
||||
"self_critique": "high",
|
||||
# Heavy phases - ultrathink (discovery, spec creation, self-critique)
|
||||
"discovery": "ultrathink",
|
||||
"spec_writing": "ultrathink",
|
||||
"self_critique": "ultrathink",
|
||||
# Light phases - medium (after first invocation with compaction)
|
||||
"requirements": "medium",
|
||||
"research": "medium",
|
||||
@@ -99,7 +85,6 @@ class TaskMetadataConfig(TypedDict, total=False):
|
||||
phaseThinking: PhaseThinkingConfig
|
||||
model: str
|
||||
thinkingLevel: str
|
||||
fastMode: bool
|
||||
|
||||
|
||||
Phase = Literal["spec", "planning", "coding", "qa"]
|
||||
@@ -127,7 +112,6 @@ def resolve_model_id(model: str) -> str:
|
||||
"haiku": "ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
"sonnet": "ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
"opus": "ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
"opus-1m": "ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
}
|
||||
env_var = env_var_map.get(model)
|
||||
if env_var:
|
||||
@@ -142,31 +126,15 @@ def resolve_model_id(model: str) -> str:
|
||||
return model
|
||||
|
||||
|
||||
def get_model_betas(model_short: str) -> list[str]:
|
||||
"""
|
||||
Get required SDK beta headers for a model shorthand.
|
||||
|
||||
Some model configurations (e.g., opus-1m for 1M context window) require
|
||||
passing beta headers to the Claude Agent SDK.
|
||||
|
||||
Args:
|
||||
model_short: Model shorthand (e.g., 'opus', 'opus-1m', 'sonnet')
|
||||
|
||||
Returns:
|
||||
List of beta header strings, or empty list if none required
|
||||
"""
|
||||
return MODEL_BETAS_MAP.get(model_short, [])
|
||||
|
||||
|
||||
def get_thinking_budget(thinking_level: str) -> int:
|
||||
def get_thinking_budget(thinking_level: str) -> int | None:
|
||||
"""
|
||||
Get the thinking budget for a thinking level.
|
||||
|
||||
Args:
|
||||
thinking_level: Thinking level (low, medium, high)
|
||||
thinking_level: Thinking level (none, low, medium, high, ultrathink)
|
||||
|
||||
Returns:
|
||||
Token budget for extended thinking
|
||||
Token budget or None for no extended thinking
|
||||
"""
|
||||
import logging
|
||||
|
||||
@@ -246,43 +214,6 @@ def get_phase_model(
|
||||
return resolve_model_id(DEFAULT_PHASE_MODELS[phase])
|
||||
|
||||
|
||||
def get_phase_model_betas(
|
||||
spec_dir: Path,
|
||||
phase: Phase,
|
||||
cli_model: str | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Get required SDK beta headers for the model selected for a specific phase.
|
||||
|
||||
Uses the same priority logic as get_phase_model() to determine which model
|
||||
shorthand is selected, then looks up any required beta headers.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
phase: Execution phase (spec, planning, coding, qa)
|
||||
cli_model: Model from CLI argument (optional)
|
||||
|
||||
Returns:
|
||||
List of beta header strings, or empty list if none required
|
||||
"""
|
||||
# Determine the model shorthand (before resolution to full ID)
|
||||
if cli_model:
|
||||
return get_model_betas(cli_model)
|
||||
|
||||
metadata = load_task_metadata(spec_dir)
|
||||
|
||||
if metadata:
|
||||
if metadata.get("isAutoProfile") and metadata.get("phaseModels"):
|
||||
phase_models = metadata["phaseModels"]
|
||||
model_short = phase_models.get(phase, DEFAULT_PHASE_MODELS[phase])
|
||||
return get_model_betas(model_short)
|
||||
|
||||
if metadata.get("model"):
|
||||
return get_model_betas(metadata["model"])
|
||||
|
||||
return get_model_betas(DEFAULT_PHASE_MODELS[phase])
|
||||
|
||||
|
||||
def get_phase_thinking(
|
||||
spec_dir: Path,
|
||||
phase: Phase,
|
||||
@@ -330,7 +261,7 @@ def get_phase_thinking_budget(
|
||||
spec_dir: Path,
|
||||
phase: Phase,
|
||||
cli_thinking: str | None = None,
|
||||
) -> int:
|
||||
) -> int | None:
|
||||
"""
|
||||
Get the thinking budget tokens for a specific execution phase.
|
||||
|
||||
@@ -340,7 +271,7 @@ def get_phase_thinking_budget(
|
||||
cli_thinking: Thinking level from CLI argument (optional)
|
||||
|
||||
Returns:
|
||||
Token budget for extended thinking
|
||||
Token budget or None for no extended thinking
|
||||
"""
|
||||
thinking_level = get_phase_thinking(spec_dir, phase, cli_thinking)
|
||||
return get_thinking_budget(thinking_level)
|
||||
@@ -351,7 +282,7 @@ def get_phase_config(
|
||||
phase: Phase,
|
||||
cli_model: str | None = None,
|
||||
cli_thinking: str | None = None,
|
||||
) -> tuple[str, str, int]:
|
||||
) -> tuple[str, str, int | None]:
|
||||
"""
|
||||
Get the full configuration for a specific execution phase.
|
||||
|
||||
@@ -371,87 +302,7 @@ def get_phase_config(
|
||||
return model_id, thinking_level, thinking_budget
|
||||
|
||||
|
||||
def is_adaptive_model(model_id: str) -> bool:
|
||||
"""
|
||||
Check if a model supports adaptive thinking via effort level.
|
||||
|
||||
Adaptive models support the CLAUDE_CODE_EFFORT_LEVEL environment variable
|
||||
for effort-based routing in addition to max_thinking_tokens.
|
||||
|
||||
Args:
|
||||
model_id: Full model ID (e.g., 'claude-opus-4-6')
|
||||
|
||||
Returns:
|
||||
True if the model supports adaptive thinking
|
||||
"""
|
||||
return model_id in ADAPTIVE_THINKING_MODELS
|
||||
|
||||
|
||||
def get_thinking_kwargs_for_model(model_id: str, thinking_level: str) -> dict:
|
||||
"""
|
||||
Get thinking-related kwargs for create_client() based on model type.
|
||||
|
||||
For adaptive models (Opus 4.6): returns both max_thinking_tokens and effort_level.
|
||||
For other models (Sonnet, Haiku): returns only max_thinking_tokens.
|
||||
|
||||
Args:
|
||||
model_id: Full model ID (e.g., 'claude-opus-4-6')
|
||||
thinking_level: Thinking level string (low, medium, high)
|
||||
|
||||
Returns:
|
||||
Dict with 'max_thinking_tokens' and optionally 'effort_level'
|
||||
"""
|
||||
kwargs: dict = {"max_thinking_tokens": get_thinking_budget(thinking_level)}
|
||||
if is_adaptive_model(model_id):
|
||||
kwargs["effort_level"] = EFFORT_LEVEL_MAP.get(thinking_level, "medium")
|
||||
return kwargs
|
||||
|
||||
|
||||
def get_phase_client_thinking_kwargs(
|
||||
spec_dir: Path,
|
||||
phase: Phase,
|
||||
phase_model: str,
|
||||
cli_thinking: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Get thinking kwargs for create_client() for a specific execution phase.
|
||||
|
||||
Combines get_phase_thinking() and get_thinking_kwargs_for_model() to produce
|
||||
the correct kwargs dict based on phase config and model capabilities.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
phase: Execution phase (spec, planning, coding, qa)
|
||||
phase_model: Resolved full model ID for this phase
|
||||
cli_thinking: Thinking level from CLI argument (optional)
|
||||
|
||||
Returns:
|
||||
Dict with 'max_thinking_tokens' and optionally 'effort_level'
|
||||
"""
|
||||
thinking_level = get_phase_thinking(spec_dir, phase, cli_thinking)
|
||||
return get_thinking_kwargs_for_model(phase_model, thinking_level)
|
||||
|
||||
|
||||
def get_fast_mode(spec_dir: Path) -> bool:
|
||||
"""
|
||||
Check if Fast Mode is enabled for this task.
|
||||
|
||||
Fast Mode provides faster Opus 4.6 output at higher cost.
|
||||
Reads the fastMode flag from task_metadata.json.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
|
||||
Returns:
|
||||
True if Fast Mode is enabled, False otherwise
|
||||
"""
|
||||
metadata = load_task_metadata(spec_dir)
|
||||
if metadata:
|
||||
return bool(metadata.get("fastMode", False))
|
||||
return False
|
||||
|
||||
|
||||
def get_spec_phase_thinking_budget(phase_name: str) -> int:
|
||||
def get_spec_phase_thinking_budget(phase_name: str) -> int | None:
|
||||
"""
|
||||
Get the thinking budget for a specific spec runner phase.
|
||||
|
||||
@@ -462,7 +313,7 @@ def get_spec_phase_thinking_budget(phase_name: str) -> int:
|
||||
phase_name: Name of the spec phase (e.g., 'discovery', 'spec_writing')
|
||||
|
||||
Returns:
|
||||
Token budget for extended thinking
|
||||
Token budget for extended thinking, or None for no extended thinking
|
||||
"""
|
||||
thinking_level = SPEC_PHASE_THINKING_LEVELS.get(phase_name, "medium")
|
||||
return get_thinking_budget(thinking_level)
|
||||
|
||||
@@ -104,8 +104,8 @@ class ContextLoader:
|
||||
)
|
||||
if declared_type in _WORKFLOW_TYPE_MAPPING:
|
||||
return _WORKFLOW_TYPE_MAPPING[declared_type]
|
||||
except (json.JSONDecodeError, KeyError): # Invalid JSON; skip
|
||||
pass # no-op: skip invalid files
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
# 2. Check complexity_assessment.json (AI's assessment)
|
||||
assessment_file = self.spec_dir / "complexity_assessment.json"
|
||||
@@ -118,8 +118,8 @@ class ContextLoader:
|
||||
)
|
||||
if declared_type in _WORKFLOW_TYPE_MAPPING:
|
||||
return _WORKFLOW_TYPE_MAPPING[declared_type]
|
||||
except (json.JSONDecodeError, KeyError): # Invalid JSON; skip
|
||||
pass # no-op: skip invalid files
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass
|
||||
|
||||
# 3. & 4. Fall back to spec content detection
|
||||
return self._detect_workflow_type_from_spec(spec_content)
|
||||
|
||||
@@ -54,6 +54,7 @@ class FeaturePlanGenerator(PlanGenerator):
|
||||
service_order = determine_service_order(files_by_service)
|
||||
|
||||
backend_phase = None
|
||||
worker_phase = None
|
||||
|
||||
for service in service_order:
|
||||
files = files_by_service[service]
|
||||
@@ -114,6 +115,8 @@ class FeaturePlanGenerator(PlanGenerator):
|
||||
# Track for dependencies
|
||||
if service_type in ["backend", "api", "server"]:
|
||||
backend_phase = phase_num
|
||||
elif service_type in ["worker", "celery"]:
|
||||
worker_phase = phase_num
|
||||
|
||||
# Add integration phase if multiple services
|
||||
if len(service_order) > 1:
|
||||
|
||||
@@ -18,47 +18,9 @@ Usage:
|
||||
python auto-claude/planner.py --spec-dir auto-claude/specs/001-feature/
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Configure safe encoding on Windows to handle Unicode characters in output
|
||||
if sys.platform == "win32":
|
||||
for _stream_name in ("stdout", "stderr"):
|
||||
_stream = getattr(sys, _stream_name)
|
||||
# Method 1: Try reconfigure (works for TTY)
|
||||
if hasattr(_stream, "reconfigure"):
|
||||
try:
|
||||
_stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
continue
|
||||
except (
|
||||
AttributeError,
|
||||
io.UnsupportedOperation,
|
||||
OSError,
|
||||
): # Stream doesn't support reconfigure
|
||||
pass # no-op
|
||||
# Method 2: Wrap with TextIOWrapper for piped output
|
||||
try:
|
||||
if hasattr(_stream, "buffer"):
|
||||
_new_stream = io.TextIOWrapper(
|
||||
_stream.buffer,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
line_buffering=True,
|
||||
)
|
||||
setattr(sys, _stream_name, _new_stream)
|
||||
except (
|
||||
AttributeError,
|
||||
io.UnsupportedOperation,
|
||||
OSError,
|
||||
): # Stream doesn't support wrapper
|
||||
pass # no-op
|
||||
# Clean up temporary variables
|
||||
del _stream_name, _stream
|
||||
if "_new_stream" in dir():
|
||||
del _new_stream
|
||||
|
||||
from implementation_plan import ImplementationPlan
|
||||
from planner_lib.context import ContextLoader
|
||||
from planner_lib.generators import get_plan_generator
|
||||
|
||||
@@ -10,47 +10,10 @@ Usage:
|
||||
python prediction.py auto-claude/specs/001-feature/
|
||||
"""
|
||||
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Configure safe encoding on Windows to handle Unicode characters in output
|
||||
if sys.platform == "win32":
|
||||
for _stream_name in ("stdout", "stderr"):
|
||||
_stream = getattr(sys, _stream_name)
|
||||
# Method 1: Try reconfigure (works for TTY)
|
||||
if hasattr(_stream, "reconfigure"):
|
||||
try:
|
||||
_stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
continue
|
||||
except (
|
||||
AttributeError,
|
||||
io.UnsupportedOperation,
|
||||
OSError,
|
||||
): # Stream doesn't support reconfigure
|
||||
pass # no-op
|
||||
# Method 2: Wrap with TextIOWrapper for piped output
|
||||
try:
|
||||
if hasattr(_stream, "buffer"):
|
||||
_new_stream = io.TextIOWrapper(
|
||||
_stream.buffer,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
line_buffering=True,
|
||||
)
|
||||
setattr(sys, _stream_name, _new_stream)
|
||||
except (
|
||||
AttributeError,
|
||||
io.UnsupportedOperation,
|
||||
OSError,
|
||||
): # Stream doesn't support wrapper
|
||||
pass # no-op
|
||||
# Clean up temporary variables
|
||||
del _stream_name, _stream
|
||||
if "_new_stream" in dir():
|
||||
del _new_stream
|
||||
|
||||
from prediction import generate_subtask_checklist
|
||||
|
||||
|
||||
|
||||
@@ -23,18 +23,18 @@ tailored security profiles.
|
||||
"""
|
||||
|
||||
# Re-export all command registries from the package
|
||||
from .command_registry.base import (
|
||||
from .command_registry import (
|
||||
BASE_COMMANDS,
|
||||
CLOUD_COMMANDS,
|
||||
CODE_QUALITY_COMMANDS,
|
||||
DATABASE_COMMANDS,
|
||||
FRAMEWORK_COMMANDS,
|
||||
INFRASTRUCTURE_COMMANDS,
|
||||
LANGUAGE_COMMANDS,
|
||||
PACKAGE_MANAGER_COMMANDS,
|
||||
VALIDATED_COMMANDS,
|
||||
VERSION_MANAGER_COMMANDS,
|
||||
)
|
||||
from .command_registry.cloud import CLOUD_COMMANDS
|
||||
from .command_registry.code_quality import CODE_QUALITY_COMMANDS
|
||||
from .command_registry.databases import DATABASE_COMMANDS
|
||||
from .command_registry.frameworks import FRAMEWORK_COMMANDS
|
||||
from .command_registry.infrastructure import INFRASTRUCTURE_COMMANDS
|
||||
from .command_registry.languages import LANGUAGE_COMMANDS
|
||||
from .command_registry.package_managers import PACKAGE_MANAGER_COMMANDS
|
||||
from .command_registry.version_managers import VERSION_MANAGER_COMMANDS
|
||||
|
||||
__all__ = [
|
||||
"BASE_COMMANDS",
|
||||
|
||||
@@ -253,8 +253,8 @@ class StackDetector:
|
||||
if "apiVersion:" in content and "kind:" in content:
|
||||
self.stack.infrastructure.append("kubernetes")
|
||||
break
|
||||
except OSError: # Non-critical failure; continue
|
||||
pass # no-op: skip inaccessible files
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Helm
|
||||
if self.parser.file_exists("Chart.yaml", "charts/"):
|
||||
|
||||
@@ -56,9 +56,12 @@ class StructureAnalyzer:
|
||||
if pkg and "scripts" in pkg:
|
||||
self.custom_scripts.npm_scripts = list(pkg["scripts"].keys())
|
||||
|
||||
# If any npm scripts exist, allow the npm-related commands
|
||||
if self.custom_scripts.npm_scripts:
|
||||
self.script_commands.update(["npm", "yarn", "pnpm", "bun"])
|
||||
# Add commands to run these scripts
|
||||
for script in self.custom_scripts.npm_scripts:
|
||||
self.script_commands.add("npm")
|
||||
self.script_commands.add("yarn")
|
||||
self.script_commands.add("pnpm")
|
||||
self.script_commands.add("bun")
|
||||
|
||||
def _detect_makefile_targets(self) -> None:
|
||||
"""Detect Makefile targets."""
|
||||
@@ -100,8 +103,8 @@ class StructureAnalyzer:
|
||||
|
||||
def _detect_shell_scripts(self) -> None:
|
||||
"""Detect shell scripts in root directory."""
|
||||
for _ in ["*.sh", "*.bash"]:
|
||||
for script_path in self.parser.glob_files(_):
|
||||
for ext in ["*.sh", "*.bash"]:
|
||||
for script_path in self.parser.glob_files(ext):
|
||||
script_name = script_path.name
|
||||
self.custom_scripts.shell_scripts.append(script_name)
|
||||
# Allow executing these scripts
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Backward compatibility shim - import from prompts_pkg.prompt_generator instead."""
|
||||
|
||||
from prompts_pkg.prompt_generator import * # noqa: F401, F403
|
||||
from prompts_pkg.prompt_generator import * # noqa: F403
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Backward compatibility shim - import from prompts_pkg.prompts instead."""
|
||||
|
||||
from prompts_pkg.prompts import * # noqa: F401, F403
|
||||
from prompts_pkg.prompts import * # noqa: F403
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user