diff --git a/.claude/commands/review-pr.md b/.claude/commands/review-pr.md
index 02a4a66..9c9098f 100644
--- a/.claude/commands/review-pr.md
+++ b/.claude/commands/review-pr.md
@@ -12,18 +12,43 @@ Pull request(s): $ARGUMENTS
1. **Retrieve PR Details**
- Use the GH CLI tool to retrieve the details (descriptions, diffs, comments, feedback, reviews, etc)
-2. **Analyze Codebase Impact**
- - Use 3 deepdive subagents to analyze the impact on the codebase
+2. **Assess PR Complexity**
-3. **Vision Alignment Check**
+ After retrieving PR details, assess complexity based on:
+ - Number of files changed
+ - Lines added/removed
+ - Number of contributors/commits
+ - Whether changes touch core/architectural files
+
+ ### Complexity Tiers
+
+ **Simple** (no deep dive agents needed):
+ - ≤5 files changed AND ≤100 lines changed AND single author
+ - Review directly without spawning agents
+
+ **Medium** (1-2 deep dive agents):
+ - 6-15 files changed, OR 100-500 lines, OR 2 contributors
+ - Spawn 1 agent for focused areas, 2 if changes span multiple domains
+
+ **Complex** (up to 3 deep dive agents):
+ - >15 files, OR >500 lines, OR >2 contributors, OR touches core architecture
+ - Spawn up to 3 agents to analyze different aspects (e.g., security, performance, architecture)
+
+3. **Analyze Codebase Impact**
+ - Based on the complexity tier determined above, spawn the appropriate number of deep dive subagents
+ - For Simple PRs: analyze directly without spawning agents
+ - For Medium PRs: spawn 1-2 agents focusing on the most impacted areas
+ - For Complex PRs: spawn up to 3 agents to cover security, performance, and architectural concerns
+
+4. **Vision Alignment Check**
- Read the project's README.md and CLAUDE.md to understand the application's core purpose
- Assess whether this PR aligns with the application's intended functionality
- If the changes deviate significantly from the core vision or add functionality that doesn't serve the application's purpose, note this in the review
- This is not a blocker, but should be flagged for the reviewer's consideration
-4. **Safety Assessment**
+5. **Safety Assessment**
- Provide a review on whether the PR is safe to merge as-is
- Provide any feedback in terms of risk level
-5. **Improvements**
+6. **Improvements**
- Propose any improvements in terms of importance and complexity
\ No newline at end of file
diff --git a/registry.py b/registry.py
index 20d31df..f84803e 100644
--- a/registry.py
+++ b/registry.py
@@ -16,7 +16,7 @@ from datetime import datetime
from pathlib import Path
from typing import Any
-from sqlalchemy import Column, DateTime, String, create_engine
+from sqlalchemy import Column, DateTime, Integer, String, create_engine, text
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
@@ -85,6 +85,7 @@ class Project(Base):
name = Column(String(50), primary_key=True, index=True)
path = Column(String, nullable=False) # POSIX format for cross-platform
created_at = Column(DateTime, nullable=False)
+ default_concurrency = Column(Integer, nullable=False, default=3)
class Settings(Base):
@@ -146,12 +147,26 @@ def _get_engine():
}
)
Base.metadata.create_all(bind=_engine)
+ _migrate_add_default_concurrency(_engine)
_SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=_engine)
logger.debug("Initialized registry database at: %s", db_path)
return _engine, _SessionLocal
+def _migrate_add_default_concurrency(engine) -> None:
+ """Add default_concurrency column if missing (for existing databases)."""
+ with engine.connect() as conn:
+ result = conn.execute(text("PRAGMA table_info(projects)"))
+ columns = [row[1] for row in result.fetchall()]
+ if "default_concurrency" not in columns:
+ conn.execute(text(
+ "ALTER TABLE projects ADD COLUMN default_concurrency INTEGER DEFAULT 3"
+ ))
+ conn.commit()
+ logger.info("Migrated projects table: added default_concurrency column")
+
+
@contextmanager
def _get_session():
"""
@@ -307,7 +322,8 @@ def list_registered_projects() -> dict[str, dict[str, Any]]:
return {
p.name: {
"path": p.path,
- "created_at": p.created_at.isoformat() if p.created_at else None
+ "created_at": p.created_at.isoformat() if p.created_at else None,
+ "default_concurrency": getattr(p, 'default_concurrency', 3) or 3
}
for p in projects
}
@@ -333,7 +349,8 @@ def get_project_info(name: str) -> dict[str, Any] | None:
return None
return {
"path": project.path,
- "created_at": project.created_at.isoformat() if project.created_at else None
+ "created_at": project.created_at.isoformat() if project.created_at else None,
+ "default_concurrency": getattr(project, 'default_concurrency', 3) or 3
}
finally:
session.close()
@@ -362,6 +379,55 @@ def update_project_path(name: str, new_path: Path) -> bool:
return True
+def get_project_concurrency(name: str) -> int:
+ """
+ Get project's default concurrency (1-5).
+
+ Args:
+ name: The project name.
+
+ Returns:
+ The default concurrency value (defaults to 3 if not set or project not found).
+ """
+ _, SessionLocal = _get_engine()
+ session = SessionLocal()
+ try:
+ project = session.query(Project).filter(Project.name == name).first()
+ if project is None:
+ return 3
+ return getattr(project, 'default_concurrency', 3) or 3
+ finally:
+ session.close()
+
+
+def set_project_concurrency(name: str, concurrency: int) -> bool:
+ """
+ Set project's default concurrency (1-5).
+
+ Args:
+ name: The project name.
+ concurrency: The concurrency value (1-5).
+
+ Returns:
+ True if updated, False if project wasn't found.
+
+ Raises:
+ ValueError: If concurrency is not between 1 and 5.
+ """
+ if concurrency < 1 or concurrency > 5:
+ raise ValueError("concurrency must be between 1 and 5")
+
+ with _get_session() as session:
+ project = session.query(Project).filter(Project.name == name).first()
+ if not project:
+ return False
+
+ project.default_concurrency = concurrency
+
+ logger.info("Set project '%s' default_concurrency to %d", name, concurrency)
+ return True
+
+
# =============================================================================
# Validation Functions
# =============================================================================
diff --git a/server/routers/projects.py b/server/routers/projects.py
index 68cf526..70e27cc 100644
--- a/server/routers/projects.py
+++ b/server/routers/projects.py
@@ -18,6 +18,7 @@ from ..schemas import (
ProjectDetail,
ProjectPrompts,
ProjectPromptsUpdate,
+ ProjectSettingsUpdate,
ProjectStats,
ProjectSummary,
)
@@ -63,13 +64,23 @@ def _get_registry_functions():
sys.path.insert(0, str(root))
from registry import (
+ get_project_concurrency,
get_project_path,
list_registered_projects,
register_project,
+ set_project_concurrency,
unregister_project,
validate_project_path,
)
- return register_project, unregister_project, get_project_path, list_registered_projects, validate_project_path
+ return (
+ register_project,
+ unregister_project,
+ get_project_path,
+ list_registered_projects,
+ validate_project_path,
+ get_project_concurrency,
+ set_project_concurrency,
+ )
router = APIRouter(prefix="/api/projects", tags=["projects"])
@@ -102,7 +113,8 @@ def get_project_stats(project_dir: Path) -> ProjectStats:
async def list_projects():
"""List all registered projects."""
_init_imports()
- _, _, _, list_registered_projects, validate_project_path = _get_registry_functions()
+ (_, _, _, list_registered_projects, validate_project_path,
+ get_project_concurrency, _) = _get_registry_functions()
projects = list_registered_projects()
result = []
@@ -123,6 +135,7 @@ async def list_projects():
path=info["path"],
has_spec=has_spec,
stats=stats,
+ default_concurrency=info.get("default_concurrency", 3),
))
return result
@@ -132,7 +145,8 @@ async def list_projects():
async def create_project(project: ProjectCreate):
"""Create a new project at the specified path."""
_init_imports()
- register_project, _, get_project_path, list_registered_projects, _ = _get_registry_functions()
+ (register_project, _, get_project_path, list_registered_projects,
+ _, _, _) = _get_registry_functions()
name = validate_project_name(project.name)
project_path = Path(project.path).resolve()
@@ -203,6 +217,7 @@ async def create_project(project: ProjectCreate):
path=project_path.as_posix(),
has_spec=False, # Just created, no spec yet
stats=ProjectStats(passing=0, total=0, percentage=0.0),
+ default_concurrency=3,
)
@@ -210,7 +225,7 @@ async def create_project(project: ProjectCreate):
async def get_project(name: str):
"""Get detailed information about a project."""
_init_imports()
- _, _, get_project_path, _, _ = _get_registry_functions()
+ (_, _, get_project_path, _, _, get_project_concurrency, _) = _get_registry_functions()
name = validate_project_name(name)
project_dir = get_project_path(name)
@@ -231,6 +246,7 @@ async def get_project(name: str):
has_spec=has_spec,
stats=stats,
prompts_dir=str(prompts_dir),
+ default_concurrency=get_project_concurrency(name),
)
@@ -244,7 +260,7 @@ async def delete_project(name: str, delete_files: bool = False):
delete_files: If True, also delete the project directory and files
"""
_init_imports()
- _, unregister_project, get_project_path, _, _ = _get_registry_functions()
+ (_, unregister_project, get_project_path, _, _, _, _) = _get_registry_functions()
name = validate_project_name(name)
project_dir = get_project_path(name)
@@ -280,7 +296,7 @@ async def delete_project(name: str, delete_files: bool = False):
async def get_project_prompts(name: str):
"""Get the content of project prompt files."""
_init_imports()
- _, _, get_project_path, _, _ = _get_registry_functions()
+ (_, _, get_project_path, _, _, _, _) = _get_registry_functions()
name = validate_project_name(name)
project_dir = get_project_path(name)
@@ -313,7 +329,7 @@ async def get_project_prompts(name: str):
async def update_project_prompts(name: str, prompts: ProjectPromptsUpdate):
"""Update project prompt files."""
_init_imports()
- _, _, get_project_path, _, _ = _get_registry_functions()
+ (_, _, get_project_path, _, _, _, _) = _get_registry_functions()
name = validate_project_name(name)
project_dir = get_project_path(name)
@@ -343,7 +359,7 @@ async def update_project_prompts(name: str, prompts: ProjectPromptsUpdate):
async def get_project_stats_endpoint(name: str):
"""Get current progress statistics for a project."""
_init_imports()
- _, _, get_project_path, _, _ = _get_registry_functions()
+ (_, _, get_project_path, _, _, _, _) = _get_registry_functions()
name = validate_project_name(name)
project_dir = get_project_path(name)
@@ -355,3 +371,40 @@ async def get_project_stats_endpoint(name: str):
raise HTTPException(status_code=404, detail="Project directory not found")
return get_project_stats(project_dir)
+
+
+@router.patch("/{name}/settings", response_model=ProjectDetail)
+async def update_project_settings(name: str, settings: ProjectSettingsUpdate):
+ """Update project-level settings (concurrency, etc.)."""
+ _init_imports()
+ (_, _, get_project_path, _, _, get_project_concurrency,
+ set_project_concurrency) = _get_registry_functions()
+
+ name = validate_project_name(name)
+ project_dir = get_project_path(name)
+
+ if not project_dir:
+ raise HTTPException(status_code=404, detail=f"Project '{name}' not found")
+
+ if not project_dir.exists():
+ raise HTTPException(status_code=404, detail="Project directory not found")
+
+ # Update concurrency if provided
+ if settings.default_concurrency is not None:
+ success = set_project_concurrency(name, settings.default_concurrency)
+ if not success:
+ raise HTTPException(status_code=500, detail="Failed to update concurrency")
+
+ # Return updated project details
+ has_spec = _check_spec_exists(project_dir)
+ stats = get_project_stats(project_dir)
+ prompts_dir = _get_project_prompts_dir(project_dir)
+
+ return ProjectDetail(
+ name=name,
+ path=project_dir.as_posix(),
+ has_spec=has_spec,
+ stats=stats,
+ prompts_dir=str(prompts_dir),
+ default_concurrency=get_project_concurrency(name),
+ )
diff --git a/server/schemas.py b/server/schemas.py
index 0a2807c..03e73ef 100644
--- a/server/schemas.py
+++ b/server/schemas.py
@@ -45,6 +45,7 @@ class ProjectSummary(BaseModel):
path: str
has_spec: bool
stats: ProjectStats
+ default_concurrency: int = 3
class ProjectDetail(BaseModel):
@@ -54,6 +55,7 @@ class ProjectDetail(BaseModel):
has_spec: bool
stats: ProjectStats
prompts_dir: str
+ default_concurrency: int = 3
class ProjectPrompts(BaseModel):
@@ -70,6 +72,18 @@ class ProjectPromptsUpdate(BaseModel):
coding_prompt: str | None = None
+class ProjectSettingsUpdate(BaseModel):
+ """Request schema for updating project-level settings."""
+ default_concurrency: int | None = None
+
+ @field_validator('default_concurrency')
+ @classmethod
+ def validate_concurrency(cls, v: int | None) -> int | None:
+ if v is not None and (v < 1 or v > 5):
+ raise ValueError("default_concurrency must be between 1 and 5")
+ return v
+
+
# ============================================================================
# Feature Schemas
# ============================================================================
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index ddde90f..476539c 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -268,6 +268,7 @@ function App() {
{
+ setConcurrency(defaultConcurrency)
+ }, [defaultConcurrency])
+
+ // Debounced save for concurrency changes
+ const updateProjectSettings = useUpdateProjectSettings(projectName)
+ const saveTimeoutRef = useRef | null>(null)
+
+ const handleConcurrencyChange = useCallback((newConcurrency: number) => {
+ setConcurrency(newConcurrency)
+
+ // Clear previous timeout
+ if (saveTimeoutRef.current) {
+ clearTimeout(saveTimeoutRef.current)
+ }
+
+ // Debounce save (500ms)
+ saveTimeoutRef.current = setTimeout(() => {
+ updateProjectSettings.mutate({ default_concurrency: newConcurrency })
+ }, 500)
+ }, [updateProjectSettings])
+
+ // Cleanup timeout on unmount
+ useEffect(() => {
+ return () => {
+ if (saveTimeoutRef.current) {
+ clearTimeout(saveTimeoutRef.current)
+ }
+ }
+ }, [])
const startAgent = useStartAgent(projectName)
const stopAgent = useStopAgent(projectName)
@@ -57,7 +91,7 @@ export function AgentControl({ projectName, status }: AgentControlProps) {
min={1}
max={5}
value={concurrency}
- onChange={(e) => setConcurrency(Number(e.target.value))}
+ onChange={(e) => handleConcurrencyChange(Number(e.target.value))}
disabled={isLoading}
className="w-16 h-2 accent-primary cursor-pointer"
title={`${concurrency} concurrent agent${concurrency > 1 ? 's' : ''}`}
diff --git a/ui/src/hooks/useProjects.ts b/ui/src/hooks/useProjects.ts
index 0af7763..4ed3914 100644
--- a/ui/src/hooks/useProjects.ts
+++ b/ui/src/hooks/useProjects.ts
@@ -4,7 +4,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import * as api from '../lib/api'
-import type { FeatureCreate, FeatureUpdate, ModelsResponse, Settings, SettingsUpdate } from '../lib/types'
+import type { FeatureCreate, FeatureUpdate, ModelsResponse, ProjectSettingsUpdate, Settings, SettingsUpdate } from '../lib/types'
// ============================================================================
// Projects
@@ -48,6 +48,19 @@ export function useDeleteProject() {
})
}
+export function useUpdateProjectSettings(projectName: string) {
+ const queryClient = useQueryClient()
+
+ return useMutation({
+ mutationFn: (settings: ProjectSettingsUpdate) =>
+ api.updateProjectSettings(projectName, settings),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ['projects'] })
+ queryClient.invalidateQueries({ queryKey: ['project', projectName] })
+ },
+ })
+}
+
// ============================================================================
// Features
// ============================================================================
diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts
index 7ef9a8a..ce3354e 100644
--- a/ui/src/lib/api.ts
+++ b/ui/src/lib/api.ts
@@ -6,6 +6,7 @@ import type {
ProjectSummary,
ProjectDetail,
ProjectPrompts,
+ ProjectSettingsUpdate,
FeatureListResponse,
Feature,
FeatureCreate,
@@ -100,6 +101,16 @@ export async function updateProjectPrompts(
})
}
+export async function updateProjectSettings(
+ name: string,
+ settings: ProjectSettingsUpdate
+): Promise {
+ return fetchJSON(`/projects/${encodeURIComponent(name)}/settings`, {
+ method: 'PATCH',
+ body: JSON.stringify(settings),
+ })
+}
+
// ============================================================================
// Features API
// ============================================================================
diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts
index d883432..269c2ef 100644
--- a/ui/src/lib/types.ts
+++ b/ui/src/lib/types.ts
@@ -15,6 +15,7 @@ export interface ProjectSummary {
path: string
has_spec: boolean
stats: ProjectStats
+ default_concurrency: number
}
export interface ProjectDetail extends ProjectSummary {
@@ -536,6 +537,10 @@ export interface SettingsUpdate {
testing_agent_ratio?: number
}
+export interface ProjectSettingsUpdate {
+ default_concurrency?: number
+}
+
// ============================================================================
// Schedule Types
// ============================================================================