diff --git a/README.md b/README.md index a5f62316..c60b5ee4 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ This launches the React-based web UI at `http://localhost:5173` with: - Kanban board view of features - Real-time agent output streaming - Start/pause/stop controls +- **Project Assistant** - AI chat for managing features and exploring the codebase ### Option 2: CLI Mode @@ -103,6 +104,22 @@ Features are stored in SQLite via SQLAlchemy and managed through an MCP server t - `feature_mark_passing` - Mark feature complete - `feature_skip` - Move feature to end of queue - `feature_create_bulk` - Initialize all features (used by initializer) +- `feature_create` - Create a single feature +- `feature_update` - Update a feature's fields +- `feature_delete` - Delete a feature from the backlog + +### Project Assistant + +The Web UI includes a **Project Assistant** - an AI-powered chat interface for each project. Click the chat button in the bottom-right corner to open it. + +**Capabilities:** +- **Explore the codebase** - Ask questions about files, architecture, and implementation details +- **Manage features** - Create, edit, delete, and deprioritize features via natural language +- **Get feature details** - Ask about specific features, their status, and test steps + +**Conversation Persistence:** +- Conversations are automatically saved to `assistant.db` in the registered project directory +- When you navigate away and return, your conversation resumes where you left off ### Session Management @@ -143,6 +160,7 @@ autonomous-coding/ ├── security.py # Bash command allowlist and validation ├── progress.py # Progress tracking utilities ├── prompts.py # Prompt loading utilities +├── registry.py # Project registry (maps names to paths) ├── api/ │ └── database.py # SQLAlchemy models (Feature table) ├── mcp_server/ @@ -165,20 +183,25 @@ autonomous-coding/ │ │ └── create-spec.md # /create-spec slash command │ ├── skills/ # Claude Code skills │ └── templates/ # Prompt templates -├── generations/ # Generated projects go here +├── generations/ # Default location for new projects (can be anywhere) ├── requirements.txt # Python dependencies └── .env # Optional configuration (N8N webhook) ``` --- -## Generated Project Structure +## Project Registry and Structure -After the agent runs, your project directory will contain: +Projects can be stored in any directory on your filesystem. The **project registry** (`registry.py`) maps project names to their paths, stored in `~/.autocoder/registry.db` (SQLite). -``` -generations/my_project/ +When you create or register a project, the registry tracks its location. This allows projects to live anywhere - in `generations/`, your home directory, or any other path. + +Each registered project directory will contain: + +```text +/ ├── features.db # SQLite database (feature test cases) +├── assistant.db # SQLite database (assistant chat history) ├── prompts/ │ ├── app_spec.txt # Your app specification │ ├── initializer_prompt.md # First session prompt @@ -192,10 +215,10 @@ generations/my_project/ ## Running the Generated Application -After the agent completes (or pauses), you can run the generated application: +After the agent completes (or pauses), you can run the generated application. Navigate to your project's registered path (the directory you selected or created when setting up the project): ```bash -cd generations/my_project +cd /path/to/your/registered/project # Run the setup script created by the agent ./init.sh diff --git a/mcp_server/feature_mcp.py b/mcp_server/feature_mcp.py index 1534bc1b..80316d34 100755 --- a/mcp_server/feature_mcp.py +++ b/mcp_server/feature_mcp.py @@ -16,6 +16,7 @@ - feature_clear_in_progress: Clear in-progress status - feature_create_bulk: Create multiple features at once - feature_create: Create a single feature +- feature_update: Update a feature's editable fields """ import json @@ -477,5 +478,119 @@ def feature_create( session.close() +@mcp.tool() +def feature_update( + feature_id: Annotated[int, Field(description="The ID of the feature to update", ge=1)], + category: Annotated[str | None, Field(default=None, min_length=1, max_length=100, description="New category (optional)")] = None, + name: Annotated[str | None, Field(default=None, min_length=1, max_length=255, description="New name (optional)")] = None, + description: Annotated[str | None, Field(default=None, min_length=1, description="New description (optional)")] = None, + steps: Annotated[list[str] | None, Field(default=None, min_length=1, description="New steps list (optional)")] = None, +) -> str: + """Update an existing feature's editable fields. + + Use this when the user asks to modify, update, edit, or change a feature. + Only the provided fields will be updated; others remain unchanged. + + Cannot update: id, priority (use feature_skip), passes, in_progress (agent-controlled) + + Args: + feature_id: The ID of the feature to update + category: New category (optional) + name: New name (optional) + description: New description (optional) + steps: New steps list (optional) + + 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"}) + + # Collect updates + updates = {} + if category is not None: + updates["category"] = category + if name is not None: + updates["name"] = name + if description is not None: + updates["description"] = description + if steps is not None: + updates["steps"] = steps + + if not updates: + return json.dumps({"error": "No fields to update. Provide at least one of: category, name, description, steps"}) + + # Apply updates + for field, value in updates.items(): + setattr(feature, field, value) + + session.commit() + session.refresh(feature) + + return json.dumps({ + "success": True, + "message": f"Updated feature: {feature.name}", + "feature": feature.to_dict() + }, indent=2) + except Exception as e: + session.rollback() + return json.dumps({"error": str(e)}) + finally: + session.close() + + +@mcp.tool() +def feature_delete( + feature_id: Annotated[int, Field(description="The ID of the feature to delete", ge=1)] +) -> str: + """Delete a feature from the backlog. + + Use this when the user asks to remove, delete, or drop a feature. + This removes the feature from tracking only - any implemented code remains. + + For completed features, consider suggesting the user create a new "removal" + feature if they also want the code removed. + + Args: + feature_id: The ID of the feature to delete + + Returns: + JSON with success message and deleted 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_info = feature.to_dict() + was_passing = feature.passes + + session.delete(feature) + session.commit() + + result = { + "success": True, + "message": f"Deleted feature: {feature_info['name']}", + "deleted_feature": feature_info, + } + + # Add hint for completed features + if was_passing: + result["note"] = "This feature was completed. The implemented code remains in the codebase. If you want the code removed, create a new feature describing what to remove." + + return json.dumps(result, indent=2) + except Exception as e: + session.rollback() + return json.dumps({"error": str(e)}) + finally: + session.close() + + if __name__ == "__main__": mcp.run() diff --git a/server/routers/features.py b/server/routers/features.py index ce0f388d..8af82881 100644 --- a/server/routers/features.py +++ b/server/routers/features.py @@ -17,6 +17,7 @@ FeatureCreate, FeatureListResponse, FeatureResponse, + FeatureUpdate, ) from ..utils.validation import validate_project_name @@ -289,6 +290,53 @@ async def skip_feature(project_name: str, feature_id: int): raise HTTPException(status_code=500, detail="Failed to skip feature") +@router.patch("/{feature_id}", response_model=FeatureResponse) +async def update_feature(project_name: str, feature_id: int, update: FeatureUpdate): + """ + Update a feature's editable fields (category, name, description, steps). + + Only provided fields are updated; others remain unchanged. + Cannot update: id, priority (use skip), passes, in_progress (agent-controlled). + """ + project_name = validate_project_name(project_name) + project_dir = _get_project_path(project_name) + + if not project_dir: + raise HTTPException(status_code=404, detail=f"Project '{project_name}' not found in registry") + + if not project_dir.exists(): + raise HTTPException(status_code=404, detail="Project directory not found") + + _, Feature = _get_db_classes() + + try: + with get_db_session(project_dir) as session: + feature = session.query(Feature).filter(Feature.id == feature_id).first() + + if not feature: + raise HTTPException(status_code=404, detail=f"Feature {feature_id} not found") + + # Get only the fields that were provided (exclude unset) + update_data = update.model_dump(exclude_unset=True) + + if not update_data: + raise HTTPException(status_code=400, detail="No fields to update") + + # Apply updates + for field, value in update_data.items(): + setattr(feature, field, value) + + session.commit() + session.refresh(feature) + + return feature_to_response(feature) + except HTTPException: + raise + except Exception: + logger.exception("Failed to update feature") + raise HTTPException(status_code=500, detail="Failed to update feature") + + @router.post("/bulk", response_model=FeatureBulkCreateResponse) async def create_features_bulk(project_name: str, bulk: FeatureBulkCreate): """ diff --git a/server/schemas.py b/server/schemas.py index cb0a4ecc..28e03f97 100644 --- a/server/schemas.py +++ b/server/schemas.py @@ -87,6 +87,14 @@ class FeatureCreate(FeatureBase): priority: int | None = None +class FeatureUpdate(BaseModel): + """Request schema for updating a feature. All fields optional for partial updates.""" + category: str | None = Field(None, min_length=1, max_length=100) + name: str | None = Field(None, min_length=1, max_length=255) + description: str | None = Field(None, min_length=1) + steps: list[str] | None = Field(None, min_length=1) + + class FeatureResponse(FeatureBase): """Response schema for a feature.""" id: int diff --git a/server/services/assistant_chat_session.py b/server/services/assistant_chat_session.py index bebed941..37e198d7 100755 --- a/server/services/assistant_chat_session.py +++ b/server/services/assistant_chat_session.py @@ -51,11 +51,13 @@ def get_cli_command() -> str: "mcp__features__feature_get_for_regression", ] -# Feature management tools (create/skip but not mark_passing) +# Feature management tools (create/skip/update/delete but not mark_passing) FEATURE_MANAGEMENT_TOOLS = [ "mcp__features__feature_create", "mcp__features__feature_create_bulk", "mcp__features__feature_skip", + "mcp__features__feature_update", + "mcp__features__feature_delete", ] # Combined list for assistant @@ -99,7 +101,9 @@ def get_system_prompt(project_name: str, project_dir: Path) -> str: **Feature Management:** - Create new features/test cases in the backlog +- Update existing features (name, description, category, steps) - Skip features to deprioritize them (move to end of queue) +- Delete features from the backlog (removes tracking only, code remains) - View feature statistics and progress ## What You CANNOT Do @@ -129,6 +133,8 @@ def get_system_prompt(project_name: str, project_dir: Path) -> str: - **feature_create**: Create a single feature in the backlog - **feature_create_bulk**: Create multiple features at once - **feature_skip**: Move a feature to the end of the queue +- **feature_update**: Update a feature's category, name, description, or steps +- **feature_delete**: Remove a feature from the backlog (code remains) ## Creating Features @@ -146,13 +152,39 @@ def get_system_prompt(project_name: str, project_dir: Path) -> str: [calls feature_create with appropriate parameters] You: Done! I've added "S3 Sync Integration" to your backlog. It's now visible on the kanban board. +## Updating Features + +When a user asks to update, modify, edit, or change a feature, use `feature_update`. +You can update any combination of: category, name, description, steps. +Only the fields you provide will be changed; others remain as-is. + +**Example interaction:** +User: "Update feature 25 to have a better description" +You: I'll update that feature's description. What should the new description be? +User: "It should be 'Implement OAuth2 authentication with Google and GitHub providers'" +You: [calls feature_update with feature_id=25 and new description] +You: Done! I've updated the description for feature 25. + +## Deleting Features + +When a user asks to remove, delete, or drop a feature, use `feature_delete`. +This removes the feature from backlog tracking only - any implemented code remains in the codebase. + +**Important:** For completed features, after deleting, suggest creating a new "removal" feature +if the user also wants the code removed. Example: +User: "Delete feature 123 and remove the implementation" +You: [calls feature_delete with feature_id=123] +You: Done! I've removed feature 123 from the backlog. Since this feature was already implemented, +the code still exists. Would you like me to create a new feature for the coding agent to remove +that implementation? + ## Guidelines 1. Be concise and helpful 2. When explaining code, reference specific file paths and line numbers 3. Use the feature tools to answer questions about project progress 4. Search the codebase to find relevant information before answering -5. When creating features, confirm what was created +5. When creating or updating features, confirm what was done 6. If you're unsure about details, ask for clarification""" diff --git a/ui/src/components/FeatureModal.tsx b/ui/src/components/FeatureModal.tsx index 6daede12..2fe6b6a0 100644 --- a/ui/src/components/FeatureModal.tsx +++ b/ui/src/components/FeatureModal.tsx @@ -1,40 +1,147 @@ -import { useState } from 'react' -import { X, CheckCircle2, Circle, SkipForward, Trash2, Loader2, AlertCircle } from 'lucide-react' -import { useSkipFeature, useDeleteFeature } from '../hooks/useProjects' -import type { Feature } from '../lib/types' +import { useState, useId, useEffect } from "react"; +import { + X, + CheckCircle2, + Circle, + SkipForward, + Trash2, + Loader2, + AlertCircle, + Pencil, + Plus, + Save, +} from "lucide-react"; +import { + useSkipFeature, + useDeleteFeature, + useUpdateFeature, +} from "../hooks/useProjects"; +import type { Feature } from "../lib/types"; + +interface Step { + id: string; + value: string; +} interface FeatureModalProps { - feature: Feature - projectName: string - onClose: () => void + feature: Feature; + projectName: string; + onClose: () => void; } -export function FeatureModal({ feature, projectName, onClose }: FeatureModalProps) { - const [error, setError] = useState(null) - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) +export function FeatureModal({ + feature, + projectName, + onClose, +}: FeatureModalProps) { + const formId = useId(); + const [error, setError] = useState(null); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + // Edit mode state + const [isEditing, setIsEditing] = useState(false); + const [editCategory, setEditCategory] = useState(feature.category); + const [editName, setEditName] = useState(feature.name); + const [editDescription, setEditDescription] = useState(feature.description); + const [editSteps, setEditSteps] = useState( + feature.steps.length > 0 + ? feature.steps.map((s, i) => ({ id: `${formId}-step-${i}`, value: s })) + : [{ id: `${formId}-step-0`, value: "" }], + ); + const [stepCounter, setStepCounter] = useState(feature.steps.length || 1); - const skipFeature = useSkipFeature(projectName) - const deleteFeature = useDeleteFeature(projectName) + const skipFeature = useSkipFeature(projectName); + const deleteFeature = useDeleteFeature(projectName); + const updateFeature = useUpdateFeature(projectName); + + // Reset edit form when feature changes or edit mode is exited + useEffect(() => { + if (!isEditing) { + setEditCategory(feature.category); + setEditName(feature.name); + setEditDescription(feature.description); + setEditSteps( + feature.steps.length > 0 + ? feature.steps.map((s, i) => ({ + id: `${formId}-step-${i}`, + value: s, + })) + : [{ id: `${formId}-step-0`, value: "" }], + ); + setStepCounter(feature.steps.length || 1); + } + }, [feature, isEditing, formId]); const handleSkip = async () => { - setError(null) + setError(null); try { - await skipFeature.mutateAsync(feature.id) - onClose() + await skipFeature.mutateAsync(feature.id); + onClose(); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to skip feature') + setError(err instanceof Error ? err.message : "Failed to skip feature"); } - } + }; const handleDelete = async () => { - setError(null) + setError(null); try { - await deleteFeature.mutateAsync(feature.id) - onClose() + await deleteFeature.mutateAsync(feature.id); + onClose(); } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to delete feature') + setError(err instanceof Error ? err.message : "Failed to delete feature"); } - } + }; + + // Edit mode step management + const handleAddStep = () => { + setEditSteps([ + ...editSteps, + { id: `${formId}-step-${stepCounter}`, value: "" }, + ]); + setStepCounter(stepCounter + 1); + }; + + const handleRemoveStep = (id: string) => { + setEditSteps(editSteps.filter((step) => step.id !== id)); + }; + + const handleStepChange = (id: string, value: string) => { + setEditSteps( + editSteps.map((step) => (step.id === id ? { ...step, value } : step)), + ); + }; + + const handleSaveEdit = async () => { + setError(null); + + // Filter out empty steps + const filteredSteps = editSteps + .map((s) => s.value.trim()) + .filter((s) => s.length > 0); + + try { + await updateFeature.mutateAsync({ + featureId: feature.id, + update: { + category: editCategory.trim(), + name: editName.trim(), + description: editDescription.trim(), + steps: filteredSteps.length > 0 ? filteredSteps : undefined, + }, + }); + setIsEditing(false); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to update feature"); + } + }; + + const handleCancelEdit = () => { + setIsEditing(false); + setError(null); + }; + + const isEditValid = + editCategory.trim() && editName.trim() && editDescription.trim(); return (
@@ -45,17 +152,20 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp {/* Header */}
- - {feature.category} - -

- {feature.name} -

+ {isEditing ? ( +

Edit Feature

+ ) : ( + <> + + {feature.category} + +

+ {feature.name} +

+ + )}
-
@@ -67,124 +177,275 @@ export function FeatureModal({ feature, projectName, onClose }: FeatureModalProp
{error} -
)} - {/* Status */} -
- {feature.passes ? ( - <> - - - COMPLETE - - - ) : ( - <> - - - PENDING + {isEditing ? ( + /* Edit Form */ + <> + {/* Category */} +
+ + setEditCategory(e.target.value)} + placeholder="e.g., Authentication, UI, API" + className="neo-input" + required + /> +
+ + {/* Name */} +
+ + setEditName(e.target.value)} + placeholder="e.g., User login form" + className="neo-input" + required + /> +
+ + {/* Description */} +
+ +