diff --git a/.claude/templates/coding_prompt.template.md b/.claude/templates/coding_prompt.template.md index 089cf1f..c2eca0d 100644 --- a/.claude/templates/coding_prompt.template.md +++ b/.claude/templates/coding_prompt.template.md @@ -88,6 +88,13 @@ Get the next feature to implement: Use the feature_get_next tool ``` +Once you've retrieved the feature, **immediately mark it as in-progress**: + +``` +# Mark feature as in-progress to prevent other sessions from working on it +Use the feature_mark_in_progress tool with feature_id=42 +``` + Focus on completing one feature perfectly and completing its testing steps in this session before moving on to other features. It's ok if you only complete one feature in this session, as there will be more sessions later that continue to make progress. @@ -335,20 +342,26 @@ The feature tools exist to reduce token usage. **DO NOT make exploratory queries ### ALLOWED Feature Tools (ONLY these): ``` -# 1. Get progress stats (passing/total counts) +# 1. Get progress stats (passing/in_progress/total counts) feature_get_stats # 2. Get the NEXT feature to work on (one feature only) feature_get_next -# 3. Get up to 3 random passing features for regression testing +# 3. Mark a feature as in-progress (call immediately after feature_get_next) +feature_mark_in_progress with feature_id={id} + +# 4. Get up to 3 random passing features for regression testing feature_get_for_regression -# 4. Mark a feature as passing (after verification) +# 5. Mark a feature as passing (after verification) feature_mark_passing with feature_id={id} -# 5. Skip a feature (moves to end of queue) - ONLY when blocked by dependency +# 6. Skip a feature (moves to end of queue) - ONLY when blocked by dependency feature_skip with feature_id={id} + +# 7. Clear in-progress status (when abandoning a feature) +feature_clear_in_progress with feature_id={id} ``` ### RULES: diff --git a/api/database.py b/api/database.py index b802e8d..f05f92f 100644 --- a/api/database.py +++ b/api/database.py @@ -28,6 +28,7 @@ class Feature(Base): description = Column(Text, nullable=False) steps = Column(JSON, nullable=False) # Stored as JSON array passes = Column(Boolean, default=False, index=True) + in_progress = Column(Boolean, default=False, index=True) def to_dict(self) -> dict: """Convert feature to dictionary for JSON serialization.""" @@ -39,6 +40,7 @@ class Feature(Base): "description": self.description, "steps": self.steps, "passes": self.passes, + "in_progress": self.in_progress, } @@ -56,6 +58,21 @@ def get_database_url(project_dir: Path) -> str: return f"sqlite:///{db_path.as_posix()}" +def _migrate_add_in_progress_column(engine) -> None: + """Add in_progress column to existing databases that don't have it.""" + from sqlalchemy import text + + with engine.connect() as conn: + # Check if column exists + result = conn.execute(text("PRAGMA table_info(features)")) + columns = [row[1] for row in result.fetchall()] + + if "in_progress" not in columns: + # Add the column with default value + conn.execute(text("ALTER TABLE features ADD COLUMN in_progress BOOLEAN DEFAULT 0")) + conn.commit() + + def create_database(project_dir: Path) -> tuple: """ Create database and return engine + session maker. @@ -69,6 +86,10 @@ def create_database(project_dir: Path) -> tuple: db_url = get_database_url(project_dir) engine = create_engine(db_url, connect_args={"check_same_thread": False}) Base.metadata.create_all(bind=engine) + + # Migrate existing databases to add in_progress column + _migrate_add_in_progress_column(engine) + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) return engine, SessionLocal diff --git a/client.py b/client.py index 115743b..1b47730 100644 --- a/client.py +++ b/client.py @@ -22,6 +22,7 @@ FEATURE_MCP_TOOLS = [ "mcp__features__feature_get_stats", "mcp__features__feature_get_next", "mcp__features__feature_get_for_regression", + "mcp__features__feature_mark_in_progress", "mcp__features__feature_mark_passing", "mcp__features__feature_skip", "mcp__features__feature_create_bulk", diff --git a/mcp_server/feature_mcp.py b/mcp_server/feature_mcp.py index 85c0a26..64eae93 100644 --- a/mcp_server/feature_mcp.py +++ b/mcp_server/feature_mcp.py @@ -12,6 +12,8 @@ Tools: - feature_get_for_regression: Get random passing features for testing - feature_mark_passing: Mark a feature as passing - feature_skip: Skip a feature (move to end of queue) +- feature_mark_in_progress: Mark a feature as in-progress +- feature_clear_in_progress: Clear in-progress status - feature_create_bulk: Create multiple features at once """ @@ -47,6 +49,16 @@ class SkipFeatureInput(BaseModel): feature_id: int = Field(..., description="The ID of the feature to skip", ge=1) +class MarkInProgressInput(BaseModel): + """Input for marking a feature as in-progress.""" + feature_id: int = Field(..., description="The ID of the feature to mark as in-progress", ge=1) + + +class ClearInProgressInput(BaseModel): + """Input for clearing in-progress status.""" + feature_id: int = Field(..., description="The ID of the feature to clear in-progress status", ge=1) + + class RegressionInput(BaseModel): """Input for getting regression features.""" limit: int = Field(default=3, ge=1, le=10, description="Maximum number of passing features to return") @@ -106,20 +118,22 @@ def get_session(): def feature_get_stats() -> str: """Get statistics about feature completion progress. - Returns the number of passing features, total features, and completion percentage. - Use this to track overall progress of the implementation. + Returns the number of passing features, in-progress features, total features, + and completion percentage. Use this to track overall progress of the implementation. Returns: - JSON with: passing (int), total (int), percentage (float) + JSON with: passing (int), in_progress (int), total (int), percentage (float) """ session = get_session() try: total = session.query(Feature).count() passing = session.query(Feature).filter(Feature.passes == True).count() + in_progress = session.query(Feature).filter(Feature.in_progress == True).count() percentage = round((passing / total) * 100, 1) if total > 0 else 0.0 return json.dumps({ "passing": passing, + "in_progress": in_progress, "total": total, "percentage": percentage }, indent=2) @@ -131,24 +145,26 @@ def feature_get_stats() -> str: def feature_get_next() -> str: """Get the highest-priority pending feature to work on. - Returns the feature with the lowest priority number that has passes=false. + Returns the feature with the lowest priority number that has passes=false + and is not currently in-progress by another session. Use this at the start of each coding session to determine what to implement next. Returns: - JSON with feature details (id, priority, category, name, description, steps, passes) - or error message if all features are passing. + JSON with feature details (id, priority, category, name, description, steps, passes, in_progress) + or error message if all features are passing or in-progress. """ session = get_session() try: feature = ( session.query(Feature) .filter(Feature.passes == False) + .filter(Feature.in_progress == False) .order_by(Feature.priority.asc(), Feature.id.asc()) .first() ) if feature is None: - return json.dumps({"error": "All features are passing! No more work to do."}) + return json.dumps({"error": "All features are passing or in-progress! No more work to do."}) return json.dumps(feature.to_dict(), indent=2) finally: @@ -195,8 +211,8 @@ def feature_mark_passing( ) -> str: """Mark a feature as passing after successful implementation. - Updates the feature's passes field to true. Use this after you have - implemented the feature and verified it works correctly. + Updates the feature's passes field to true and clears the in_progress flag. + Use this after you have implemented the feature and verified it works correctly. Args: feature_id: The ID of the feature to mark as passing @@ -212,6 +228,7 @@ def feature_mark_passing( return json.dumps({"error": f"Feature with ID {feature_id} not found"}) feature.passes = True + feature.in_progress = False session.commit() session.refresh(feature) @@ -232,7 +249,8 @@ def feature_skip( - Technical prerequisites that need to be addressed first The feature's priority is set to max_priority + 1, so it will be - worked on after all other pending features. + worked on after all other pending features. Also clears the in_progress + flag so the feature returns to "pending" status. Args: feature_id: The ID of the feature to skip @@ -257,6 +275,7 @@ def feature_skip( new_priority = (max_priority_result[0] + 1) if max_priority_result else 1 feature.priority = new_priority + feature.in_progress = False session.commit() session.refresh(feature) @@ -271,6 +290,74 @@ def feature_skip( session.close() +@mcp.tool() +def feature_mark_in_progress( + feature_id: Annotated[int, Field(description="The ID of the feature to mark as in-progress", ge=1)] +) -> str: + """Mark a feature as in-progress. Call immediately after feature_get_next(). + + This prevents other agent sessions from working on the same feature. + Use this as soon as you retrieve a feature to work on. + + Args: + feature_id: The ID of the feature to mark as in-progress + + Returns: + JSON with the updated feature details, or error if not found or already in-progress. + """ + session = get_session() + try: + feature = session.query(Feature).filter(Feature.id == feature_id).first() + + if feature is None: + return json.dumps({"error": f"Feature with ID {feature_id} not found"}) + + if feature.passes: + return json.dumps({"error": f"Feature with ID {feature_id} is already passing"}) + + if feature.in_progress: + return json.dumps({"error": f"Feature with ID {feature_id} is already in-progress"}) + + feature.in_progress = True + session.commit() + session.refresh(feature) + + return json.dumps(feature.to_dict(), indent=2) + finally: + session.close() + + +@mcp.tool() +def feature_clear_in_progress( + feature_id: Annotated[int, Field(description="The ID of the feature to clear in-progress status", ge=1)] +) -> str: + """Clear in-progress status from a feature. + + Use this when abandoning a feature or manually unsticking a stuck feature. + The feature will return to the pending queue. + + Args: + feature_id: The ID of the feature to clear in-progress status + + Returns: + JSON with the updated feature details, or error if not found. + """ + session = get_session() + try: + feature = session.query(Feature).filter(Feature.id == feature_id).first() + + if feature is None: + return json.dumps({"error": f"Feature with ID {feature_id} not found"}) + + feature.in_progress = False + session.commit() + session.refresh(feature) + + return json.dumps(feature.to_dict(), indent=2) + finally: + session.close() + + @mcp.tool() def feature_create_bulk( features: Annotated[list[dict], Field(description="List of features to create, each with category, name, description, and steps")] diff --git a/progress.py b/progress.py index c2a4a4b..8551020 100644 --- a/progress.py +++ b/progress.py @@ -56,19 +56,19 @@ def has_features(project_dir: Path) -> bool: return False -def count_passing_tests(project_dir: Path) -> tuple[int, int]: +def count_passing_tests(project_dir: Path) -> tuple[int, int, int]: """ - Count passing and total tests via direct database access. + Count passing, in_progress, and total tests via direct database access. Args: project_dir: Directory containing the project Returns: - (passing_count, total_count) + (passing_count, in_progress_count, total_count) """ db_file = project_dir / "features.db" if not db_file.exists(): - return 0, 0 + return 0, 0, 0 try: conn = sqlite3.connect(db_file) @@ -77,11 +77,17 @@ def count_passing_tests(project_dir: Path) -> tuple[int, int]: total = cursor.fetchone()[0] cursor.execute("SELECT COUNT(*) FROM features WHERE passes = 1") passing = cursor.fetchone()[0] + # Handle case where in_progress column doesn't exist yet + try: + cursor.execute("SELECT COUNT(*) FROM features WHERE in_progress = 1") + in_progress = cursor.fetchone()[0] + except sqlite3.OperationalError: + in_progress = 0 conn.close() - return passing, total + return passing, in_progress, total except Exception as e: print(f"[Database error in count_passing_tests: {e}]") - return 0, 0 + return 0, 0, 0 def get_all_passing_features(project_dir: Path) -> list[dict]: @@ -205,11 +211,14 @@ def print_session_header(session_num: int, is_initializer: bool) -> None: def print_progress_summary(project_dir: Path) -> None: """Print a summary of current progress.""" - passing, total = count_passing_tests(project_dir) + passing, in_progress, total = count_passing_tests(project_dir) if total > 0: percentage = (passing / total) * 100 - print(f"\nProgress: {passing}/{total} tests passing ({percentage:.1f}%)") + status_parts = [f"{passing}/{total} tests passing ({percentage:.1f}%)"] + if in_progress > 0: + status_parts.append(f"{in_progress} in progress") + print(f"\nProgress: {', '.join(status_parts)}") send_progress_webhook(passing, total, project_dir) else: print("\nProgress: No features in database yet") diff --git a/server/routers/features.py b/server/routers/features.py index 56e4d6d..087cf38 100644 --- a/server/routers/features.py +++ b/server/routers/features.py @@ -93,6 +93,7 @@ def feature_to_response(f) -> FeatureResponse: description=f.description, steps=f.steps if isinstance(f.steps, list) else [], passes=f.passes, + in_progress=f.in_progress, ) @@ -123,18 +124,21 @@ async def list_features(project_name: str): all_features = session.query(Feature).order_by(Feature.priority).all() pending = [] + in_progress = [] done = [] for f in all_features: feature_response = feature_to_response(f) if f.passes: done.append(feature_response) + elif f.in_progress: + in_progress.append(feature_response) else: pending.append(feature_response) return FeatureListResponse( pending=pending, - in_progress=[], + in_progress=in_progress, done=done, ) except HTTPException: diff --git a/server/routers/projects.py b/server/routers/projects.py index 13fc827..ff93aea 100644 --- a/server/routers/projects.py +++ b/server/routers/projects.py @@ -73,7 +73,7 @@ def validate_project_name(name: str) -> str: def get_project_stats(project_dir: Path) -> ProjectStats: """Get statistics for a project.""" _init_imports() - passing, total = _count_passing_tests(project_dir) + passing, _, total = _count_passing_tests(project_dir) percentage = (passing / total * 100) if total > 0 else 0.0 return ProjectStats(passing=passing, total=total, percentage=round(percentage, 1)) diff --git a/server/schemas.py b/server/schemas.py index fee89c7..c14b9ac 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -78,6 +78,7 @@ class FeatureResponse(FeatureBase): id: int priority: int passes: bool + in_progress: bool class Config: from_attributes = True diff --git a/server/services/spec_chat_session.py b/server/services/spec_chat_session.py index d788451..2582931 100644 --- a/server/services/spec_chat_session.py +++ b/server/services/spec_chat_session.py @@ -83,8 +83,12 @@ class SpecChatSession: except UnicodeDecodeError: skill_content = skill_path.read_text(encoding="utf-8", errors="replace") - # Replace $ARGUMENTS with the project path (use forward slashes for consistency) - project_path = f"generations/{self.project_name}" + # Ensure project directory exists (like CLI does in start.py) + self.project_dir.mkdir(parents=True, exist_ok=True) + + # Replace $ARGUMENTS with absolute project path (like CLI does in start.py:184) + # Using absolute path avoids confusion when project folder name differs from app name + project_path = str(self.project_dir.resolve()) system_prompt = skill_content.replace("$ARGUMENTS", project_path) # Create Claude SDK client with limited tools for spec creation diff --git a/server/websocket.py b/server/websocket.py index 0027127..56a92ae 100644 --- a/server/websocket.py +++ b/server/websocket.py @@ -117,21 +117,24 @@ async def poll_progress(websocket: WebSocket, project_name: str): project_dir = _get_generations_dir() / project_name count_passing_tests = _get_count_passing_tests() last_passing = -1 + last_in_progress = -1 last_total = -1 while True: try: - passing, total = count_passing_tests(project_dir) + passing, in_progress, total = count_passing_tests(project_dir) # Only send if changed - if passing != last_passing or total != last_total: + if passing != last_passing or in_progress != last_in_progress or total != last_total: last_passing = passing + last_in_progress = in_progress last_total = total percentage = (passing / total * 100) if total > 0 else 0 await websocket.send_json({ "type": "progress", "passing": passing, + "in_progress": in_progress, "total": total, "percentage": round(percentage, 1), }) @@ -204,11 +207,12 @@ async def project_websocket(websocket: WebSocket, project_name: str): # Send initial progress count_passing_tests = _get_count_passing_tests() - passing, total = count_passing_tests(project_dir) + passing, in_progress, total = count_passing_tests(project_dir) percentage = (passing / total * 100) if total > 0 else 0 await websocket.send_json({ "type": "progress", "passing": passing, + "in_progress": in_progress, "total": total, "percentage": round(percentage, 1), }) diff --git a/ui/src/hooks/useWebSocket.ts b/ui/src/hooks/useWebSocket.ts index 973624b..bf9e2b8 100644 --- a/ui/src/hooks/useWebSocket.ts +++ b/ui/src/hooks/useWebSocket.ts @@ -8,6 +8,7 @@ import type { WSMessage, AgentStatus } from '../lib/types' interface WebSocketState { progress: { passing: number + in_progress: number total: number percentage: number } @@ -20,7 +21,7 @@ const MAX_LOGS = 100 // Keep last 100 log lines export function useProjectWebSocket(projectName: string | null) { const [state, setState] = useState({ - progress: { passing: 0, total: 0, percentage: 0 }, + progress: { passing: 0, in_progress: 0, total: 0, percentage: 0 }, agentStatus: 'stopped', logs: [], isConnected: false, @@ -57,6 +58,7 @@ export function useProjectWebSocket(projectName: string | null) { ...prev, progress: { passing: message.passing, + in_progress: message.in_progress, total: message.total, percentage: message.percentage, }, diff --git a/ui/src/lib/types.ts b/ui/src/lib/types.ts index 6fc102f..e2310f2 100644 --- a/ui/src/lib/types.ts +++ b/ui/src/lib/types.ts @@ -5,6 +5,7 @@ // Project types export interface ProjectStats { passing: number + in_progress: number total: number percentage: number } @@ -34,6 +35,7 @@ export interface Feature { description: string steps: string[] passes: boolean + in_progress: boolean } export interface FeatureListResponse { @@ -79,6 +81,7 @@ export type WSMessageType = 'progress' | 'feature_update' | 'log' | 'agent_statu export interface WSProgressMessage { type: 'progress' passing: number + in_progress: number total: number percentage: number }