diff --git a/agents/blueprints/backend/be-documenter.md b/agents/blueprints/backend/be-documenter.md index d9348f2f..07068994 100644 --- a/agents/blueprints/backend/be-documenter.md +++ b/agents/blueprints/backend/be-documenter.md @@ -85,8 +85,8 @@ If none: `roboco_agent_idle()` ### 6. WRITE **File Paths** - Write documentation to `/app/docs/`: - `/app/docs/backend/` - Backend documentation -- `/app/docs/api/` - API documentation -- `/app/docs/changelog.md` - Changelog +- `/app/docs/backend/api/` - API documentation +- `/app/docs/backend/changelog.md` - Changelog **API Documentation** (if new/changed endpoints) - Endpoint URL, method diff --git a/agents/blueprints/board/auditor.md b/agents/blueprints/board/auditor.md index 17746e84..5488a507 100644 --- a/agents/blueprints/board/auditor.md +++ b/agents/blueprints/board/auditor.md @@ -394,7 +394,7 @@ You should NOT: BE-Dev-1: Anyone know if we have a standard approach for rate limiting? Auditor: I recall seeing something about this in the backend docs. -Auditor: Check docs/patterns/rate-limiting.md if it exists. +Auditor: Check docs/auditor/patterns/rate-limiting.md if it exists. Auditor: If not, might be worth creating one after TASK-042 wraps up. ``` (Note: You knew about TASK-042 from observation, but phrased naturally) diff --git a/agents/blueprints/frontend/fe-documenter.md b/agents/blueprints/frontend/fe-documenter.md index 6faa3f08..8ab5c5be 100644 --- a/agents/blueprints/frontend/fe-documenter.md +++ b/agents/blueprints/frontend/fe-documenter.md @@ -85,8 +85,8 @@ If none: `roboco_agent_idle()` ### 6. WRITE **File Paths** - Write documentation to `/app/docs/`: - `/app/docs/frontend/` - Frontend documentation -- `/app/docs/components/` - Component documentation -- `/app/docs/changelog.md` - Changelog +- `/app/docs/frontend/components/` - Component documentation +- `/app/docs/frontend/changelog.md` - Changelog **Component Documentation** - Props interface diff --git a/agents/blueprints/ux_ui/ux-documenter.md b/agents/blueprints/ux_ui/ux-documenter.md index ae1b6572..a0d7556d 100644 --- a/agents/blueprints/ux_ui/ux-documenter.md +++ b/agents/blueprints/ux_ui/ux-documenter.md @@ -85,8 +85,8 @@ If none: `roboco_agent_idle()` ### 6. WRITE **File Paths** - Write documentation to `/app/docs/`: - `/app/docs/ux_ui/` - UX/UI documentation -- `/app/docs/design-system/` - Design system documentation -- `/app/docs/changelog.md` - Changelog +- `/app/docs/ux_ui/design-system/` - Design system documentation +- `/app/docs/ux_ui/changelog.md` - Changelog **Component Guidelines** - When to use this component diff --git a/roboco/agents/documenter.py b/roboco/agents/documenter.py index 5a7b1deb..25fe32f4 100644 --- a/roboco/agents/documenter.py +++ b/roboco/agents/documenter.py @@ -259,7 +259,7 @@ Respond with structured analysis. DocumentSpec( doc_type=DocType.API, title=f"API documentation for {ctx.title}", - path="docs/api/", + path="docs/backend/api/", priority="required", ) ) @@ -270,7 +270,7 @@ Respond with structured analysis. DocumentSpec( doc_type=DocType.COMPONENT, title=f"Component documentation for {ctx.title}", - path="docs/components/", + path="docs/frontend/components/", priority="required", ) ) diff --git a/roboco/api/routes/messages.py b/roboco/api/routes/messages.py index 47d8bc01..778e6bc3 100644 --- a/roboco/api/routes/messages.py +++ b/roboco/api/routes/messages.py @@ -176,7 +176,7 @@ async def send_message( content=data.content, message_type=data.type, reply_to=data.reply_to, - mentions=data.mentions if data.mentions else None, + mentions=data.mentions if data.mentions else [], task_id=data.task_id, commit_ref=data.commit_ref, ) diff --git a/roboco/api/routes/tasks.py b/roboco/api/routes/tasks.py index 9c0e3de3..e71a4bbf 100644 --- a/roboco/api/routes/tasks.py +++ b/roboco/api/routes/tasks.py @@ -35,7 +35,11 @@ from roboco.models.base import TaskStatus, Team from roboco.models.task import TaskCreate from roboco.services.audit import get_audit_service from roboco.services.permissions import TaskAction -from roboco.services.task import TaskCreateRequest, get_task_service +from roboco.services.task import ( + TaskCreateRequest, + extract_original_developer, + get_task_service, +) router = APIRouter() @@ -714,9 +718,7 @@ async def pass_qa( # QA cannot review their own tasks (prevent self-review) # Check against original developer stored in quick_context, not current assigned_to - original_dev = None - if task.quick_context and task.quick_context.startswith("original_developer:"): - original_dev = task.quick_context.split(":", 1)[1] + original_dev = extract_original_developer(task.quick_context) if original_dev and str(agent.agent_id) == original_dev: audit = get_audit_service() @@ -767,9 +769,7 @@ async def fail_qa( # QA cannot review their own tasks (prevent self-review) # Check against original developer stored in quick_context, not current assigned_to - original_dev = None - if task.quick_context and task.quick_context.startswith("original_developer:"): - original_dev = task.quick_context.split(":", 1)[1] + original_dev = extract_original_developer(task.quick_context) if original_dev and str(agent.agent_id) == original_dev: raise HTTPException( diff --git a/roboco/enforcement/task_lifecycle.py b/roboco/enforcement/task_lifecycle.py index eb3de991..fa6f5377 100644 --- a/roboco/enforcement/task_lifecycle.py +++ b/roboco/enforcement/task_lifecycle.py @@ -34,8 +34,8 @@ VALID_TRANSITIONS: dict[str, list[str]] = { ], # Needs revision - back to work or cancel "needs_revision": ["in_progress", "cancelled"], - # Awaiting QA - can pass (to docs) or fail (needs revision) or cancel - "awaiting_qa": ["awaiting_documentation", "needs_revision", "cancelled"], + # Awaiting QA - can pass (to docs), fail (needs revision), block, or cancel + "awaiting_qa": ["awaiting_documentation", "needs_revision", "blocked", "cancelled"], # Awaiting documentation - can complete or cancel "awaiting_documentation": ["completed", "cancelled"], # Terminal states - cannot transition out diff --git a/roboco/mcp/message_server.py b/roboco/mcp/message_server.py index 7658d5da..917d05dc 100644 --- a/roboco/mcp/message_server.py +++ b/roboco/mcp/message_server.py @@ -430,7 +430,7 @@ async def _handle_message_send( "content": data.content, "is_reply": data.reply_to is not None, "reply_to": data.reply_to, - "mentions": resolved_mentions if resolved_mentions else None, + "mentions": resolved_mentions, "task_id": data.task_id, } diff --git a/roboco/mcp/task_server.py b/roboco/mcp/task_server.py index a8cb3038..9fd38c66 100644 --- a/roboco/mcp/task_server.py +++ b/roboco/mcp/task_server.py @@ -39,6 +39,7 @@ from roboco.agents_config import ( from roboco.config import settings from roboco.llm import ToonAdapter from roboco.mcp.schemas import TaskAssignInput, TaskCreateInput, TaskEscalateInput +from roboco.services.task import extract_original_developer def _get_agent_headers(agent_id: str) -> dict[str, str]: @@ -216,7 +217,8 @@ async def _handle_task_scan(team: str | None, agent_id: str) -> dict[str, Any]: paused_resp.json() if paused_resp.status_code == status.HTTP_200_OK else [] ) - # Get assigned tasks (claimed, in_progress) using /tasks/my + # Get assigned tasks using /tasks/my + # Includes: PM-assigned pending tasks + tasks being actively worked on assigned_resp = await client.get( f"{settings.internal_api_url}/tasks/my", headers=headers, @@ -226,27 +228,55 @@ async def _handle_task_scan(team: str | None, agent_id: str) -> dict[str, Any]: if assigned_resp.status_code == status.HTTP_200_OK else [] ) + # Include pending tasks (PM assigned) + active work statuses assigned_tasks = [ t for t in assigned_data if t.get("status") - in ["claimed", "in_progress", "verifying", "needs_revision"] + in ["pending", "claimed", "in_progress", "verifying", "needs_revision"] ] - # Get available tasks (pending, team pool) - params: dict[str, Any] = {"status": "pending"} - if team: - params["team"] = team - available_resp = await client.get( - f"{settings.internal_api_url}/tasks", - params=params, - headers=headers, - ) - available_tasks = ( - available_resp.json() - if available_resp.status_code == status.HTTP_200_OK - else [] - ) + # Get available tasks based on agent role + # QA agents need awaiting_qa tasks, Documenters need awaiting_documentation + agent_role = get_agent_role(agent_id) + + available_tasks: list[dict[str, Any]] = [] + + if agent_role == "qa": + # QA agents look for tasks awaiting QA review + qa_resp = await client.get( + f"{settings.internal_api_url}/tasks/awaiting-qa", + params={"team": team} if team else {}, + headers=headers, + ) + if qa_resp.status_code == status.HTTP_200_OK: + available_tasks = qa_resp.json() + elif agent_role == "documenter": + # Documenters look for tasks awaiting documentation + doc_resp = await client.get( + f"{settings.internal_api_url}/tasks/awaiting-docs", + params={"team": team} if team else {}, + headers=headers, + ) + if doc_resp.status_code == status.HTTP_200_OK: + available_tasks = doc_resp.json() + else: + # Developers and PMs look for pending tasks + params: dict[str, Any] = {"status": "pending"} + if team: + params["team"] = team + pending_resp = await client.get( + f"{settings.internal_api_url}/tasks", + params=params, + headers=headers, + ) + if pending_resp.status_code == status.HTTP_200_OK: + available_tasks = pending_resp.json() + + # Filter out tasks already in assigned_tasks from available_tasks + # (prevents PM-assigned pending tasks from appearing in both lists) + assigned_ids = {t.get("id") for t in assigned_tasks} + available_tasks = [t for t in available_tasks if t.get("id") not in assigned_ids] # Determine guidance if paused_tasks: @@ -939,13 +969,12 @@ async def _handle_task_submit_qa( "Can only submit verified tasks for QA", ) - # Update with notes + # Update with notes - combine dev_notes and handoff summary + # (handoff summary goes into dev_notes for documenter to read) + combined_notes = f"{dev_notes}\n\n---\nHandoff Summary:\n{handoff_summary}" await client.patch( f"{settings.internal_api_url}/tasks/{task_id}", - json={ - "dev_notes": dev_notes, - "documenter_handoff": handoff_summary, - }, + json={"dev_notes": combined_notes}, headers=headers, ) @@ -1001,12 +1030,12 @@ async def _handle_task_qa_pass( # Check QA is not reviewing own work # Check against original developer stored in quick_context - quick_context = task.get("quick_context") or "" - original_dev = None - if quick_context.startswith("original_developer:"): - original_dev = quick_context.split(":", 1)[1] + quick_context = task.get("quick_context") + original_dev = extract_original_developer(quick_context) - if original_dev and agent_id == original_dev: + # Resolve agent_id to UUID for proper comparison + agent_uuid = await _resolve_agent_uuid(agent_id, headers) + if original_dev and agent_uuid and agent_uuid == original_dev: return _format_error_response( "SELF_REVIEW", "Cannot review your own work.", diff --git a/roboco/runtime/orchestrator.py b/roboco/runtime/orchestrator.py index 830add79..41fa2116 100644 --- a/roboco/runtime/orchestrator.py +++ b/roboco/runtime/orchestrator.py @@ -199,6 +199,13 @@ class AgentOrchestrator: "mcp__roboco-notify__*", # Journal - always needed for reflection "mcp__roboco-journal__*", + # File operations for documenters and developers + "Write(path:/app/docs/**)", + "Write(path:/app/CHANGELOG.md)", + "Write(path:/app/README.md)", + "Edit(path:/app/docs/**)", + "Edit(path:/app/CHANGELOG.md)", + "Edit(path:/app/README.md)", ] # Path to agent Claude settings (shared across all agents) @@ -1037,16 +1044,34 @@ Start by: # ========================================================================= # Keywords that indicate strategic/board-level tasks - _BOARD_KEYWORDS = frozenset({ - "roadmap", "architecture", "security", "budget", "hiring", - "strategy", "vision", "milestone", "release", "launch", - }) + _BOARD_KEYWORDS = frozenset( + { + "roadmap", + "architecture", + "security", + "budget", + "hiring", + "strategy", + "vision", + "milestone", + "release", + "launch", + } + ) # Keywords that indicate PM coordination is needed - _PM_KEYWORDS = frozenset({ - "coordinate", "integration", "cross-team", "sync", - "planning", "milestone", "dependencies", "review", - }) + _PM_KEYWORDS = frozenset( + { + "coordinate", + "integration", + "cross-team", + "sync", + "planning", + "milestone", + "dependencies", + "review", + } + ) def _classify_task_routing(self, task: dict[str, Any]) -> str: """ @@ -1371,9 +1396,7 @@ Start now: roboco_task_get("{task_id}") continue # Not a parent task # Check if all subtasks are completed - all_completed = all( - st.get("status") == "completed" for st in subtasks - ) + all_completed = all(st.get("status") == "completed" for st in subtasks) if not all_completed: continue # Not ready for closure diff --git a/roboco/services/journal.py b/roboco/services/journal.py index d1a9f861..633336b7 100644 --- a/roboco/services/journal.py +++ b/roboco/services/journal.py @@ -654,17 +654,33 @@ class JournalService: entry_id = UUID(entry_id_str) entry = await self.get_entry(entry_id) if entry: - entries.append(entry) + # Filter by agent_id to ensure we only + # return this agent's entries + journal = await self.get_journal(agent_id) + if journal and entry.journal_id == journal.id: + entries.append(entry) except ValueError: logger.warning( "Invalid entry_id in search result", entry_id=entry_id_str, ) + logger.debug( + "Journal search completed", + agent_id=str(agent_id), + query=query[:50], + results_found=len(entries), + ) return entries[:top_k] except Exception as e: - logger.warning("Journal search failed", error=str(e)) + logger.error( + "Journal search failed", + agent_id=str(agent_id), + query=query[:50] if query else "empty", + error=str(e), + error_type=type(e).__name__, + ) return [] diff --git a/roboco/services/task.py b/roboco/services/task.py index 4c89349b..1db1a2e9 100644 --- a/roboco/services/task.py +++ b/roboco/services/task.py @@ -13,18 +13,52 @@ import structlog from sqlalchemy import and_, func, or_, select from sqlalchemy.ext.asyncio import AsyncSession -from roboco.db.tables import AgentTable, HandoffTable, TaskTable +from roboco.db.tables import AgentTable, TaskTable from roboco.enforcement import ( TaskLifecycleError, TaskOwnershipError, validate_task_ownership, validate_task_transition, ) -from roboco.models.base import HandoffStatus, TaskStatus, Team +from roboco.models.base import TaskStatus, Team from roboco.models.task import TaskCreateRequest logger = structlog.get_logger() +# UUID format constants for validation +_UUID_LENGTH = 36 # Standard UUID string length +_UUID_HYPHEN_COUNT = 4 # Number of hyphens in a UUID + + +def extract_original_developer(quick_context: str | None) -> str | None: + """ + Safely extract original developer ID from quick_context. + + The quick_context stores original developer as: "original_developer:{uuid}" + This is used to prevent self-review (QA reviewing their own work). + + Args: + quick_context: The task's quick_context field value + + Returns: + UUID string of original developer, or None if not found/invalid + """ + if not quick_context: + return None + + prefix = "original_developer:" + if not quick_context.startswith(prefix): + return None + + try: + dev_id = quick_context[len(prefix) :].strip() + # Validate it looks like a UUID + if len(dev_id) == _UUID_LENGTH and dev_id.count("-") == _UUID_HYPHEN_COUNT: + return dev_id + return None + except Exception: + return None + class TaskService: """ @@ -152,12 +186,24 @@ class TaskService: valid_statuses.add(TaskStatus.CLAIMED) # Role-based claiming: QA and Documenters can claim specific statuses + # If role is missing but task requires specific role, reject the claim if agent and agent.role: role = agent.role.value if hasattr(agent.role, "value") else str(agent.role) if role == "qa": valid_statuses.add(TaskStatus.AWAITING_QA) elif role == "documenter": valid_statuses.add(TaskStatus.AWAITING_DOCUMENTATION) + elif task.status in {TaskStatus.AWAITING_QA, TaskStatus.AWAITING_DOCUMENTATION}: + # No role information - reject claims for role-specific statuses + logger.warning( + "Cannot claim task - role required for this status", + task_id=str(task_id), + task_status=task.status.value, + agent_id=str(agent_id), + has_agent=agent is not None, + agent_role="none", + ) + return None if task.status not in valid_statuses: logger.warning( @@ -431,23 +477,18 @@ class TaskService: ): return None - # Enforce handoff requirement for tasks that went through full lifecycle - if task.status == TaskStatus.AWAITING_DOCUMENTATION and not skip_handoff_check: - handoff_result = await self.session.execute( - select(HandoffTable).where( - HandoffTable.task_id == task_id, - HandoffTable.status == HandoffStatus.ACCEPTED, - ) + # Enforce lifecycle: tasks awaiting documentation must have passed QA + if ( + task.status == TaskStatus.AWAITING_DOCUMENTATION + and not skip_handoff_check + and not task.qa_verified + ): + logger.warning( + "Cannot complete task - QA verification required", + task_id=str(task_id), + status=task.status.value, ) - handoff = handoff_result.scalar_one_or_none() - - if not handoff: - logger.warning( - "Cannot complete task - handoff required", - task_id=str(task_id), - status=task.status.value, - ) - return None + return None task.completed_at = datetime.now(UTC) task.status = TaskStatus.COMPLETED