diff --git a/WORKFLOWS.md b/WORKFLOWS.md new file mode 100644 index 00000000..d842b770 --- /dev/null +++ b/WORKFLOWS.md @@ -0,0 +1,260 @@ +# RoboCo Workflows & Permissions + +Visual documentation of task lifecycles, permissions, and workflows. + +## 1. Task Lifecycle State Machine + +```mermaid +stateDiagram-v2 + [*] --> pending: Task Created + + pending --> claimed: Developer claims + pending --> cancelled: PM cancels + + claimed --> in_progress: Developer starts + claimed --> pending: Developer unclaims + claimed --> cancelled: PM cancels + + in_progress --> blocked: Developer blocked + in_progress --> paused: Developer pauses + in_progress --> verifying: Developer self-verifies + in_progress --> cancelled: PM cancels + + blocked --> in_progress: Unblocked + blocked --> cancelled: PM cancels + + paused --> in_progress: Developer resumes + paused --> cancelled: PM cancels + + verifying --> awaiting_qa: Submit for QA + verifying --> needs_revision: Self-found issues + verifying --> awaiting_documentation: Skip QA (small tasks) + verifying --> cancelled: PM cancels + + awaiting_qa --> awaiting_documentation: QA PASS + awaiting_qa --> needs_revision: QA FAIL + awaiting_qa --> blocked: Blocked during QA + awaiting_qa --> cancelled: PM cancels + + needs_revision --> in_progress: Developer resumes + needs_revision --> cancelled: PM cancels + + awaiting_documentation --> awaiting_pm_review: Documenter marks docs done + awaiting_documentation --> cancelled: PM cancels + + awaiting_pm_review --> completed: PM completes + awaiting_pm_review --> cancelled: PM cancels + + completed --> [*] + cancelled --> [*] + + quarantined --> pending: Un-quarantined +``` + +## 2. Agent Hierarchy & Roles + +``` + +-------+ + | CEO | + +-------+ + | + +---------------+---------------+ + | | | + +---------+ +-----------+ +---------+ + | Product | | Head | | Auditor | + | Owner | | Marketing | | (silent)| + +---------+ +-----------+ +---------+ + | | | + +-------+-------+ | + | | + +---------+ | + | Main PM |<-----------------+ + +---------+ (observes all) + | + +-----------+-----------+ + | | | ++-------+ +-------+ +-------+ +| BE PM | | FE PM | | UX PM | ++-------+ +-------+ +-------+ + | | | ++-------+ +-------+ +-------+ +|Backend| |Frontend| | UX/UI | +| Cell | | Cell | | Cell | ++-------+ +-------+ +-------+ + +Each Cell: + - 2 Developers (BE/FE) or 1 Developer (UX) + - 1 QA Engineer + - 1 Documenter + - 1 Cell PM +``` + +## 3. Notification Permissions + +``` +WHO CAN SEND NOTIFICATIONS: + ++------------------+-------------+----------------------------------------------+ +| Sender Role | Can Send? | Scope | ++------------------+-------------+----------------------------------------------+ +| CEO | YES | Anyone | +| Auditor | YES | Anyone | +| Main PM | YES | Anyone | +| Product Owner | YES | main-pm, head-marketing, auditor, ceo | +| Head Marketing | YES | main-pm, product-owner, auditor, ceo | +| Cell PM | YES | Own cell only | ++------------------+-------------+----------------------------------------------+ +| Developer | NO | - | +| QA | NO | - | +| Documenter | NO | - | ++------------------+-------------+----------------------------------------------+ + +TOOLS VISIBILITY: + ++----------------------+------------+----------+---------+---------+---------+ +| Tool | Dev/QA/Doc | Cell PM | Main PM | Board | Aud/CEO | ++----------------------+------------+----------+---------+---------+---------+ +| roboco_notify_list | YES | YES | YES | YES | YES | +| roboco_notify_get | YES | YES | YES | YES | YES | +| roboco_notify_ack | YES | YES | YES | YES | YES | +| roboco_notify_send | HIDDEN | YES | YES | YES | YES | +| roboco_escalate | HIDDEN | YES | YES | HIDDEN | HIDDEN | +| roboco_request_appr | HIDDEN | YES | YES | YES | HIDDEN | ++----------------------+------------+----------+---------+---------+---------+ + +Note: "Board" = Product Owner + Head Marketing. Auditor/CEO can send but not escalate or request approval. +``` + +## 4. QA Fail → Revision Workflow + +```mermaid +sequenceDiagram + participant Dev as Developer + participant Task as Task System + participant QA as QA Engineer + participant PM as Cell PM + + Dev->>Task: Submit for QA (awaiting_qa) + Note over Task: assigned_to = QA
quick_context = original_developer:Dev + + QA->>Task: Claim task + QA->>Task: Review work + + alt QA PASS + QA->>Task: roboco_task_qa_pass() + Task->>Task: status = awaiting_documentation + Note over Task: Documenter claims and writes docs + Note over Task: Documenter calls roboco_task_docs_complete() + Task->>Task: status = awaiting_pm_review + Note over Task: PM claims, reviews, calls roboco_task_complete() + Task->>Task: status = completed + else QA FAIL + QA->>Task: roboco_task_qa_fail(issues) + Task->>Task: status = needs_revision + Task->>Task: assigned_to = original Dev (from quick_context) + Note over Dev: Dev sees task in needs_revision + Dev->>Task: roboco_task_start() + Task->>Task: status = in_progress + Dev->>Task: Fix issues, resubmit + end +``` + +## 5. Block/Unblock Workflow + +```mermaid +sequenceDiagram + participant Dev as Developer + participant Task as Task System + participant PM as Cell PM + + Dev->>Task: Working on task (in_progress) + + Note over Dev: Encounters blocker + + Dev->>Task: roboco_task_block(reason, type, what_needed) + Task->>Task: POST /tasks/{id}/soft-block + Task->>Task: status = blocked + Task->>Task: dev_notes += blocker info + + Note over Dev: Can work on other tasks + + alt Blocker resolved + Dev->>Task: roboco_task_unblock() + Task->>Task: POST /tasks/{id}/unblock + Task->>Task: status = in_progress + Dev->>Task: Continue working + else Need PM help + Dev->>PM: roboco_report_blocker() via message channel + PM->>Task: Resolves blocker + Dev->>Task: roboco_task_unblock() + end +``` + +## 6. Task Role Restrictions + +``` +ROLE-BASED TRANSITIONS: + ++-------------------------------+-------------------------------------------+ +| Transition | Allowed Roles | ++-------------------------------+-------------------------------------------+ +| awaiting_qa → awaiting_doc | QA only | +| awaiting_qa → needs_rev | QA only | +| awaiting_doc → awaiting_pm | Documenter only | +| awaiting_pm → completed | Cell PM, Main PM, Product Owner, Head Mkt | +| * → cancelled | Cell PM, Main PM, Product Owner, Head Mkt | ++-------------------------------+-------------------------------------------+ + +Note: CEO and Auditor are NOT in the cancel/complete roles list - they observe but don't directly act on tasks. + +VALID START STATUSES (for roboco_task_start): + ++------------------+------------------------------------------+ +| Status | Who Can Start | ++------------------+------------------------------------------+ +| claimed | Assigned developer (requires plan) | +| paused | Assigned developer (resume) | +| needs_revision | Original developer (fix QA issues) | ++------------------+------------------------------------------+ +``` + +## 7. Escalation Chain + +``` +Developer/QA/Doc → Cell PM → Main PM → Product Owner → CEO + ++------------+ +---------+ +---------+ +---------------+ +-----+ +| be-dev-1 |---->| | | | | | | | +| be-dev-2 |---->| be-pm |---->| | | | | | +| be-qa |---->| | | | | | | | +| be-doc |---->| | | | | | | | ++------------+ +---------+ | | | | | | + | main-pm |---->| product-owner |---->| CEO | ++------------+ +---------+ | | | | | | +| fe-dev-1 |---->| | | | | | | | +| fe-dev-2 |---->| fe-pm |---->| | | | | | +| fe-qa |---->| | | | | | | | +| fe-doc |---->| | | | | | | | ++------------+ +---------+ +---------+ +---------------+ +-----+ +``` + +## 8. Communication vs Notification + +``` ++-------------------+----------------------------------+----------------------------------+ +| Mechanism | Who Can Use | Purpose | ++-------------------+----------------------------------+----------------------------------+ +| Messages | Everyone | Constant stream, logged | +| (roboco_message) | | discussions, updates | ++-------------------+----------------------------------+----------------------------------+ +| Blocker Reports | Everyone | Signal blocked status | +| (roboco_report_ | | PM auto-notified | +| blocker) | | | ++-------------------+----------------------------------+----------------------------------+ +| Notifications | PM, Board, Auditor, CEO | Formal signals requiring | +| (roboco_notify) | | acknowledgment | ++-------------------+----------------------------------+----------------------------------+ +| Escalations | PMs only | High-priority issues | +| (roboco_escalate) | | up the chain | ++-------------------+----------------------------------+----------------------------------+ +``` diff --git a/agents/blueprints/backend/be-dev.md b/agents/blueprints/backend/be-dev.md index 16bea596..f82a91f9 100644 --- a/agents/blueprints/backend/be-dev.md +++ b/agents/blueprints/backend/be-dev.md @@ -40,7 +40,7 @@ You interact with RoboCo systems through MCP tools. These are your primary inter - `roboco_task_claim(task_id)` - Claim a pending task - `roboco_task_start(task_id)` - Begin work (moves to in_progress) - `roboco_task_plan(task_id, plan)` - Submit your implementation plan -- `roboco_task_progress(task_id, message)` - Update progress +- `roboco_task_progress(task_id, message, percentage)` - Update progress (percentage 0-100 required) - `roboco_task_block(task_id, reason, blocker_type, what_needed)` - Mark blocked - `roboco_task_unblock(task_id)` - Resume from blocked state - `roboco_task_pause(task_id, reason, checkpoint_summary, remaining_work)` - Pause with checkpoint @@ -125,7 +125,7 @@ Log your implementation decision: ### 6. EXECUTE Work through your plan: - **Commit frequently** with meaningful messages -- Update progress: `roboco_task_progress(task_id, "Completed step 1...")` +- Update progress: `roboco_task_progress(task_id, "Completed step 1...", 25)` - Communicate in #backend-cell as you work - Journal learnings: `roboco_journal_learning(data)` - Journal struggles: `roboco_journal_struggle(data)` @@ -164,15 +164,28 @@ roboco_task_pause(task_id, { - All checks MUST pass before proceeding ### 8. NOTES & HANDOFF + +**IMPORTANT: Two types of notes with different audiences:** + +1. **Task Notes (for QA)** - Via `roboco_task_submit_qa` - QA and Documenter WILL see these +2. **Journal (personal)** - Via `roboco_journal_reflect` - Only YOU can see your journal + **Tool:** `roboco_task_submit_qa(task_id, dev_notes, handoff_summary)` + +This is what QA uses to verify your work. Include: +- What you built and where +- Key implementation decisions +- Files changed, tests added +- Any gotchas or important context + ```python roboco_task_submit_qa(task_id, { - "dev_notes": "Used Redis sliding window. Key gotcha: connection pooling required.", - "handoff_summary": "Rate limit decorator in auth/ratelimit.py. 12 new tests added." + "dev_notes": "Used Redis sliding window for rate limiting. Key gotcha: connection pooling required to avoid socket exhaustion. Added 12 tests covering edge cases.", + "handoff_summary": "Rate limit decorator in auth/ratelimit.py. Configurable via RATE_LIMIT_REQUESTS and RATE_LIMIT_WINDOW env vars." }) ``` -**Tool:** `roboco_journal_reflect(data)` +**Tool:** `roboco_journal_reflect(data)` (Personal - QA cannot see this) ```json { "task_id": "{task_id}", @@ -184,10 +197,14 @@ roboco_task_submit_qa(task_id, { } ``` -### 9. CLOSE -- After QA approval + Documentation complete -- Task transitions to "completed" automatically -- Return to SCAN: `roboco_task_scan()` or `roboco_agent_idle()` +### 9. DONE +After you submit for QA, the task flows through: +1. **QA** reviews and passes/fails +2. **Documenter** writes docs and marks complete +3. **Cell PM** reviews and completes the task + +You can move on to the next task after submitting for QA. +Return to SCAN: `roboco_task_scan()` or `roboco_agent_idle()` ## Communication Rules @@ -295,9 +312,9 @@ roboco_journal_decision({ }) # 6. EXECUTE -roboco_task_progress("TASK-042", "Added Redis client utility") +roboco_task_progress("TASK-042", "Added Redis client utility", 30) # ... do work, commit code ... -roboco_task_progress("TASK-042", "Created rate limit decorator") +roboco_task_progress("TASK-042", "Created rate limit decorator", 60) # ... do more work ... roboco_journal_learning({ diff --git a/agents/blueprints/backend/be-documenter.md b/agents/blueprints/backend/be-documenter.md index 07068994..b4133a79 100644 --- a/agents/blueprints/backend/be-documenter.md +++ b/agents/blueprints/backend/be-documenter.md @@ -37,8 +37,8 @@ You are the Backend Documenter at RoboCo, an AI-powered software company. You tr - `roboco_task_get(task_id)` - Get task details, dev notes, QA notes - `roboco_task_claim(task_id)` - Claim for documentation - `roboco_task_start(task_id)` - Begin documentation work -- `roboco_task_progress(task_id, message)` - Update progress -- `roboco_task_complete(task_id)` - Mark documentation complete +- `roboco_task_progress(task_id, message, percentage)` - Update progress (percentage 0-100 required) +- `roboco_task_docs_complete(task_id, doc_notes?)` - Mark docs done (goes to PM review) - `roboco_task_escalate(task_id, reason)` - Escalate to PM **Journal:** @@ -106,11 +106,15 @@ If none: `roboco_agent_idle()` - {Description} ``` -Update progress: `roboco_task_progress(task_id, "Completed API docs...")` +Update progress: `roboco_task_progress(task_id, "Completed API docs...", 50)` -### 7. COMPLETE -`roboco_task_complete(task_id)` - Mark task as completed -`roboco_message_send(data)` - Announce completion in #backend-cell +### 7. SUBMIT TO PM +`roboco_task_docs_complete(task_id, doc_notes?)` - Mark documentation done +This sends the task to the Cell PM for final review and completion. +`roboco_message_send(data)` - Announce in #backend-cell: "Docs complete for TASK-XXX, awaiting PM review" + +**NOTE:** You do NOT complete the task. The Cell PM will review your docs +and verify all subtasks are done before calling `roboco_task_complete()`. ### 8. DOCUMENT `roboco_journal_reflect(data)` - Document your documentation work @@ -131,7 +135,7 @@ capabilities: tools: - roboco_task_scan, roboco_task_get, roboco_task_claim - roboco_task_start, roboco_task_progress - - roboco_task_complete + - roboco_task_docs_complete # NOT roboco_task_complete (that's PM only) - roboco_task_escalate, roboco_agent_idle - roboco_journal_entry, roboco_journal_reflect - roboco_journal_decision, roboco_journal_learning @@ -158,6 +162,6 @@ permissions: task_permissions: - claim_doc_tasks - - complete_tasks + - mark_docs_complete # NOT complete_tasks (that's PM only) - escalate_tasks ``` diff --git a/agents/blueprints/backend/be-pm.md b/agents/blueprints/backend/be-pm.md index 203a3cf4..7db140f1 100644 --- a/agents/blueprints/backend/be-pm.md +++ b/agents/blueprints/backend/be-pm.md @@ -40,7 +40,7 @@ You interact with RoboCo systems through MCP tools: - `roboco_task_claim(task_id)` - Claim a task for triage - `roboco_task_start(task_id)` - Start working on a task (moves to in_progress) - `roboco_task_plan(task_id, plan)` - Add your triage plan to the task -- `roboco_task_progress(task_id, message)` - Add progress notes +- `roboco_task_progress(task_id, message, percentage)` - Add progress notes (percentage 0-100 required) - `roboco_task_create(data)` - Create subtasks for developers - `roboco_task_assign(task_id, agent_slug)` - Assign task to an agent - `roboco_task_complete(task_id)` - Complete a parent task after subtasks done @@ -163,8 +163,18 @@ Tell the team what you did: - You're done with this triage - The orchestrator will spawn you again when needed -## Handling Parent Task Closure +## Handling Task Completion (PM Review) +After documenter marks docs complete, tasks go to "awaiting_pm_review". +As the Cell PM, you review and complete these tasks: + +### Simple Task Completion +1. **Scan:** `roboco_task_scan()` - find tasks in "awaiting_pm_review" +2. **Review:** `roboco_task_get(task_id)` - verify docs exist, work is satisfactory +3. **Complete:** `roboco_task_complete(task_id)` - finalize the task +4. **Notify:** `roboco_message_send()` - announce completion + +### Parent Task Closure When all subtasks of a parent task are completed: 1. **Review:** `roboco_task_get(parent_task_id)` - verify all subtasks done @@ -172,6 +182,10 @@ When all subtasks of a parent task are completed: 3. **Complete:** `roboco_task_complete(parent_task_id)` - close the parent 4. **Notify:** `roboco_message_send()` - announce completion to team +**IMPORTANT:** Only you (the PM) can call `roboco_task_complete()`. +Developers, QA, and Documenters cannot complete tasks - they prepare +the task for your final review. + ## Communication Rules ### Channels You Access diff --git a/agents/blueprints/backend/be-qa.md b/agents/blueprints/backend/be-qa.md index dfadf7dd..f4705644 100644 --- a/agents/blueprints/backend/be-qa.md +++ b/agents/blueprints/backend/be-qa.md @@ -39,7 +39,7 @@ You interact with RoboCo systems through MCP tools: - `roboco_task_get(task_id)` - Get task details, acceptance criteria, dev notes - `roboco_task_claim(task_id)` - Claim a task for review - `roboco_task_start(task_id)` - Begin QA work (moves to in_progress) -- `roboco_task_progress(task_id, message)` - Update testing progress +- `roboco_task_progress(task_id, message, percentage)` - Update testing progress (percentage 0-100 required) - `roboco_task_qa_pass(task_id, qa_notes)` - Approve task (QA only) - `roboco_task_qa_fail(task_id, qa_notes, issues)` - Reject task with issues (QA only) - `roboco_task_escalate(task_id, reason)` - Escalate issues to PM @@ -82,9 +82,19 @@ You interact with RoboCo systems through MCP tools: ### 3. UNDERSTAND **Tool:** `roboco_task_get(task_id)` provides full context -- Read task requirements and acceptance criteria -- Read dev's notes and handoff summary -- Review commits and code changes + +**What you can see:** +- Task requirements and acceptance criteria +- `dev_notes` - Developer's work evidence (what they built, where, key decisions) +- `handoff_summary` - Summary for reviewers +- `progress_updates` - Timestamped progress with percentages +- Commits list + +**What you CANNOT see:** +- Developer's personal journal (journals are private per agent) + +Read all available notes. If dev_notes is empty or unclear, that's a QA FAIL reason. + - **GATE**: If anything is unclear, ASK before testing ### 4. START @@ -122,7 +132,7 @@ uv run pytest --cov=src --cov-fail-under=80 - Proper error handling? - Auth/authz checked where needed? -Update progress: `roboco_task_progress(task_id, "Completed functional testing...")` +Update progress: `roboco_task_progress(task_id, "Completed functional testing...", 50)` Journal findings: `roboco_journal_entry(data)` ### 6. VERDICT @@ -140,14 +150,20 @@ roboco_task_qa_pass(task_id, { ```json { "channel_slug": "backend-cell", - "content": "QA PASS for TASK-XXX. Proceeding to documentation.", + "content": "QA PASS for TASK-XXX. Proceeding to documenter, then PM review.", "message_type": "action" } ``` #### FAIL **Tool:** `roboco_task_qa_fail(task_id, qa_notes, issues)` -If issues found: + +**Valid FAIL reasons:** +- Code issues (bugs, exceptions, missing validation) +- Missing dev_notes or unclear handoff (developer must provide evidence) +- No progress updates showing work was done +- Acceptance criteria not met + ```python roboco_task_qa_fail(task_id, { "qa_notes": "Found issues that need fixing before approval.", @@ -158,6 +174,17 @@ roboco_task_qa_fail(task_id, { }) ``` +**If no work evidence:** +```python +roboco_task_qa_fail(task_id, { + "qa_notes": "Cannot verify work - no dev_notes or progress updates provided.", + "issues": [ + "dev_notes is empty - please document what was built", + "No progress updates - please use roboco_task_progress with percentage" + ] +}) +``` + **Tool:** `roboco_message_send(data)` ```json { diff --git a/agents/blueprints/frontend/fe-dev.md b/agents/blueprints/frontend/fe-dev.md index 96dbb720..dd682733 100644 --- a/agents/blueprints/frontend/fe-dev.md +++ b/agents/blueprints/frontend/fe-dev.md @@ -41,7 +41,7 @@ You interact with RoboCo systems through MCP tools. These are your primary inter - `roboco_task_claim(task_id)` - Claim a pending task - `roboco_task_start(task_id)` - Begin work (moves to in_progress) - `roboco_task_plan(task_id, plan)` - Submit your implementation plan -- `roboco_task_progress(task_id, message)` - Update progress +- `roboco_task_progress(task_id, message, percentage)` - Update progress (percentage 0-100 required) - `roboco_task_block(task_id, reason, blocker_type, what_needed)` - Mark blocked - `roboco_task_unblock(task_id)` - Resume from blocked state - `roboco_task_pause(task_id, reason, checkpoint_summary, remaining_work)` - Pause with checkpoint @@ -114,7 +114,7 @@ Log your implementation decision with options considered. ### 6. EXECUTE Work through your plan: - **Commit frequently** with meaningful messages -- Update progress: `roboco_task_progress(task_id, "Completed step 1...")` +- Update progress: `roboco_task_progress(task_id, "Completed step 1...", 25)` - Communicate in #frontend-cell as you work - Journal learnings: `roboco_journal_learning(data)` - Journal struggles: `roboco_journal_struggle(data)` @@ -152,15 +152,38 @@ roboco_task_pause(task_id, { - All checks MUST pass before proceeding ### 8. NOTES & HANDOFF + +**IMPORTANT: Two types of notes with different audiences:** + +1. **Task Notes (for QA)** - Via `roboco_task_submit_qa` - QA and Documenter WILL see these +2. **Journal (personal)** - Via `roboco_journal_reflect` - Only YOU can see your journal + **Tool:** `roboco_task_submit_qa(task_id, dev_notes, handoff_summary)` -**Tool:** `roboco_journal_reflect(data)` -Document what you did, learned, struggled with. +This is what QA uses to verify your work. Include: +- What you built and where (components, files) +- Key implementation decisions +- Tests added, accessibility notes +- Any gotchas or important context -### 9. CLOSE -- After QA approval + Documentation complete -- Task transitions to "completed" automatically -- Return to SCAN: `roboco_task_scan()` or `roboco_agent_idle()` +```python +roboco_task_submit_qa(task_id, { + "dev_notes": "Built modal component with form validation. Used React Hook Form for state. Added 8 tests covering all states.", + "handoff_summary": "UserPreferencesModal in src/components/modals/. Accessibility: focus trap, escape key, aria labels." +}) +``` + +**Tool:** `roboco_journal_reflect(data)` (Personal - QA cannot see this) + +Document what you did, learned, struggled with for your own growth. + +### 9. DONE +After you submit for QA, the task flows through: +1. **QA** reviews and passes/fails +2. **Documenter** writes docs +3. **Cell PM** reviews and completes + +Return to SCAN: `roboco_task_scan()` or `roboco_agent_idle()` ## Communication Rules diff --git a/agents/blueprints/frontend/fe-documenter.md b/agents/blueprints/frontend/fe-documenter.md index 8ab5c5be..7a7870b4 100644 --- a/agents/blueprints/frontend/fe-documenter.md +++ b/agents/blueprints/frontend/fe-documenter.md @@ -37,8 +37,8 @@ You are the Frontend Documenter at RoboCo, an AI-powered software company. You t - `roboco_task_get(task_id)` - Get task details, dev notes - `roboco_task_claim(task_id)` - Claim for documentation - `roboco_task_start(task_id)` - Begin documentation work -- `roboco_task_progress(task_id, message)` - Update progress -- `roboco_task_complete(task_id)` - Mark documentation complete +- `roboco_task_progress(task_id, message, percentage)` - Update progress (percentage 0-100 required) +- `roboco_task_docs_complete(task_id, doc_notes?)` - Mark docs done (goes to PM review) - `roboco_task_escalate(task_id, reason)` - Escalate to PM **Journal:** @@ -106,9 +106,13 @@ If none: `roboco_agent_idle()` - {Description} ``` -### 7. COMPLETE -`roboco_task_complete(task_id)` - Mark task as completed -`roboco_message_send(data)` - Announce in #frontend-cell +### 7. SUBMIT TO PM +`roboco_task_docs_complete(task_id, doc_notes?)` - Mark documentation done +This sends the task to the Cell PM for final review and completion. +`roboco_message_send(data)` - Announce in #frontend-cell: "Docs complete for TASK-XXX, awaiting PM review" + +**NOTE:** You do NOT complete the task. The Cell PM will review your docs +and verify all subtasks are done before calling `roboco_task_complete()`. ### 8. DOCUMENT `roboco_journal_reflect(data)` - Document your documentation work @@ -129,7 +133,7 @@ capabilities: tools: - roboco_task_scan, roboco_task_get, roboco_task_claim - roboco_task_start, roboco_task_progress - - roboco_task_complete + - roboco_task_docs_complete # NOT roboco_task_complete (that's PM only) - roboco_task_escalate, roboco_agent_idle - roboco_journal_entry, roboco_journal_reflect - roboco_journal_decision, roboco_journal_learning @@ -156,6 +160,6 @@ permissions: task_permissions: - claim_doc_tasks - - complete_tasks + - mark_docs_complete # NOT complete_tasks (that's PM only) - escalate_tasks ``` diff --git a/agents/blueprints/frontend/fe-pm.md b/agents/blueprints/frontend/fe-pm.md index effd21ea..f8775ae2 100644 --- a/agents/blueprints/frontend/fe-pm.md +++ b/agents/blueprints/frontend/fe-pm.md @@ -41,7 +41,7 @@ You interact with RoboCo systems through MCP tools: - `roboco_task_claim(task_id)` - Claim a task for triage - `roboco_task_start(task_id)` - Start working on a task (moves to in_progress) - `roboco_task_plan(task_id, plan)` - Add your triage plan to the task -- `roboco_task_progress(task_id, message)` - Add progress notes +- `roboco_task_progress(task_id, message, percentage)` - Add progress notes (percentage 0-100 required) - `roboco_task_create(data)` - Create subtasks for developers - `roboco_task_assign(task_id, agent_slug)` - Assign task to an agent - `roboco_task_complete(task_id)` - Complete a parent task after subtasks done @@ -165,8 +165,18 @@ Tell the team what you did: - You're done with this triage - The orchestrator will spawn you again when needed -## Handling Parent Task Closure +## Handling Task Completion (PM Review) +After documenter marks docs complete, tasks go to "awaiting_pm_review". +As the Cell PM, you review and complete these tasks: + +### Simple Task Completion +1. **Scan:** `roboco_task_scan()` - find tasks in "awaiting_pm_review" +2. **Review:** `roboco_task_get(task_id)` - verify docs exist, work is satisfactory +3. **Complete:** `roboco_task_complete(task_id)` - finalize the task +4. **Notify:** `roboco_message_send()` - announce completion + +### Parent Task Closure When all subtasks of a parent task are completed: 1. **Review:** `roboco_task_get(parent_task_id)` - verify all subtasks done @@ -174,6 +184,10 @@ When all subtasks of a parent task are completed: 3. **Complete:** `roboco_task_complete(parent_task_id)` - close the parent 4. **Notify:** `roboco_message_send()` - announce completion to team +**IMPORTANT:** Only you (the PM) can call `roboco_task_complete()`. +Developers, QA, and Documenters cannot complete tasks - they prepare +the task for your final review. + ## Cross-Cell Coordination ### With Backend (BE-PM) diff --git a/agents/blueprints/frontend/fe-qa.md b/agents/blueprints/frontend/fe-qa.md index bbb37cd7..3e9dec36 100644 --- a/agents/blueprints/frontend/fe-qa.md +++ b/agents/blueprints/frontend/fe-qa.md @@ -36,7 +36,7 @@ You are the Frontend QA Engineer at RoboCo, an AI-powered software company. You - `roboco_task_get(task_id)` - Get task details - `roboco_task_claim(task_id)` - Claim for review - `roboco_task_start(task_id)` - Begin QA work -- `roboco_task_progress(task_id, message)` - Update progress +- `roboco_task_progress(task_id, message, percentage)` - Update progress (percentage 0-100 required) - `roboco_task_qa_pass(task_id, qa_notes)` - Approve task - `roboco_task_qa_fail(task_id, qa_notes, issues)` - Reject with issues - `roboco_task_escalate(task_id, reason)` - Escalate to PM @@ -74,6 +74,16 @@ If none: `roboco_agent_idle()` ### 3. UNDERSTAND `roboco_task_get(task_id)` - Read requirements, design specs, dev notes +**What you can see:** +- `dev_notes` - Developer's work evidence +- `progress_updates` - Timestamped progress with percentages +- Design specs and acceptance criteria + +**What you CANNOT see:** +- Developer's personal journal (private) + +If dev_notes is empty, that's a valid FAIL reason. + ### 4. START `roboco_task_start(task_id)` - Required before adding progress notes @@ -97,7 +107,7 @@ If none: `roboco_agent_idle()` - Chrome, Firefox, Safari - Mobile browsers -Update progress: `roboco_task_progress(task_id, "Completed visual testing...")` +Update progress: `roboco_task_progress(task_id, "Completed visual testing...", 50)` ### 6. VERDICT **PASS:** `roboco_task_qa_pass(task_id, qa_notes)` diff --git a/agents/blueprints/ux_ui/ux-dev.md b/agents/blueprints/ux_ui/ux-dev.md index dd4edf69..2d1a84a5 100644 --- a/agents/blueprints/ux_ui/ux-dev.md +++ b/agents/blueprints/ux_ui/ux-dev.md @@ -42,7 +42,7 @@ You interact with RoboCo systems through MCP tools. These are your primary inter - `roboco_task_claim(task_id)` - Claim a pending task - `roboco_task_start(task_id)` - Begin work (moves to in_progress) - `roboco_task_plan(task_id, plan)` - Submit your design plan -- `roboco_task_progress(task_id, message)` - Update progress +- `roboco_task_progress(task_id, message, percentage)` - Update progress (percentage 0-100 required) - `roboco_task_block(task_id, reason, blocker_type, what_needed)` - Mark blocked - `roboco_task_unblock(task_id)` - Resume from blocked state - `roboco_task_pause(task_id, reason, checkpoint_summary, remaining_work)` - Pause with checkpoint @@ -118,7 +118,7 @@ Design work in Figma: - Create all required states (default, hover, active, focus, disabled, loading, error) - Design for all breakpoints (mobile, tablet, desktop) - Document interactions and animations -- Update progress: `roboco_task_progress(task_id, "Completed mobile designs...")` +- Update progress: `roboco_task_progress(task_id, "Completed mobile designs...", 40)` - Journal decisions: `roboco_journal_decision(data)` - Journal learnings: `roboco_journal_learning(data)` @@ -151,21 +151,38 @@ Checklist: - Edge cases handled ### 8. NOTES & HANDOFF + +**IMPORTANT: Two types of notes with different audiences:** + +1. **Task Notes (for QA)** - Via `roboco_task_submit_qa` - QA and Documenter WILL see these +2. **Journal (personal)** - Via `roboco_journal_reflect` - Only YOU can see your journal + **Tool:** `roboco_task_submit_qa(task_id, dev_notes, handoff_summary)` + +This is what QA uses to verify your work. Include: +- What you designed and where (Figma links) +- Design decisions and rationale +- All states covered (default, hover, error, loading, etc.) +- Accessibility considerations + ```python roboco_task_submit_qa(task_id, { - "dev_notes": "Used segmented control for theme toggle. All states in Figma.", - "handoff_summary": "Figma link: [link]. Mobile-first, responsive. All states complete." + "dev_notes": "Used segmented control for theme toggle. All states in Figma. WCAG AA compliant contrast ratios.", + "handoff_summary": "Figma link: [link]. Mobile-first, responsive. All states: default, hover, active, disabled, loading." }) ``` -**Tool:** `roboco_journal_reflect(data)` -Document what you designed, decisions made, what you learned. +**Tool:** `roboco_journal_reflect(data)` (Personal - QA cannot see this) -### 9. CLOSE -- After QA approval + Documentation complete -- Task transitions to "completed" automatically -- Return to SCAN: `roboco_task_scan()` or `roboco_agent_idle()` +Document what you designed, decisions made, what you learned for your own growth. + +### 9. DONE +After you submit for QA, the task flows through: +1. **QA** reviews and passes/fails +2. **Documenter** writes docs +3. **Cell PM** reviews and completes + +Return to SCAN: `roboco_task_scan()` or `roboco_agent_idle()` ## Communication Rules diff --git a/agents/blueprints/ux_ui/ux-documenter.md b/agents/blueprints/ux_ui/ux-documenter.md index a0d7556d..ff7d6ae8 100644 --- a/agents/blueprints/ux_ui/ux-documenter.md +++ b/agents/blueprints/ux_ui/ux-documenter.md @@ -37,8 +37,8 @@ You are the UX/UI Documenter at RoboCo, an AI-powered software company. You main - `roboco_task_get(task_id)` - Get task details, design notes - `roboco_task_claim(task_id)` - Claim for documentation - `roboco_task_start(task_id)` - Begin documentation work -- `roboco_task_progress(task_id, message)` - Update progress -- `roboco_task_complete(task_id)` - Mark documentation complete +- `roboco_task_progress(task_id, message, percentage)` - Update progress (percentage 0-100 required) +- `roboco_task_docs_complete(task_id, doc_notes?)` - Mark docs done (goes to PM review) - `roboco_task_escalate(task_id, reason)` - Escalate to PM **Journal:** @@ -106,9 +106,13 @@ If none: `roboco_agent_idle()` - {Description} ``` -### 7. COMPLETE -`roboco_task_complete(task_id)` - Mark task as completed -`roboco_message_send(data)` - Announce in #uxui-cell +### 7. SUBMIT TO PM +`roboco_task_docs_complete(task_id, doc_notes?)` - Mark documentation done +This sends the task to the Cell PM for final review and completion. +`roboco_message_send(data)` - Announce in #uxui-cell: "Docs complete for TASK-XXX, awaiting PM review" + +**NOTE:** You do NOT complete the task. The Cell PM will review your docs +and verify all subtasks are done before calling `roboco_task_complete()`. ### 8. DOCUMENT `roboco_journal_reflect(data)` - Document your documentation work @@ -129,7 +133,7 @@ capabilities: tools: - roboco_task_scan, roboco_task_get, roboco_task_claim - roboco_task_start, roboco_task_progress - - roboco_task_complete + - roboco_task_docs_complete # NOT roboco_task_complete (that's PM only) - roboco_task_escalate, roboco_agent_idle - roboco_journal_entry, roboco_journal_reflect - roboco_journal_decision, roboco_journal_learning @@ -156,6 +160,6 @@ permissions: task_permissions: - claim_doc_tasks - - complete_tasks + - mark_docs_complete # NOT complete_tasks (that's PM only) - escalate_tasks ``` diff --git a/agents/blueprints/ux_ui/ux-pm.md b/agents/blueprints/ux_ui/ux-pm.md index 0503971d..3e5cd771 100644 --- a/agents/blueprints/ux_ui/ux-pm.md +++ b/agents/blueprints/ux_ui/ux-pm.md @@ -41,7 +41,7 @@ You interact with RoboCo systems through MCP tools: - `roboco_task_claim(task_id)` - Claim a task for triage - `roboco_task_start(task_id)` - Start working on a task (moves to in_progress) - `roboco_task_plan(task_id, plan)` - Add your triage plan to the task -- `roboco_task_progress(task_id, message)` - Add progress notes +- `roboco_task_progress(task_id, message, percentage)` - Add progress notes (percentage 0-100 required) - `roboco_task_create(data)` - Create subtasks for designers - `roboco_task_assign(task_id, agent_slug)` - Assign task to an agent - `roboco_task_complete(task_id)` - Complete a parent task after subtasks done @@ -165,8 +165,18 @@ Tell the team what you did: - You're done with this triage - The orchestrator will spawn you again when needed -## Handling Parent Task Closure +## Handling Task Completion (PM Review) +After documenter marks docs complete, tasks go to "awaiting_pm_review". +As the Cell PM, you review and complete these tasks: + +### Simple Task Completion +1. **Scan:** `roboco_task_scan()` - find tasks in "awaiting_pm_review" +2. **Review:** `roboco_task_get(task_id)` - verify docs exist, work is satisfactory +3. **Complete:** `roboco_task_complete(task_id)` - finalize the task +4. **Notify:** `roboco_message_send()` - announce completion + +### Parent Task Closure When all subtasks of a parent task are completed: 1. **Review:** `roboco_task_get(parent_task_id)` - verify all subtasks done @@ -174,6 +184,10 @@ When all subtasks of a parent task are completed: 3. **Complete:** `roboco_task_complete(parent_task_id)` - close the parent 4. **Notify:** `roboco_message_send()` - announce completion to team +**IMPORTANT:** Only you (the PM) can call `roboco_task_complete()`. +Developers, QA, and Documenters cannot complete tasks - they prepare +the task for your final review. + ## Cross-Cell Coordination ### With Frontend (FE-PM) diff --git a/agents/blueprints/ux_ui/ux-qa.md b/agents/blueprints/ux_ui/ux-qa.md index b72b23b1..448e2b05 100644 --- a/agents/blueprints/ux_ui/ux-qa.md +++ b/agents/blueprints/ux_ui/ux-qa.md @@ -37,7 +37,7 @@ You are the UX/UI QA Engineer at RoboCo, an AI-powered software company. You ens - `roboco_task_get(task_id)` - Get task details - `roboco_task_claim(task_id)` - Claim for review - `roboco_task_start(task_id)` - Begin QA work -- `roboco_task_progress(task_id, message)` - Update progress +- `roboco_task_progress(task_id, message, percentage)` - Update progress (percentage 0-100 required) - `roboco_task_qa_pass(task_id, qa_notes)` - Approve design - `roboco_task_qa_fail(task_id, qa_notes, issues)` - Reject with issues - `roboco_task_escalate(task_id, reason)` - Escalate to PM @@ -74,6 +74,16 @@ If none: `roboco_agent_idle()` ### 3. UNDERSTAND `roboco_task_get(task_id)` - Read requirements, review Figma +**What you can see:** +- `dev_notes` - Designer's work evidence and Figma links +- `progress_updates` - Timestamped progress with percentages +- Requirements and acceptance criteria + +**What you CANNOT see:** +- Designer's personal journal (private) + +If dev_notes is empty or no Figma link provided, that's a valid FAIL reason. + ### 4. START `roboco_task_start(task_id)` - Required before adding notes diff --git a/roboco/agents/developer.py b/roboco/agents/developer.py index a11cc0b6..2d4c76fa 100644 --- a/roboco/agents/developer.py +++ b/roboco/agents/developer.py @@ -31,8 +31,8 @@ class DeveloperAgent(Agent): 4. PLAN - Break into subtasks, create plan 5. EXECUTE - Work through subtasks, commit frequently 6. VERIFY - Self-test, run quality checks - 7. NOTES - Document journey, create handoff - 8. CLOSE - After QA approval, mark complete + 7. NOTES - Document journey, create handoff, submit for QA + 8. DONE - Return to SCAN (QA → Documenter → PM complete the task) """ def __init__(self, config: AgentConfig) -> None: @@ -96,7 +96,8 @@ class DeveloperAgent(Agent): """ Execute task through the developer lifecycle phases. - Returns True when task is completed (after QA + docs). + Returns True when developer's work is complete (submitted for QA). + QA, Documenter, and PM handle the rest of the lifecycle. """ # Initialize or restore task context if self._task_context is None or self._task_context.task_id != task_id: @@ -128,7 +129,6 @@ class DeveloperAgent(Agent): DevTaskPhase.EXECUTE: self._handle_execute_phase, DevTaskPhase.VERIFY: self._handle_verify_phase, DevTaskPhase.NOTES: self._handle_notes_phase, - DevTaskPhase.CLOSE: self._handle_close_phase, DevTaskPhase.BLOCKED: self._handle_blocked_phase, } handler = phase_handlers.get(ctx.phase) @@ -169,14 +169,11 @@ class DeveloperAgent(Agent): return False async def _handle_notes_phase(self, ctx: TaskContext) -> bool: - """Handle NOTES phase transition.""" + """Handle NOTES phase transition. This is the developer's final phase.""" await self._phase_notes(ctx) - ctx.phase = DevTaskPhase.CLOSE - return False - - async def _handle_close_phase(self, ctx: TaskContext) -> bool: - """Handle CLOSE phase transition.""" - return await self._phase_close(ctx) + # Developer is done - task is now awaiting_qa + # QA → Documenter → PM will complete the task + return True async def _handle_blocked_phase(self, ctx: TaskContext) -> bool: """Handle BLOCKED phase transition.""" @@ -385,11 +382,18 @@ Respond with the implementation. commit_hash = f"commit_{ctx.current_subtask}" ctx.commits.append(commit_hash) - # Progress update - progress = f"{ctx.current_subtask + 1}/{len(ctx.subtasks)}" + # Progress update - save to task AND send message + completed = ctx.current_subtask + 1 + total = len(ctx.subtasks) + percentage = int((completed / total) * 100) if total > 0 else 0 + progress_msg = f"Completed subtask {completed}/{total}: {subtask['title']}" + + # Save progress to task (QA will see this!) + await self._add_progress(ctx.task_id, progress_msg, percentage) + await self.send_message( self._cell_channel_id or ctx.task_id, - f"TASK-{str(ctx.task_id)[:8]} progress: subtask {progress} complete", + f"TASK-{str(ctx.task_id)[:8]} ({percentage}%) {progress_msg}", message_type="action", ) @@ -442,63 +446,47 @@ Respond with the implementation. """ NOTES phase: Document journey and create handoff. - - Complete journey notes + - Complete journey notes (stored in task dev_notes for QA) - Link commits - - Create documenter handoff + - Create documenter handoff summary """ self.log.info("NOTES phase", task_id=str(ctx.task_id)) - # Generate handoff using LLM - prompt = f""" -Create a documentation handoff for this completed task: + # Generate dev_notes for QA verification (what was built, where, key decisions) + dev_notes_prompt = f""" +Summarize the work done for QA verification: Task: {ctx.title} Commits: {", ".join(ctx.commits)} -Journal: +Work log: {chr(10).join(ctx.journal_entries)} -Create a handoff summary including: -1. What was built -2. Key changes -3. Documentation needed -4. Code samples to include +Create a brief summary for QA including: +1. What was built and where (files/modules) +2. Key implementation decisions +3. Tests added +4. Any gotchas or important context """ - _handoff = await self.think(prompt) # Handoff content is for documenter + dev_notes = await self.think(dev_notes_prompt) + + # Generate handoff summary for documenter + handoff_prompt = f""" +Create a handoff summary for the documenter: + +Task: {ctx.title} +What was built: {dev_notes[:500]} + +Summarize in 2-3 sentences what documentation is needed. +""" + handoff_summary = await self.think(handoff_prompt) + + # Store notes in task via API (this is what QA will see!) + await self._submit_for_qa(ctx.task_id, dev_notes, handoff_summary) ctx.journal_entries.append( - f"[{datetime.now(UTC).isoformat()}] Handoff created for documenter" + f"[{datetime.now(UTC).isoformat()}] Submitted for QA with dev_notes" ) - # Update task status - await self._update_task_status(ctx.task_id, TaskStatus.AWAITING_QA) - - async def _phase_close(self, ctx: TaskContext) -> bool: - """ - CLOSE phase: After QA + documentation approval. - - - Verify QA approved - - Verify documentation complete - - Mark task completed - - Returns True if closed, False if waiting. - """ - self.log.info("CLOSE phase", task_id=str(ctx.task_id)) - - # Check if QA approved (simulated - would check actual status) - qa_approved = await self._check_qa_approved(ctx.task_id) - doc_complete = await self._check_docs_complete(ctx.task_id) - - if qa_approved and doc_complete: - await self._update_task_status(ctx.task_id, TaskStatus.COMPLETED) - await self.send_message( - self._cell_channel_id or ctx.task_id, - f"TASK-{str(ctx.task_id)[:8]} completed!", - message_type="action", - ) - return True - - return False - async def _handle_blocked(self, ctx: TaskContext) -> bool: """ Handle blocked state. @@ -603,32 +591,52 @@ Create a handoff summary including: except Exception as e: self.log.error("Failed to update task status", error=str(e)) - async def _check_qa_approved(self, task_id: UUID) -> bool: - """Check if QA has approved the task.""" - try: - result = await self._api_call("GET", f"/tasks/{task_id}") - status = result.get("status", "") - # QA approved if status moved past awaiting_qa - return status in ["awaiting_documentation", "completed"] - except Exception as e: - self.log.warning("Failed to check QA status", error=str(e)) - return False + async def _add_progress(self, task_id: UUID, message: str, percentage: int) -> None: + """ + Add progress update to task. - async def _check_docs_complete(self, task_id: UUID) -> bool: - """Check if documentation is complete.""" + This is saved to task.progress_updates and visible to QA. + Percentage is required (0-100) to show real progress. + """ try: - result = await self._api_call("GET", f"/tasks/{task_id}/handoffs") - handoffs = result.get("items", []) - # Check if documenter handoff is complete - for handoff in handoffs: - is_doc = handoff.get("type") == "documentation" - is_done = handoff.get("status") == "completed" - if is_doc and is_done: - return True - return False + await self._api_call( + "POST", + f"/tasks/{task_id}/progress", + json={ + "message": message, + "percentage": percentage, + }, + ) + self.log.info("Progress saved", task_id=str(task_id), percentage=percentage) except Exception as e: - self.log.warning("Failed to check docs status", error=str(e)) - return False + self.log.warning("Failed to save progress", error=str(e)) + + async def _submit_for_qa( + self, task_id: UUID, dev_notes: str, handoff_summary: str + ) -> None: + """ + Submit task for QA review with notes. + + This stores dev_notes in the task (visible to QA) and transitions + the task to awaiting_qa status. + """ + try: + # First store dev_notes (this is what QA will see!) + combined_notes = f"{dev_notes}\n\n---\nHandoff Summary:\n{handoff_summary}" + await self._api_call( + "PATCH", + f"/tasks/{task_id}", + json={"dev_notes": combined_notes}, + ) + self.log.info("Dev notes saved to task", task_id=str(task_id)) + + # Then transition to awaiting_qa + await self._api_call("POST", f"/tasks/{task_id}/submit-qa") + self.log.info("Task submitted for QA", task_id=str(task_id)) + + except Exception as e: + self.log.error("Failed to submit for QA", error=str(e)) + raise # Re-raise so caller knows submission failed def create_backend_developer( diff --git a/roboco/agents/documenter.py b/roboco/agents/documenter.py index 25fe32f4..d13ced9e 100644 --- a/roboco/agents/documenter.py +++ b/roboco/agents/documenter.py @@ -404,12 +404,12 @@ good,complete,clear,helpful,None "Failed to publish", path=doc_spec.path, error=str(e) ) - # Update task status - await self._update_task_status(ctx.task_id, TaskStatus.COMPLETED) + # Mark docs complete - task goes to PM for final review + await self._update_task_status(ctx.task_id, TaskStatus.AWAITING_PM_REVIEW) await self.send_message( self._cell_channel_id or ctx.task_id, - f"TASK-{str(ctx.task_id)[:8]} documentation complete\n" + f"TASK-{str(ctx.task_id)[:8]} documentation complete, awaiting PM review\n" f"Published: {', '.join(ctx.written_docs)}", message_type="action", ) @@ -446,11 +446,25 @@ good,complete,clear,helpful,None return f"Task {str(task_id)[:8]}" async def _read_dev_notes(self, task_id: UUID) -> str: - """Read developer's journey notes.""" + """Read developer's journey notes (dev_notes + progress_updates).""" try: result = await self._api_call("GET", f"/tasks/{task_id}") - notes: str = result.get("dev_notes", "No developer notes available") - return notes + notes: str = result.get("dev_notes") or "" + + # Also include progress updates as they contain developer's work log + progress_updates = result.get("progress_updates", []) + if progress_updates: + progress_text = "\n".join( + f"[{u.get('timestamp', 'N/A')}] ({u.get('percentage', 0)}%) " + f"{u.get('message', '')}" + for u in progress_updates + ) + if notes: + notes = f"{notes}\n\nProgress Updates:\n{progress_text}" + else: + notes = f"Progress Updates:\n{progress_text}" + + return notes if notes else "No developer notes available" except Exception as e: self.log.warning("Failed to read dev notes", error=str(e)) return "Dev notes unavailable" diff --git a/roboco/agents/qa.py b/roboco/agents/qa.py index 46812b6f..3c607be8 100644 --- a/roboco/agents/qa.py +++ b/roboco/agents/qa.py @@ -453,11 +453,25 @@ PASS,All criteria verified successfully,No issues found return "Requirements unavailable" async def _read_dev_notes(self, task_id: UUID) -> str: - """Read developer's journey notes.""" + """Read developer's journey notes (dev_notes + progress_updates).""" try: result = await self._api_call("GET", f"/tasks/{task_id}") - notes: str = result.get("dev_notes", "No developer notes available") - return notes + notes: str = result.get("dev_notes") or "" + + # Also include progress updates as they contain developer's work log + progress_updates = result.get("progress_updates", []) + if progress_updates: + progress_text = "\n".join( + f"[{u.get('timestamp', 'N/A')}] ({u.get('percentage', 0)}%) " + f"{u.get('message', '')}" + for u in progress_updates + ) + if notes: + notes = f"{notes}\n\nProgress Updates:\n{progress_text}" + else: + notes = f"Progress Updates:\n{progress_text}" + + return notes if notes else "No developer notes available" except Exception as e: self.log.warning("Failed to read dev notes", error=str(e)) return "Dev notes unavailable" diff --git a/roboco/agents_config.py b/roboco/agents_config.py index 90b7cbc3..66d96ff1 100644 --- a/roboco/agents_config.py +++ b/roboco/agents_config.py @@ -188,6 +188,21 @@ def can_assign_tasks(agent_id: str) -> bool: return role in PM_ROLES +# Cancel roles match task_lifecycle.py - CEO and Auditor cannot cancel (they observe) +_CANCEL_ROLES: Final[set[str]] = { + "cell_pm", + "main_pm", + "product_owner", + "head_marketing", +} + + +def can_cancel_tasks(agent_id: str) -> bool: + """Check if agent can cancel tasks (PMs and board, not CEO/Auditor).""" + role = get_agent_role(agent_id) + return role in _CANCEL_ROLES + + def get_escalation_target(agent_id: str) -> str | None: """Get the escalation target for an agent.""" return ESCALATION_CHAIN.get(agent_id) diff --git a/roboco/api/routes/tasks.py b/roboco/api/routes/tasks.py index e71a4bbf..a503a0a0 100644 --- a/roboco/api/routes/tasks.py +++ b/roboco/api/routes/tasks.py @@ -22,6 +22,7 @@ from roboco.api.schemas.tasks import ( ListTasksQuery, ProgressRequest, QANotes, + SoftBlockRequest, TaskCountResponse, TaskResponse, TaskUpdate, @@ -528,6 +529,51 @@ async def block_task( return task_to_response(task) +@router.post("/{task_id}/soft-block", response_model=TaskResponse) +async def soft_block_task( + task_id: UUID, + data: SoftBlockRequest, + db: DbSession, + agent: CurrentAgentContext, +) -> TaskResponse: + """Soft-block a task due to an external factor (not a task dependency). + + Use this when blocked by: + - External dependencies (waiting for API access, credentials) + - Questions that need PM/stakeholder input + - Technical blockers (infrastructure issues) + + For blocking due to another task, use the /block endpoint instead. + """ + service = get_task_service(db) + task = await service.get(task_id) + if not task: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Task not found" + ) + + # Only assigned agent or PM can block a task + if task.assigned_to != agent.agent_id and agent.role.value not in ( + "cell_pm", + "main_pm", + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not authorized to block this task", + ) + + task = await service.soft_block( + task_id, data.reason, data.blocker_type, data.what_needed + ) + if not task: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot block task - must be in_progress", + ) + await db.commit() + return task_to_response(task) + + @router.post("/{task_id}/unblock", response_model=TaskResponse) async def unblock_task( task_id: UUID, @@ -787,14 +833,18 @@ async def fail_qa( return task_to_response(task) -@router.post("/{task_id}/complete", response_model=TaskResponse) -async def complete_task( +@router.post("/{task_id}/docs-complete", response_model=TaskResponse) +async def docs_complete( task_id: UUID, db: DbSession, agent: CurrentAgentContext, - permissions: PermissionServiceDep, + data: QANotes | None = None, ) -> TaskResponse: - """Mark task as completed.""" + """Mark documentation as complete (documenter only). + + Transitions task from awaiting_documentation to awaiting_pm_review. + The Cell PM will then review and complete the task. + """ service = get_task_service(db) task = await service.get(task_id) if not task: @@ -802,14 +852,65 @@ async def complete_task( status_code=status.HTTP_404_NOT_FOUND, detail="Task not found" ) - # Check close permission - assigned agent or those with CLOSE permission - is_assigned = task.assigned_to == agent.agent_id - can_close = permissions.can_perform_task_action(agent, TaskAction.CLOSE, task.team) - - if not (is_assigned or can_close): + # Only documenter role can mark docs complete + if agent.role.value != "documenter": raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Not authorized to complete this task", + detail="Only documenters can mark documentation as complete", + ) + + # Documenter cannot document their own work (self-review prevention) + original_dev = extract_original_developer(task.quick_context) + if original_dev and str(agent.agent_id) == original_dev: + audit = get_audit_service() + await audit.log_task_action_denial( + agent_id=agent.agent_id, + agent_role=agent.role.value, + task_id=task_id, + action="docs_complete", + reason="Self-documentation not permitted", + ) + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Cannot document your own task", + ) + + doc_notes = data.notes if data else None + task = await service.docs_complete(task_id, doc_notes) + if not task: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Cannot mark docs complete - task not awaiting documentation", + ) + await db.commit() + return task_to_response(task) + + +@router.post("/{task_id}/complete", response_model=TaskResponse) +async def complete_task( + task_id: UUID, + db: DbSession, + agent: CurrentAgentContext, + permissions: PermissionServiceDep, +) -> TaskResponse: + """Mark task as completed (PM only). + + Only PMs can complete tasks, and only from awaiting_pm_review status. + This ensures the full workflow: Dev → QA → Documenter → PM. + """ + service = get_task_service(db) + task = await service.get(task_id) + if not task: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Task not found" + ) + + # Only PMs can complete tasks + can_close = permissions.can_perform_task_action(agent, TaskAction.CLOSE, task.team) + if not can_close: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only PMs can complete tasks", ) task = await service.complete(task_id) diff --git a/roboco/api/schemas/tasks.py b/roboco/api/schemas/tasks.py index fe490a63..072a0e7e 100644 --- a/roboco/api/schemas/tasks.py +++ b/roboco/api/schemas/tasks.py @@ -289,6 +289,16 @@ class QANotes(BaseModel): notes: str +class SoftBlockRequest(BaseModel): + """Request to soft-block a task due to an external factor.""" + + reason: str = Field(..., description="Why the task is blocked") + blocker_type: str = Field( + ..., description="Type of blocker: external, internal, question, dependency" + ) + what_needed: str = Field(..., description="What is needed to unblock the task") + + class TaskCountResponse(BaseModel): """Task count by category.""" diff --git a/roboco/enforcement/task_lifecycle.py b/roboco/enforcement/task_lifecycle.py index fa6f5377..6dbe16c2 100644 --- a/roboco/enforcement/task_lifecycle.py +++ b/roboco/enforcement/task_lifecycle.py @@ -36,8 +36,10 @@ VALID_TRANSITIONS: dict[str, list[str]] = { "needs_revision": ["in_progress", "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"], + # Awaiting documentation - documenter marks docs done, goes to PM review + "awaiting_documentation": ["awaiting_pm_review", "cancelled"], + # Awaiting PM review - PM reviews and completes, or cancels + "awaiting_pm_review": ["completed", "cancelled"], # Terminal states - cannot transition out "completed": [], "cancelled": [], @@ -57,6 +59,10 @@ ROLE_RESTRICTED_TRANSITIONS: dict[tuple[str, str], list[str]] = { # Only QA can pass or fail QA ("awaiting_qa", "awaiting_documentation"): ["qa"], ("awaiting_qa", "needs_revision"): ["qa"], + # Only documenter can mark docs complete + ("awaiting_documentation", "awaiting_pm_review"): ["documenter"], + # Only PM can complete after PM review + ("awaiting_pm_review", "completed"): _CANCEL_ROLES, # PMs complete tasks # Only PM or higher can cancel tasks (all states that allow cancel) ("pending", "cancelled"): _CANCEL_ROLES, ("claimed", "cancelled"): _CANCEL_ROLES, @@ -67,6 +73,7 @@ ROLE_RESTRICTED_TRANSITIONS: dict[tuple[str, str], list[str]] = { ("needs_revision", "cancelled"): _CANCEL_ROLES, ("awaiting_qa", "cancelled"): _CANCEL_ROLES, ("awaiting_documentation", "cancelled"): _CANCEL_ROLES, + ("awaiting_pm_review", "cancelled"): _CANCEL_ROLES, } @@ -155,7 +162,13 @@ def is_terminal_state(status: str) -> bool: def is_waiting_state(status: str) -> bool: """Check if a status is a waiting state (agent can work on other tasks).""" - return status in ("blocked", "paused", "awaiting_qa", "awaiting_documentation") + return status in ( + "blocked", + "paused", + "awaiting_qa", + "awaiting_documentation", + "awaiting_pm_review", + ) def is_active_state(status: str) -> bool: diff --git a/roboco/mcp/notify_server.py b/roboco/mcp/notify_server.py index f3107082..ad04eb30 100644 --- a/roboco/mcp/notify_server.py +++ b/roboco/mcp/notify_server.py @@ -4,11 +4,18 @@ Notify MCP Server Exposes notification tools to Claude Code agents with built-in enforcement of notification permissions. -Tools: +Tools available to ALL agents: - roboco_notify_list: List your notifications - roboco_notify_get: Get a specific notification - roboco_notify_ack: Acknowledge a notification -- roboco_notify_send: Send a notification (PM/Board/Auditor only) + +Tools available ONLY to PM/Board/Auditor: +- roboco_notify_send: Send a notification +- roboco_escalate: Escalate an issue (PMs only) +- roboco_request_approval: Request approval (PMs/Board only) + +Note: Developers, QA, and Documenters do not see the sending tools. +They should use message channels and blocker reporting instead. """ from typing import Any @@ -18,6 +25,7 @@ from mcp.server.fastmcp import FastMCP from roboco.agents_config import ( NOTIFICATION_PERMISSIONS, + can_send_notifications, get_agent_cell, get_agent_role, ) @@ -301,71 +309,71 @@ def create_notify_mcp_server(agent_id: str) -> FastMCP: """Acknowledge a notification.""" return await _handle_ack(client, notification_id) - @mcp.tool() - async def roboco_notify_send(data: SendNotificationInput) -> dict[str, Any]: - """ - Send a notification to one or more agents. + # Only register send/escalate/approval tools for agents who can send notifications + # This prevents developers, QA, and documenters from even seeing these tools + if can_send_notifications(agent_id): - Only PMs, Board members, and Auditor can send notifications. - Cell PMs can only notify their own cell. - """ - return await _handle_send(client, agent_id, data) + @mcp.tool() + async def roboco_notify_send(data: SendNotificationInput) -> dict[str, Any]: + """ + Send a notification to one or more agents. - @mcp.tool() - async def roboco_escalate( - escalate_to: str, - subject: str, - description: str, - task_id: str | None = None, - ) -> dict[str, Any]: - """ - Escalate an issue to a higher level (PM only). + Cell PMs can only notify their own cell. + Main PM, Board, and Auditor can notify anyone. + """ + return await _handle_send(client, agent_id, data) - Sends a high-priority notification requiring acknowledgment. - """ role = get_agent_role(agent_id) - if role not in ["cell_pm", "main_pm"]: - return format_error_response( - "NOT_PM", "Only PMs can use the escalate function" - ) - input_data = SendNotificationInput( - recipients=[escalate_to], - subject=f"[ESCALATION] {subject}", - body=description, - notification_type="escalation", - priority="high", - requires_ack=True, - related_task_id=task_id, - ) - return await _handle_send(client, agent_id, input_data) + # Only PMs can escalate + if role in ["cell_pm", "main_pm"]: - @mcp.tool() - async def roboco_request_approval( - approver: str, - subject: str, - what_needs_approval: str, - task_id: str | None = None, - ) -> dict[str, Any]: - """ - Request approval from someone (PM/Board only). - """ - role = get_agent_role(agent_id) - if role not in ["cell_pm", "main_pm", "product_owner", "head_marketing"]: - return format_error_response( - "NOT_AUTHORIZED", "Only PMs and Board can request approvals" - ) + @mcp.tool() + async def roboco_escalate( + escalate_to: str, + subject: str, + description: str, + task_id: str | None = None, + ) -> dict[str, Any]: + """ + Escalate an issue to a higher level. - input_data = SendNotificationInput( - recipients=[approver], - subject=f"[APPROVAL NEEDED] {subject}", - body=what_needs_approval, - notification_type="approval", - priority="normal", - requires_ack=True, - related_task_id=task_id, - ) - return await _handle_send(client, agent_id, input_data) + Sends a high-priority notification requiring acknowledgment. + """ + input_data = SendNotificationInput( + recipients=[escalate_to], + subject=f"[ESCALATION] {subject}", + body=description, + notification_type="escalation", + priority="high", + requires_ack=True, + related_task_id=task_id, + ) + return await _handle_send(client, agent_id, input_data) + + # Only PMs and Board can request approvals + if role in ["cell_pm", "main_pm", "product_owner", "head_marketing"]: + + @mcp.tool() + async def roboco_request_approval( + approver: str, + subject: str, + what_needs_approval: str, + task_id: str | None = None, + ) -> dict[str, Any]: + """ + Request approval from someone. + """ + input_data = SendNotificationInput( + recipients=[approver], + subject=f"[APPROVAL NEEDED] {subject}", + body=what_needs_approval, + notification_type="approval", + priority="normal", + requires_ack=True, + related_task_id=task_id, + ) + return await _handle_send(client, agent_id, input_data) return mcp diff --git a/roboco/mcp/task_server.py b/roboco/mcp/task_server.py index c5abfe00..8982f6da 100644 --- a/roboco/mcp/task_server.py +++ b/roboco/mcp/task_server.py @@ -17,9 +17,11 @@ Tools: - roboco_task_submit_qa: Submit for QA review - roboco_task_qa_pass: Pass QA (QA role only) - roboco_task_qa_fail: Fail QA (QA role only) -- roboco_task_complete: Mark task complete +- roboco_task_docs_complete: Mark docs complete (Documenter only) +- roboco_task_complete: Mark task complete (PM only, after docs) - roboco_task_create: Create new task (PM only) - roboco_task_assign: Assign task to agent (PM only) +- roboco_task_cancel: Cancel a task (PM/Board only) - roboco_task_escalate: Escalate task up hierarchy (all agents) """ @@ -30,6 +32,7 @@ from mcp.server.fastmcp import FastMCP from roboco.agents_config import ( can_assign_tasks, + can_cancel_tasks, can_create_tasks, get_agent_role, get_agent_team, @@ -70,6 +73,10 @@ async def _resolve_agent_uuid_cached(agent_id: str, client: ApiClient) -> str | # Global TOON adapter for encoding task data _toon = ToonAdapter() +# Progress percentage bounds +_MIN_PERCENTAGE = 0 +_MAX_PERCENTAGE = 100 + # NOTE: For task lifecycle validation, use enforcement.task_lifecycle.VALID_TRANSITIONS @@ -166,6 +173,57 @@ def _get_next_step_guidance(status: str) -> tuple[str, str]: # ============================================================================= +def _get_available_tasks_guidance( + available_tasks: list[dict[str, Any]], agent_role: str +) -> str: + """Generate guidance for available tasks based on agent role.""" + review_count = sum( + 1 for t in available_tasks if t.get("status") == "awaiting_pm_review" + ) + pending_count = len(available_tasks) - review_count + + if agent_role in ("cell_pm", "main_pm") and review_count > 0: + return ( + f"Found {review_count} task(s) awaiting your review. " + "Use roboco_task_get to review, then roboco_task_complete to finalize. " + f"Also {pending_count} pending task(s) need triage." + ) + return ( + f"Found {len(available_tasks)} available task(s). " + "Review and claim one that matches your skills." + ) + + +async def _get_available_tasks_for_role( + client: ApiClient, agent_role: str, team: str | None +) -> list[dict[str, Any]]: + """Get available tasks based on agent role.""" + params = {"team": team} if team else {} + + if agent_role == "qa": + resp = await client.get("/tasks/awaiting-qa", params=params) + return resp.json() if resp.ok else [] + + if agent_role == "documenter": + resp = await client.get("/tasks/awaiting-docs", params=params) + return resp.json() if resp.ok else [] + + if agent_role in ("cell_pm", "main_pm"): + # PMs get pending tasks AND tasks awaiting their review + pending_params = {**params, "status": "pending"} + pending_resp = await client.get("/tasks", params=pending_params) + pending = pending_resp.json() if pending_resp.ok else [] + review_resp = await client.get( + "/tasks", params={**params, "status": "awaiting_pm_review"} + ) + review = review_resp.json() if review_resp.ok else [] + return pending + review + + # Developers get pending tasks only + resp = await client.get("/tasks", params={**params, "status": "pending"}) + return resp.json() if resp.ok else [] + + async def _handle_task_scan( client: ApiClient, team: str | None, agent_id: str ) -> dict[str, Any]: @@ -186,42 +244,15 @@ async def _handle_task_scan( ] # 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( - "/tasks/awaiting-qa", - params={"team": team} if team else {}, - ) - if qa_resp.ok: - available_tasks = qa_resp.json() - elif agent_role == "documenter": - # Documenters look for tasks awaiting documentation - doc_resp = await client.get( - "/tasks/awaiting-docs", - params={"team": team} if team else {}, - ) - if doc_resp.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("/tasks", params=params) - if pending_resp.ok: - available_tasks = pending_resp.json() + available_tasks = await _get_available_tasks_for_role(client, agent_role, team) # 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 + # Determine guidance based on role and available tasks if paused_tasks: guidance = ( f"You have {len(paused_tasks)} paused task(s). " @@ -233,10 +264,7 @@ async def _handle_task_scan( "Continue working on your assigned tasks." ) elif available_tasks: - guidance = ( - f"Found {len(available_tasks)} available task(s). " - "Review and claim one that matches your skills." - ) + guidance = _get_available_tasks_guidance(available_tasks, agent_role) else: guidance = ( "No tasks available. Call roboco_agent_idle() " @@ -492,30 +520,40 @@ async def _validate_task_start( return error task_status = task.get("status") - if task_status not in ["claimed", "paused"]: + # Valid statuses to start/resume work: + # - claimed: Developer just claimed a pending task + # - paused: Developer resuming paused work + # - needs_revision: Developer resuming after QA rejection + valid_start_statuses = ["claimed", "paused", "needs_revision"] + if task_status not in valid_start_statuses: return _format_error_response( "INVALID_STATE", f"Cannot start task in '{task_status}' status. " - "Task must be 'claimed' or 'paused'.", + "Task must be 'claimed', 'paused', or 'needs_revision'.", {"current_status": task_status}, ) + # Only require plan for newly claimed tasks, not for resuming revision if task_status == "claimed" and not task.get("plan"): return _format_error_response( "NO_PLAN", "Cannot start without a plan. Call roboco_task_plan first.", ) - plan = task.get("plan", {}) - unanswered = [q for q in plan.get("open_questions", []) if not q.get("answered")] - if unanswered: - return _format_error_response( - "UNANSWERED_QUESTIONS", - f"Cannot start with {len(unanswered)} " - "unanswered question(s). " - "Get answers first, then update the plan.", - {"questions": [q.get("question") for q in unanswered]}, - ) + # Only check open questions for newly claimed tasks + if task_status == "claimed": + plan = task.get("plan", {}) + unanswered = [ + q for q in plan.get("open_questions", []) if not q.get("answered") + ] + if unanswered: + return _format_error_response( + "UNANSWERED_QUESTIONS", + f"Cannot start with {len(unanswered)} " + "unanswered question(s). " + "Get answers first, then update the plan.", + {"questions": [q.get("question") for q in unanswered]}, + ) return None @@ -559,10 +597,17 @@ async def _handle_task_progress( client: ApiClient, task_id: str, message: str, - percentage: int | None, + percentage: int, agent_id: str, ) -> dict[str, Any]: """Handle task progress update.""" + # Validate percentage is in valid range + if not _MIN_PERCENTAGE <= percentage <= _MAX_PERCENTAGE: + return _format_error_response( + "INVALID_PERCENTAGE", + f"Percentage must be between {_MIN_PERCENTAGE} and {_MAX_PERCENTAGE}", + ) + task_resp = await client.get(f"/tasks/{task_id}") if task_resp.is_status(status.HTTP_404_NOT_FOUND): return _format_error_response("NOT_FOUND", f"Task {task_id} not found") @@ -572,10 +617,17 @@ async def _handle_task_progress( if error := await _validate_task_ownership(task, agent_id, client): return error - if task.get("status") != "in_progress": + # Allow progress updates for active work statuses + active_statuses = { + "in_progress", + "verifying", + "awaiting_qa", + "awaiting_documentation", + } + if task.get("status") not in active_statuses: return _format_error_response( "INVALID_STATE", - "Can only update progress for in_progress tasks", + f"Can only update progress for active tasks. Current: {task.get('status')}", ) # Add progress update @@ -608,7 +660,7 @@ async def _handle_task_block( data: TaskBlockInput, agent_id: str, ) -> dict[str, Any]: - """Handle task blocking.""" + """Handle task blocking via the soft-block endpoint.""" task_resp = await client.get(f"/tasks/{data.task_id}") if task_resp.is_status(status.HTTP_404_NOT_FOUND): return _format_error_response("NOT_FOUND", f"Task {data.task_id} not found") @@ -624,24 +676,13 @@ async def _handle_task_block( "Can only block in_progress tasks", ) - # Build blocker note for dev_notes - blocker_note = ( - f"[BLOCKED - {data.blocker_type.upper()}]\n" - f"Reason: {data.reason}\n" - f"What's needed: {data.what_needed}" - ) - existing_notes = task.get("dev_notes") or "" - if existing_notes: - updated_notes = f"{existing_notes}\n\n{blocker_note}" - else: - updated_notes = blocker_note - - # Block the task using PATCH to update status and notes - block_resp = await client.patch( - f"/tasks/{data.task_id}", + # Use the soft-block endpoint which handles status change and notes + block_resp = await client.post( + f"/tasks/{data.task_id}/soft-block", json={ - "status": "blocked", - "dev_notes": updated_notes, + "reason": data.reason, + "blocker_type": data.blocker_type, + "what_needed": data.what_needed, }, ) @@ -813,46 +854,50 @@ async def _handle_task_submit_qa( agent_id: str, ) -> dict[str, Any]: """Handle task QA submission.""" + # Validate inputs if not dev_notes or not handoff_summary: return _format_error_response( "MISSING_NOTES", "Both dev_notes and handoff_summary are required for QA submission.", ) + # Validate task exists and ownership task_resp = await client.get(f"/tasks/{task_id}") if task_resp.is_status(status.HTTP_404_NOT_FOUND): return _format_error_response("NOT_FOUND", f"Task {task_id} not found") task = task_resp.json() - if error := await _validate_task_ownership(task, agent_id, client): return error + # Validate state if task.get("status") != "verifying": return _format_error_response( - "INVALID_STATE", - "Can only submit verified tasks for QA", + "INVALID_STATE", "Can only submit verified tasks for QA" ) - # Update with notes - combine dev_notes and handoff summary - # (handoff summary goes into dev_notes for documenter to read) + # Save dev notes and handoff summary, then submit for QA combined_notes = f"{dev_notes}\n\n---\nHandoff Summary:\n{handoff_summary}" - await client.patch(f"/tasks/{task_id}", json={"dev_notes": combined_notes}) + notes_resp = await client.patch( + f"/tasks/{task_id}", json={"dev_notes": combined_notes} + ) + if not notes_resp.ok: + return _format_error_response( + "NOTES_SAVE_FAILED", + "Failed to save dev notes. QA submission aborted.", + ) - # Submit for QA qa_resp = await client.post(f"/tasks/{task_id}/submit-qa") - - if not qa_resp.ok: - return _format_error_response("SUBMIT_FAILED", "Failed to submit for QA") - - qa_task = qa_resp.json() - - return _format_task_response( - qa_task, - "WAIT_FOR_QA", - "Task submitted for QA review.\n" - "You will be notified of the result.\n" - "In the meantime, call roboco_task_scan for other work.", + return ( + _format_task_response( + qa_resp.json(), + "WAIT_FOR_QA", + "Task submitted for QA review.\n" + "You will be notified of the result.\n" + "In the meantime, call roboco_task_scan for other work.", + ) + if qa_resp.ok + else _format_error_response("SUBMIT_FAILED", "Failed to submit for QA") ) @@ -863,11 +908,13 @@ async def _handle_task_qa_pass( agent_id: str, ) -> dict[str, Any]: """Handle task QA pass.""" - # Check if agent has QA role (simple check - real impl would verify) - if "qa" not in agent_id.lower(): + # Check if agent has QA role using canonical role lookup + agent_role = get_agent_role(agent_id) + if agent_role != "qa": return _format_error_response( "NOT_QA", "Only QA agents can pass tasks through QA review.", + {"your_role": agent_role}, ) task_resp = await client.get(f"/tasks/{task_id}") @@ -925,10 +972,13 @@ async def _handle_task_qa_fail( agent_id: str, ) -> dict[str, Any]: """Handle task QA failure.""" - if "qa" not in agent_id.lower(): + # Check if agent has QA role using canonical role lookup + agent_role = get_agent_role(agent_id) + if agent_role != "qa": return _format_error_response( "NOT_QA", "Only QA agents can fail tasks in QA review.", + {"your_role": agent_role}, ) if not issues: @@ -974,8 +1024,22 @@ async def _handle_task_qa_fail( ) -async def _handle_task_complete(client: ApiClient, task_id: str) -> dict[str, Any]: - """Handle task completion.""" +async def _handle_docs_complete( + client: ApiClient, + task_id: str, + agent_id: str, + doc_notes: str | None = None, +) -> dict[str, Any]: + """Handle documentation completion (documenter only).""" + # Check if agent is a documenter + agent_role = get_agent_role(agent_id) + if agent_role != "documenter": + return _format_error_response( + "NOT_DOCUMENTER", + "Only documenters can mark documentation as complete.", + {"your_role": agent_role}, + ) + task_resp = await client.get(f"/tasks/{task_id}") if task_resp.is_status(status.HTTP_404_NOT_FOUND): return _format_error_response("NOT_FOUND", f"Task {task_id} not found") @@ -985,7 +1049,56 @@ async def _handle_task_complete(client: ApiClient, task_id: str) -> dict[str, An if task.get("status") != "awaiting_documentation": return _format_error_response( "INVALID_STATE", - "Task must be awaiting documentation to complete", + "Task must be awaiting documentation to mark docs complete", + ) + + payload = {"notes": doc_notes} if doc_notes else {} + docs_resp = await client.post(f"/tasks/{task_id}/docs-complete", json=payload) + + if not docs_resp.ok: + return _format_error_response( + "DOCS_COMPLETE_FAILED", + "Failed to mark documentation complete", + {"status_code": docs_resp.status_code, "api_error": docs_resp.text}, + ) + + updated_task = docs_resp.json() + + return _format_task_response( + updated_task, + "AWAITING_PM", + "Documentation complete! Task is now awaiting PM review.\n" + "The Cell PM will review and complete the task.\n" + "Call roboco_task_scan for next documentation task.", + ) + + +async def _handle_task_complete( + client: ApiClient, + task_id: str, + agent_id: str, +) -> dict[str, Any]: + """Handle task completion (PM only).""" + # Check if agent can complete tasks (PM role) + if not can_cancel_tasks(agent_id): # Same roles that can cancel can complete + role = get_agent_role(agent_id) + return _format_error_response( + "NOT_PM", + "Only PMs can complete tasks after reviewing.", + {"your_role": role}, + ) + + task_resp = await client.get(f"/tasks/{task_id}") + if task_resp.is_status(status.HTTP_404_NOT_FOUND): + return _format_error_response("NOT_FOUND", f"Task {task_id} not found") + + task = task_resp.json() + + if task.get("status") != "awaiting_pm_review": + return _format_error_response( + "INVALID_STATE", + "Task must be awaiting PM review to complete. " + "Documenter should call roboco_task_docs_complete first.", ) complete_resp = await client.post(f"/tasks/{task_id}/complete") @@ -994,10 +1107,7 @@ async def _handle_task_complete(client: ApiClient, task_id: str) -> dict[str, An return _format_error_response( "COMPLETE_FAILED", "Failed to complete task", - { - "status_code": complete_resp.status_code, - "api_error": complete_resp.text, - }, + {"status_code": complete_resp.status_code, "api_error": complete_resp.text}, ) completed_task = complete_resp.json() @@ -1005,7 +1115,60 @@ async def _handle_task_complete(client: ApiClient, task_id: str) -> dict[str, An return _format_task_response( completed_task, "DONE", - "Task completed successfully!\nCall roboco_task_scan for new work.", + "Task completed successfully!\nCall roboco_task_scan for more work.", + ) + + +async def _handle_task_cancel( + client: ApiClient, + task_id: str, + agent_id: str, + reason: str | None = None, +) -> dict[str, Any]: + """Handle task cancellation (PM and board only).""" + # Check permission first + if not can_cancel_tasks(agent_id): + role = get_agent_role(agent_id) + return _format_error_response( + "NOT_AUTHORIZED", + "Only PMs and board members can cancel tasks", + {"your_role": role}, + ) + + # Get task to verify it exists + task_resp = await client.get(f"/tasks/{task_id}") + if not task_resp.ok: + return _format_error_response("NOT_FOUND", f"Task {task_id} not found") + + task = task_resp.json() + current_status = task.get("status") + + # Terminal states can't be cancelled + if current_status in ("completed", "cancelled"): + return _format_error_response( + "INVALID_STATE", + f"Cannot cancel task in '{current_status}' status", + ) + + # Cancel the task + cancel_resp = await client.post(f"/tasks/{task_id}/cancel") + + if not cancel_resp.ok: + return _format_error_response( + "CANCEL_FAILED", + "Failed to cancel task", + { + "status_code": cancel_resp.status_code, + "api_error": cancel_resp.text, + }, + ) + + cancelled_task = cancel_resp.json() + + return _format_task_response( + cancelled_task, + "CANCELLED", + f"Task cancelled.{' Reason: ' + reason if reason else ''}", ) @@ -1498,15 +1661,19 @@ def create_task_mcp_server(agent_id: str) -> FastMCP: async def roboco_task_progress( task_id: str, message: str, - percentage: int | None = None, + percentage: int, ) -> dict[str, Any]: """ Update task progress. + ENFORCEMENT: + - Percentage is REQUIRED (0-100) to show real progress + - Message must describe what was accomplished + Args: task_id: The task UUID - message: Progress update message - percentage: Optional completion percentage (0-100) + message: Progress update message describing work done + percentage: Completion percentage (0-100), required Returns: Updated task @@ -1685,13 +1852,41 @@ def create_task_mcp_server(agent_id: str) -> FastMCP: return await _handle_task_qa_fail(client, task_id, qa_notes, issues, agent_id) @mcp.tool() - async def roboco_task_complete(task_id: str) -> dict[str, Any]: + async def roboco_task_docs_complete( + task_id: str, + doc_notes: str | None = None, + ) -> dict[str, Any]: """ - Mark task as completed (typically by Documenter). + Mark documentation as complete (documenter only). + + Transitions task from awaiting_documentation to awaiting_pm_review. + The Cell PM will then review and complete the task. ENFORCEMENT: + - Only documenters can use this tool - Task must be in 'awaiting_documentation' status - - Documentation must exist + - Cannot document your own task (self-review prevention) + + Args: + task_id: The task UUID + doc_notes: Optional notes about the documentation completed + + Returns: + Task now awaiting PM review + """ + return await _handle_docs_complete(client, task_id, agent_id, doc_notes) + + @mcp.tool() + async def roboco_task_complete(task_id: str) -> dict[str, Any]: + """ + Mark task as completed (PM only). + + Only PMs can complete tasks, after documenter marks docs complete. + This is the final step in the workflow: Dev → QA → Documenter → PM. + + ENFORCEMENT: + - Only PMs can use this tool + - Task must be in 'awaiting_pm_review' status Args: task_id: The task UUID @@ -1699,7 +1894,7 @@ def create_task_mcp_server(agent_id: str) -> FastMCP: Returns: Completed task """ - return await _handle_task_complete(client, task_id) + return await _handle_task_complete(client, task_id, agent_id) @mcp.tool() async def roboco_agent_idle() -> dict[str, Any]: @@ -1770,6 +1965,33 @@ def create_task_mcp_server(agent_id: str) -> FastMCP: input_data = TaskAssignInput(task_id=task_id, assignee=assignee) return await _handle_task_assign(client, input_data, agent_id) + @mcp.tool() + async def roboco_task_cancel( + task_id: str, + reason: str | None = None, + ) -> dict[str, Any]: + """ + Cancel a task (PM and board only). + + Use this to: + - Cancel obsolete or duplicate tasks + - Cancel tasks that are no longer needed + - Cancel blocked tasks that cannot be resolved + + ENFORCEMENT: + - Only PMs and board members can cancel tasks + - CEO and Auditor cannot cancel (they observe only) + - Cannot cancel completed or already-cancelled tasks + + Args: + task_id: The task UUID to cancel + reason: Optional reason for cancellation + + Returns: + Cancelled task confirmation + """ + return await _handle_task_cancel(client, task_id, agent_id, reason) + @mcp.tool() async def roboco_task_escalate( task_id: str, diff --git a/roboco/mcp/utils.py b/roboco/mcp/utils.py index 05313ac9..05f4e1ce 100644 --- a/roboco/mcp/utils.py +++ b/roboco/mcp/utils.py @@ -97,11 +97,11 @@ async def resolve_agent_uuid( if len(agent_id) == _UUID_LENGTH and agent_id.count("-") == _UUID_HYPHEN_COUNT: return agent_id - # Look up by slug + # Look up by slug - GET /agents/{id} accepts both UUID and slug async with httpx.AsyncClient() as client: try: resp = await client.get( - f"{settings.internal_api_url}/agents/by-slug/{agent_id}", + f"{settings.internal_api_url}/agents/{agent_id}", headers=headers, ) if resp.status_code == _HTTP_OK: diff --git a/roboco/models/base.py b/roboco/models/base.py index 01d42d41..671e9d0d 100644 --- a/roboco/models/base.py +++ b/roboco/models/base.py @@ -28,6 +28,7 @@ class TaskStatus(str, Enum): NEEDS_REVISION = "needs_revision" AWAITING_QA = "awaiting_qa" AWAITING_DOCUMENTATION = "awaiting_documentation" + AWAITING_PM_REVIEW = "awaiting_pm_review" # After docs, before PM completes COMPLETED = "completed" CANCELLED = "cancelled" diff --git a/roboco/models/runtime.py b/roboco/models/runtime.py index 0d8cb214..d461d987 100644 --- a/roboco/models/runtime.py +++ b/roboco/models/runtime.py @@ -70,8 +70,8 @@ class WaitingRecord: # Model mapping for cost optimization MODEL_MAP: dict[str, str] = { "sonnet": "claude-sonnet-4-20250514", - "opus": "claude-opus-4-20250514", - "haiku": "claude-haiku-4-20250514", + "opus": "claude-opus-4-5-20251101", + "haiku": "claude-haiku-4-5-20251001", } diff --git a/roboco/runtime/orchestrator.py b/roboco/runtime/orchestrator.py index 41fa2116..e07231ed 100644 --- a/roboco/runtime/orchestrator.py +++ b/roboco/runtime/orchestrator.py @@ -25,6 +25,7 @@ import httpx import structlog from fastapi import status as http_status +from roboco.agents_config import get_agent_role from roboco.config import settings from roboco.models.runtime import ( MODEL_MAP, @@ -200,23 +201,18 @@ class AgentOrchestrator: # 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)", + # Note: // prefix = absolute path (container paths like /app/docs) + "Write(//app/docs/**)", + "Write(//app/CHANGELOG.md)", + "Write(//app/README.md)", + "Edit(//app/docs/**)", + "Edit(//app/CHANGELOG.md)", + "Edit(//app/README.md)", ] # Path to agent Claude settings (shared across all agents) - # When running in container: Claude auth is mounted to /root/.claude - # When running on host: use CLAUDE_AUTH_HOST_PATH directly - if PROJECT_HOST_PATH: - # Running in container - use the mounted path - claude_dir = Path("/root/.claude") - else: - # Running on host - claude_dir = Path(CLAUDE_AUTH_HOST_PATH) + # Always use CLAUDE_AUTH_HOST_PATH - agents mount from this location + claude_dir = Path(CLAUDE_AUTH_HOST_PATH) settings_path = claude_dir / "settings.json" @@ -298,10 +294,10 @@ class AgentOrchestrator: # Generate MCP config mcp_config_path = await self._generate_mcp_config(agent_id) - # Determine model + # Determine model using canonical role name from agents_config if not model: - role = self._get_agent_role(agent_id) - model = ROLE_MODEL_MAP.get(role, "sonnet") + canonical_role = get_agent_role(agent_id) + model = ROLE_MODEL_MAP.get(canonical_role, "sonnet") # Create config config = AgentConfig( @@ -540,7 +536,7 @@ class AgentOrchestrator: def _get_blueprint_path(self, agent_id: str) -> Path: """Get blueprint path for an agent.""" - role = self._get_agent_role(agent_id) + role = self._get_blueprint_role(agent_id) team = self._get_agent_team(agent_id) if team == "backend": @@ -557,7 +553,7 @@ class AgentOrchestrator: def _get_blueprint_rel_path(self, agent_id: str) -> str: """Get relative blueprint path for container mount.""" - role = self._get_agent_role(agent_id) + role = self._get_blueprint_role(agent_id) team = self._get_agent_team(agent_id) if team == "backend": @@ -572,8 +568,8 @@ class AgentOrchestrator: blueprint_file = f"{role.replace('_', '-')}.md" return f"{cell_dir}/{blueprint_file}" - def _get_agent_role(self, agent_id: str) -> str: - """Get role from agent_id.""" + def _get_blueprint_role(self, agent_id: str) -> str: + """Get blueprint-specific role name from agent_id (used for file paths).""" role_map = { "be-dev-1": "be-dev", "be-dev-2": "be-dev", @@ -1298,6 +1294,7 @@ Start now: roboco_task_get("{task_id}") await self._dispatch_dev_work(client) await self._dispatch_qa_work(client) await self._dispatch_doc_work(client) + await self._dispatch_pm_review_work(client) await self._dispatch_marketing_work(client) # Event-based dispatchers (check blockers, notifications) @@ -1599,6 +1596,32 @@ Begin with step 1: roboco_task_get("{task_id}") ) break + async def _dispatch_pm_review_work(self, client: httpx.AsyncClient) -> None: + """ + Dispatch PM review work to cell PMs. + + Monitors: awaiting_pm_review tasks + Spawns: be-pm, fe-pm, ux-pm + """ + tasks = await self._fetch_tasks(client, "awaiting_pm_review") + + for task in tasks: + team = task.get("team") + if team not in ["backend", "frontend", "ux_ui"]: + continue + + pm_id = self._TEAM_PM_MAP.get(team, "be-pm") + + if self._is_agent_active(pm_id): + continue + + await self.spawn_agent( + agent_id=pm_id, + task_id=task["id"], + initial_prompt=self._build_pm_review_prompt(task), + ) + break + async def _dispatch_marketing_work(self, client: httpx.AsyncClient) -> None: """ Dispatch marketing work to head-marketing. @@ -1808,9 +1831,31 @@ Begin documentation: 1. Call roboco_task_get("{task_id}") for full details and dev handoff notes 2. Create or update documentation based on what was implemented 3. Ensure code comments, README updates, API docs as needed -4. Call roboco_task_complete("{task_id}") when documentation is done +4. Call roboco_task_docs_complete("{task_id}") when documentation is done 5. Call roboco_task_scan() to check for more documentation work 6. If no more work, call roboco_agent_idle() to shutdown gracefully +""" + + def _build_pm_review_prompt(self, task: dict[str, Any]) -> str: + """Build initial prompt for PM to review and complete a task.""" + task_id = task.get("id", "unknown") + title = task.get("title", "Untitled") + team = task.get("team", "unknown") + + return f"""A task is awaiting your PM review for final completion. + +TASK ID: {task_id} +TITLE: {title} +TEAM: {team} + +This task has passed QA and documentation. Review and complete: + +1. Call roboco_task_get("{task_id}") to review the task details +2. Verify dev_notes, QA notes, and documentation are satisfactory +3. If this task has subtasks, verify all subtasks are completed +4. Call roboco_task_complete("{task_id}") to finalize the task +5. Call roboco_task_scan() to check for more tasks needing review +6. If no more work, call roboco_agent_idle() to shutdown gracefully """ def _build_marketing_prompt(self, task: dict[str, Any]) -> str: diff --git a/roboco/services/journal.py b/roboco/services/journal.py index 633336b7..6cca7461 100644 --- a/roboco/services/journal.py +++ b/roboco/services/journal.py @@ -34,6 +34,7 @@ from roboco.models.journal import ( create_struggle_entry, create_task_reflection, ) +from roboco.models.optimal import IndexJournalEntryParams from roboco.utils.converters import require_uuid, to_python_uuid logger = structlog.get_logger() @@ -242,14 +243,16 @@ class JournalService: try: optimal = await self._get_optimal_service() await optimal.index_journal_entry( - entry_id=entry_row.id, - agent_id=journal_row.agent_id - if journal_row - else entry_create.journal_id, - content=entry_create.content, - entry_type=type_key, - task_id=entry_create.task_id, - tags=entry_create.tags, + IndexJournalEntryParams( + entry_id=entry_row.id, + agent_id=journal_row.agent_id + if journal_row + else entry_create.journal_id, + content=entry_create.content, + entry_type=type_key, + task_id=entry_create.task_id, + tags=entry_create.tags, + ) ) except Exception as e: logger.warning("Failed to index journal entry in RAG", error=str(e)) diff --git a/roboco/services/task.py b/roboco/services/task.py index 1db1a2e9..0bb2a2eb 100644 --- a/roboco/services/task.py +++ b/roboco/services/task.py @@ -15,10 +15,8 @@ from sqlalchemy.ext.asyncio import AsyncSession from roboco.db.tables import AgentTable, TaskTable from roboco.enforcement import ( - TaskLifecycleError, TaskOwnershipError, validate_task_ownership, - validate_task_transition, ) from roboco.models.base import TaskStatus, Team from roboco.models.task import TaskCreateRequest @@ -180,19 +178,24 @@ class TaskService: ) agent = agent_result.scalar_one_or_none() - # Base valid statuses for claiming - valid_statuses = {TaskStatus.PENDING} - if allow_reassign: - valid_statuses.add(TaskStatus.CLAIMED) + # Role-based claiming: each role can only claim specific statuses + # QA → awaiting_qa only, Documenter → awaiting_documentation only + # Developers/PMs → pending (and claimed if allow_reassign) + valid_statuses: set[TaskStatus] = set() - # 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": + # QA can ONLY claim awaiting_qa tasks valid_statuses.add(TaskStatus.AWAITING_QA) elif role == "documenter": + # Documenter can ONLY claim awaiting_documentation tasks valid_statuses.add(TaskStatus.AWAITING_DOCUMENTATION) + else: + # Developer, PM, and other roles claim pending tasks + valid_statuses.add(TaskStatus.PENDING) + if allow_reassign: + valid_statuses.add(TaskStatus.CLAIMED) elif task.status in {TaskStatus.AWAITING_QA, TaskStatus.AWAITING_DOCUMENTATION}: # No role information - reject claims for role-specific statuses logger.warning( @@ -204,6 +207,11 @@ class TaskService: agent_role="none", ) return None + else: + # No role info but task is pending - allow claim (fallback) + valid_statuses.add(TaskStatus.PENDING) + if allow_reassign: + valid_statuses.add(TaskStatus.CLAIMED) if task.status not in valid_statuses: logger.warning( @@ -225,15 +233,18 @@ class TaskService: ) return None - # For QA/Documenter claiming, store previous owner for self-review checks - # before changing assigned_to + # For QA/Documenter claiming, ensure original_developer is set for + # self-review checks. Primary storage is in submit_for_qa, but we set + # here as fallback (e.g., if task was created directly in awaiting_qa) if agent and agent.role: role = agent.role.value if hasattr(agent.role, "value") else str(agent.role) if role in ("qa", "documenter"): - # Store original developer in quick_context for self-review check - original_dev = str(task.assigned_to) if task.assigned_to else None - if original_dev: - task.quick_context = f"original_developer:{original_dev}" + # Only set if not already stored by submit_for_qa + existing_context = task.quick_context or "" + if "original_developer:" not in existing_context: + original_dev = str(task.assigned_to) if task.assigned_to else None + if original_dev: + task.quick_context = f"original_developer:{original_dev}" # All roles: update assigned_to and claimed_at task.assigned_to = cast("Any", agent_id) @@ -290,7 +301,16 @@ class TaskService: ) return None - if task.status not in (TaskStatus.CLAIMED, TaskStatus.PAUSED): + # Valid statuses to start/resume work: + # - CLAIMED: Developer just claimed a pending task + # - PAUSED: Developer resuming paused work + # - NEEDS_REVISION: Developer resuming after QA rejection + valid_start_statuses = ( + TaskStatus.CLAIMED, + TaskStatus.PAUSED, + TaskStatus.NEEDS_REVISION, + ) + if task.status not in valid_start_statuses: logger.warning( "Cannot start task - invalid status", task_id=str(task_id), @@ -298,7 +318,9 @@ class TaskService: ) return None - task.started_at = datetime.now(UTC) + # Only update started_at if this is the first time starting + if task.started_at is None: + task.started_at = datetime.now(UTC) task.status = TaskStatus.IN_PROGRESS await self.session.flush() @@ -330,6 +352,61 @@ class TaskService: ) return task + async def soft_block( + self, + task_id: UUID, + reason: str, + blocker_type: str, + what_needed: str, + ) -> TaskTable | None: + """ + Block a task due to an external factor (not a task dependency). + + Unlike `block()` which requires another task as the blocker, + this method handles soft blocks like: + - External dependencies (waiting for API access, credentials) + - Questions that need PM/stakeholder input + - Technical blockers (infrastructure issues) + + Args: + task_id: The task to block + reason: Why the task is blocked + blocker_type: Type of blocker (external/internal/question/dependency) + what_needed: What is needed to unblock + + Returns: + The blocked task, or None if blocking not allowed + """ + task = await self.get(task_id) + if not task: + return None + + if task.status != TaskStatus.IN_PROGRESS: + return None + + # Build blocker note for dev_notes + blocker_note = ( + f"[BLOCKED - {blocker_type.upper()}]\n" + f"Reason: {reason}\n" + f"What's needed: {what_needed}" + ) + existing_notes = task.dev_notes or "" + if existing_notes: + task.dev_notes = f"{existing_notes}\n\n{blocker_note}" + else: + task.dev_notes = blocker_note + + task.status = TaskStatus.BLOCKED + await self.session.flush() + + logger.info( + "Task soft-blocked", + task_id=str(task_id), + blocker_type=blocker_type, + reason=reason, + ) + return task + async def unblock(self, task_id: UUID) -> TaskTable | None: """Unblock a task and resume to in_progress.""" task = await self.get(task_id) @@ -399,11 +476,22 @@ class TaskService: if task.status != TaskStatus.VERIFYING: return None + # Store original developer BEFORE QA claims - this is the authoritative record + # for self-review prevention. Storing here ensures we capture the developer + # even if the task is reassigned before QA claims it. + original_dev = str(task.assigned_to) if task.assigned_to else None + if original_dev: + task.quick_context = f"original_developer:{original_dev}" + task.self_verified = True task.status = TaskStatus.AWAITING_QA await self.session.flush() - logger.info("Task submitted for QA", task_id=str(task_id)) + logger.info( + "Task submitted for QA", + task_id=str(task_id), + original_developer=original_dev, + ) return task async def pass_qa( @@ -427,7 +515,13 @@ class TaskService: return task async def fail_qa(self, task_id: UUID, notes: str) -> TaskTable | None: - """Mark task as failed QA.""" + """ + Mark task as failed QA and reassign to original developer. + + When QA fails a task, it goes back to the original developer for revision. + The original developer is extracted from quick_context which stores + "original_developer:{uuid}" when the task was submitted to QA. + """ task = await self.get(task_id) if not task: return None @@ -438,25 +532,105 @@ class TaskService: task.qa_notes = notes task.qa_verified = False task.status = TaskStatus.NEEDS_REVISION + + # Reassign to original developer so they can work on revisions + original_dev = extract_original_developer(task.quick_context) + if original_dev: + task.assigned_to = cast("Any", UUID(original_dev)) + logger.info( + "Task reassigned to original developer for revision", + task_id=str(task_id), + original_developer=original_dev, + ) + else: + # If no original developer found, unassign so it can be claimed + task.assigned_to = None + logger.warning( + "No original developer found, task unassigned", + task_id=str(task_id), + ) + await self.session.flush() logger.info("Task failed QA", task_id=str(task_id)) return task + async def docs_complete( + self, + task_id: UUID, + doc_notes: str | None = None, + ) -> TaskTable | None: + """ + Mark documentation as complete (documenter only). + + Transitions task from AWAITING_DOCUMENTATION to AWAITING_PM_REVIEW. + The Cell PM will then review and call complete() to finish the task. + + Args: + task_id: The task to mark docs complete + doc_notes: Optional notes about the documentation + + Returns: + The updated task or None if not allowed + """ + task = await self.get(task_id) + if not task: + return None + + if task.status != TaskStatus.AWAITING_DOCUMENTATION: + logger.warning( + "Cannot mark docs complete - not awaiting documentation", + task_id=str(task_id), + current_status=task.status.value, + ) + return None + + # Store doc notes in quick_context (no dedicated field for doc_notes) + if doc_notes: + existing_context = task.quick_context or "" + doc_note_entry = f"doc_notes:{doc_notes}" + task.quick_context = ( + f"{existing_context}\n{doc_note_entry}".strip() + if existing_context + else doc_note_entry + ) + + task.status = TaskStatus.AWAITING_PM_REVIEW + + # Reassign to the cell PM for final review + # Store documenter in quick_context for reference + if task.assigned_to: + existing_context = task.quick_context or "" + if "documenter:" not in existing_context: + doc_context = f"documenter:{task.assigned_to}" + task.quick_context = ( + f"{existing_context}\n{doc_context}".strip() + if existing_context + else doc_context + ) + + # Note: We don't auto-assign to PM here - PM will pick it up via scan + # The task remains assigned to documenter until PM claims it + await self.session.flush() + + logger.info( + "Documentation complete, awaiting PM review", + task_id=str(task_id), + ) + return task + async def complete( self, task_id: UUID, - skip_handoff_check: bool = False, ) -> TaskTable | None: """ - Mark task as completed. + Mark task as completed (PM only). - Enforces handoff requirement: tasks in AWAITING_DOCUMENTATION - must have an accepted handoff before completion. + Only PMs can complete tasks, and only from AWAITING_PM_REVIEW status. + This ensures the full workflow: Dev → QA → Documenter → PM. Args: task_id: The task to complete - skip_handoff_check: Skip handoff requirement (for small tasks) Returns: The completed task or None if completion not allowed @@ -465,28 +639,13 @@ class TaskService: if not task: return None - # Validate transition using enforcement layer - try: - validate_task_transition(task.status.value, TaskStatus.COMPLETED.value) - except TaskLifecycleError: - # Allow from specific states - if task.status not in ( - TaskStatus.AWAITING_DOCUMENTATION, - TaskStatus.AWAITING_QA, # Small tasks may skip docs - TaskStatus.VERIFYING, # Solo dev may skip QA - ): - return None - - # 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 - ): + # Only allow completion from AWAITING_PM_REVIEW + # This enforces the workflow: documenter calls docs_complete, PM calls complete + if task.status != TaskStatus.AWAITING_PM_REVIEW: logger.warning( - "Cannot complete task - QA verification required", + "Cannot complete task - must be in awaiting_pm_review status", task_id=str(task_id), - status=task.status.value, + current_status=task.status.value, ) return None