Documenter is ignored but it works pretty well

This commit is contained in:
Renn F
2025-12-25 07:40:39 +01:00
parent 15f5be3f8b
commit 7f13e10bf4
13 changed files with 386 additions and 99 deletions
+2 -7
View File
@@ -1,13 +1,9 @@
services: services:
# ========================================================================== # ==========================================================================
# PostgreSQL - Primary Database with pgvector for RAG # PostgreSQL - Primary Database with pgvector for RAG
# Uses Docker Hardened Image (DHI) base with pgvector extension
# ========================================================================== # ==========================================================================
postgres: postgres:
build: image: pgvector/pgvector:pg16
context: .
dockerfile: docker/postgres-pgvector.Dockerfile
image: roboco-postgres-pgvector
container_name: roboco-postgres container_name: roboco-postgres
restart: unless-stopped restart: unless-stopped
environment: environment:
@@ -26,10 +22,9 @@ services:
# ========================================================================== # ==========================================================================
# Redis - Cache, Sessions, Event Bus # Redis - Cache, Sessions, Event Bus
# Uses Docker Hardened Image (DHI) for enhanced security
# ========================================================================== # ==========================================================================
redis: redis:
image: dhi.io/redis:8-alpine image: redis:8-alpine
container_name: roboco-redis container_name: roboco-redis
restart: unless-stopped restart: unless-stopped
command: redis-server --appendonly yes command: redis-server --appendonly yes
+3 -3
View File
@@ -1,10 +1,10 @@
# ============================================================================= # =============================================================================
# Agent Base Image - Docker Hardened Image (DHI) # Agent Base Image
# ============================================================================= # =============================================================================
# Uses DHI Python 3.13 with dev tools for Claude Code agent containers # Python 3.13 with dev tools for Claude Code agent containers
# ============================================================================= # =============================================================================
FROM dhi.io/python:3.13-debian13-dev FROM python:3.13-bookworm
# Install Node.js 22 (required for Claude Code CLI) # Install Node.js 22 (required for Claude Code CLI)
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
+3 -3
View File
@@ -1,10 +1,10 @@
# ============================================================================= # =============================================================================
# Orchestrator - Docker Hardened Image (DHI) # Orchestrator
# ============================================================================= # =============================================================================
# API Server + Agent Spawner using DHI Python 3.13 # API Server + Agent Spawner using Python 3.13
# ============================================================================= # =============================================================================
FROM dhi.io/python:3.13-debian13-dev FROM python:3.13-bookworm
# Install dependencies + Docker CLI # Install dependencies + Docker CLI
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
+6 -36
View File
@@ -1,44 +1,14 @@
# ============================================================================= # =============================================================================
# PostgreSQL with pgvector on Docker Hardened Image # PostgreSQL with pgvector (Custom Build Example)
# ============================================================================= # =============================================================================
# Multi-stage build: # Example: Build pgvector from source on standard postgres image
# 1. Build pgvector extension using standard postgres image (has build tools) # Currently unused - docker-compose.yml uses pgvector/pgvector:pg16 directly
# 2. Copy compiled extension to DHI postgres (minimal, secure runtime)
# ============================================================================= # =============================================================================
# ----------------------------------------------------------------------------- FROM pgvector/pgvector:pg17
# Stage 1: Build pgvector extension
# -----------------------------------------------------------------------------
FROM postgres:17 AS builder
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
git \
postgresql-server-dev-17 \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Clone and build pgvector (use tagged release for reproducibility)
ARG PGVECTOR_VERSION=0.8.1
RUN git clone --branch v${PGVECTOR_VERSION} --depth 1 https://github.com/pgvector/pgvector.git /tmp/pgvector \
&& cd /tmp/pgvector \
&& make OPTFLAGS="" \
&& make install
# -----------------------------------------------------------------------------
# Stage 2: DHI Runtime with pgvector
# -----------------------------------------------------------------------------
FROM dhi.io/postgres:17-debian13
# Copy pgvector extension files from builder
# Extension shared library
COPY --from=builder /usr/lib/postgresql/17/lib/vector.so /usr/lib/postgresql/17/lib/
# Extension control and SQL files
COPY --from=builder /usr/share/postgresql/17/extension/vector* /usr/share/postgresql/17/extension/
# Add init script to create extension on startup # Add init script to create extension on startup
COPY docker/postgres-init/01-create-extensions.sql /docker-entrypoint-initdb.d/ COPY docker/postgres-init/01-create-extensions.sql /docker-entrypoint-initdb.d/
LABEL org.opencontainers.image.title="PostgreSQL with pgvector (DHI)" LABEL org.opencontainers.image.title="PostgreSQL with pgvector"
LABEL org.opencontainers.image.description="Docker Hardened PostgreSQL 17 with pgvector extension for vector similarity search" LABEL org.opencontainers.image.description="PostgreSQL 17 with pgvector extension for vector similarity search"
+10 -2
View File
@@ -291,9 +291,17 @@ Respond with structured analysis.
# PLAN: Save documentation plan to task API (required before start) # PLAN: Save documentation plan to task API (required before start)
plan_data = { plan_data = {
"approach": f"Document {ctx.title}", "approach": f"Document {ctx.title}",
"steps": [doc.title for doc in ctx.documents_needed], "sub_tasks": [
{
"id": f"doc-{i}",
"title": doc.title,
"description": f"Write {doc.doc_type.value} at {doc.path}",
"completed": False,
"order": i,
}
for i, doc in enumerate(ctx.documents_needed)
],
"risks": [], "risks": [],
"estimated_sessions": 1,
} }
await self._api_call("PATCH", f"/tasks/{ctx.task_id}", json={"plan": plan_data}) await self._api_call("PATCH", f"/tasks/{ctx.task_id}", json={"plan": plan_data})
+24 -4
View File
@@ -23,11 +23,31 @@ _DEFAULT_PROMPTS = {
Team.UX_UI: "You are a UX/UI developer.", Team.UX_UI: "You are a UX/UI developer.",
} }
# Default capabilities for each team # Default capabilities for each team (matches blueprint capabilities)
_CAPABILITIES = { _CAPABILITIES = {
Team.BACKEND: ["code_execution", "git_operations", "file_management"], Team.BACKEND: [
Team.FRONTEND: ["code_execution", "git_operations", "file_management"], "code_execution",
Team.UX_UI: ["design_tools", "file_management"], "git_operations",
"file_management",
"api_development",
"database_design",
],
Team.FRONTEND: [
"code_execution",
"git_operations",
"file_management",
"browser_testing",
"accessibility_testing",
"responsive_design",
],
Team.UX_UI: [
"design_tools",
"file_management",
"figma_expertise",
"prototyping",
"design_system_management",
"accessibility_design",
],
} }
+20 -4
View File
@@ -23,11 +23,27 @@ _DEFAULT_PROMPTS = {
Team.UX_UI: "You are a UX/UI documenter.", Team.UX_UI: "You are a UX/UI documenter.",
} }
# Default capabilities for each team # Default capabilities for each team (matches blueprint capabilities)
_CAPABILITIES = { _CAPABILITIES = {
Team.BACKEND: ["documentation", "file_management"], Team.BACKEND: [
Team.FRONTEND: ["documentation", "storybook", "file_management"], "technical_writing",
Team.UX_UI: ["documentation", "design_system_docs"], "api_documentation",
"code_reading",
"file_management",
],
Team.FRONTEND: [
"technical_writing",
"component_documentation",
"code_reading",
"storybook",
"file_management",
],
Team.UX_UI: [
"design_documentation",
"design_system_maintenance",
"technical_writing",
"file_management",
],
} }
+18 -4
View File
@@ -23,11 +23,25 @@ _DEFAULT_PROMPTS = {
Team.UX_UI: "You are a UX/UI QA engineer.", Team.UX_UI: "You are a UX/UI QA engineer.",
} }
# Default capabilities for each team # Default capabilities for each team (matches blueprint capabilities)
_CAPABILITIES = { _CAPABILITIES = {
Team.BACKEND: ["code_review", "test_execution", "security_analysis"], Team.BACKEND: [
Team.FRONTEND: ["visual_testing", "a11y_testing", "browser_testing"], "code_review",
Team.UX_UI: ["design_review", "consistency_check", "a11y_testing"], "test_execution",
"security_analysis",
"quality_assurance",
],
Team.FRONTEND: [
"visual_testing",
"accessibility_testing",
"browser_testing",
"quality_assurance",
],
Team.UX_UI: [
"design_review",
"accessibility_review",
"quality_assurance",
],
} }
+210 -22
View File
@@ -311,34 +311,121 @@ class CellPMAgent(Agent, CyclicPhaseRunner[CellPMPhase]):
} }
async def _delegate_task(self, task_id: UUID, task: dict[str, Any]) -> bool: async def _delegate_task(self, task_id: UUID, task: dict[str, Any]) -> bool:
"""Delegate task by creating subtasks for developers.""" """Delegate task by creating subtasks for developers.
# Simple delegation - create one subtask assigned to available dev
Follows blueprint workflow:
CREATE (backlog) → SESSION → ACTIVATE (pending) → NOTIFY
"""
# Find available developer
best_dev = await self._find_best_dev(task_id) best_dev = await self._find_best_dev(task_id)
if not best_dev: if not best_dev:
self.log.warning("No available developer for task", task_id=str(task_id)) self.log.warning("No available developer for task", task_id=str(task_id))
return False return False
# Create subtask team = self.team.value if self.team else "backend"
await self._api_call(
# Step 1: Create subtask with status "backlog" (prevents premature pickup)
subtask_resp = await self._api_call(
"POST", "POST",
"/tasks", "/tasks",
json={ json={
"title": f"Implement: {task.get('title', 'Task')}", "title": f"Implement: {task.get('title', 'Task')}",
"description": task.get("description", ""), "description": task.get("description", ""),
"team": self.team.value if self.team else "backend", "team": team,
"acceptance_criteria": task.get("acceptance_criteria", []), "acceptance_criteria": task.get("acceptance_criteria", []),
"parent_task_id": str(task_id), "parent_task_id": str(task_id),
"assigned_to": str(best_dev.agent_id), "assigned_to": str(best_dev.agent_id),
"status": "pending", "status": "backlog", # Backlog until session is ready
}, },
) )
subtask_id = subtask_resp.get("id")
if not subtask_id:
self.log.error("Failed to create subtask", parent_task_id=str(task_id))
return False
self.log.info( self.log.info(
"PM created subtask", "PM created subtask (backlog)",
subtask_id=subtask_id,
parent_task_id=str(task_id), parent_task_id=str(task_id),
assigned_to=str(best_dev.agent_id), assigned_to=str(best_dev.agent_id),
) )
# Step 2: Create session for the subtask
channel_slug = self._get_team_channel(team)
try:
await self._api_call(
"POST",
"/sessions/for-tasks",
json={
"task_ids": [subtask_id],
"channel_slug": channel_slug,
"scope": f"Work session for {task.get('title', 'task')}",
"relationship_type": "implements",
},
)
self.log.info("Session created for subtask", subtask_id=subtask_id)
except Exception as e:
self.log.warning(
"Session creation failed, activating anyway",
subtask_id=subtask_id,
error=str(e),
)
# Step 3: Activate subtask (changes status to pending)
try:
await self._api_call("POST", f"/tasks/{subtask_id}/activate")
self.log.info("Subtask activated", subtask_id=subtask_id)
except Exception as e:
self.log.error(
"Failed to activate subtask",
subtask_id=subtask_id,
error=str(e),
)
return False
# Step 4: Notify assigned developer
try:
await self._notify_developer(best_dev, subtask_id, task)
except Exception as e:
self.log.warning(
"Failed to notify developer (task still assigned)",
error=str(e),
)
return True return True
def _get_team_channel(self, team: str) -> str:
"""Get the channel slug for a team."""
channel_map = {
"backend": "backend-cell",
"frontend": "frontend-cell",
"ux_ui": "uxui-cell",
}
return channel_map.get(team, "backend-cell")
async def _notify_developer(
self, dev: Any, subtask_id: str, task: dict[str, Any]
) -> None:
"""Notify developer of new task assignment."""
await self._api_call(
"POST",
"/notifications",
json={
"type": "task_assigned",
"priority": "normal",
"to_agents": [str(dev.agent_id)],
"subject": f"New task: {task.get('title', 'Task')}",
"body": (
f"You've been assigned a new task.\n\n"
f"Task ID: {subtask_id}\n"
f"Title: {task.get('title', 'Task')}\n\n"
f"Use roboco_task_scan to find and claim it."
),
"related_task_id": subtask_id,
"requires_ack": False,
},
)
# ========================================================================= # =========================================================================
# CELL PM PHASES # CELL PM PHASES
# ========================================================================= # =========================================================================
@@ -445,11 +532,15 @@ medium,TASK-abc123,P1,backend-dev-1
# Check for pending questions in channel # Check for pending questions in channel
questions = await self._get_pending_questions() questions = await self._get_pending_questions()
for question in questions: for question_data in questions:
question_content = question_data.get("content", "")
task_id = question_data.get("task_id")
session_id = question_data.get("session_id")
# Use TOON for token-efficient context encoding # Use TOON for token-efficient context encoding
question_context = self.format_context_labeled( question_context = self.format_context_labeled(
"Cell Question", "Cell Question",
{"question": question, "cell": self.cell_name}, {"question": question_content, "cell": self.cell_name},
) )
prompt = f"""A cell member needs help: prompt = f"""A cell member needs help:
@@ -464,12 +555,26 @@ As the Cell PM, provide:
Be helpful and unblock the team. Be helpful and unblock the team.
""" """
response = await self.think(prompt) response = await self.think(prompt)
# TODO: Questions should include task_id for routing
# For now, log that we can't route without session context # Send response with task_id for proper routing
self.log.info( if session_id:
"PM response (no session context - need task_id in questions)", await self.send_message(
response_preview=response[:100], UUID(session_id),
) response,
message_type="answer",
task_id=UUID(task_id) if task_id else None,
)
self.log.info(
"PM responded to question",
task_id=task_id,
response_preview=response[:100],
)
else:
self.log.info(
"PM response (no session - using channel)",
task_id=task_id,
response_preview=response[:100],
)
async def _phase_escalate(self) -> None: async def _phase_escalate(self) -> None:
""" """
@@ -626,15 +731,26 @@ Be helpful and unblock the team.
except Exception as e: except Exception as e:
self.log.error("Failed to assign task", error=str(e)) self.log.error("Failed to assign task", error=str(e))
async def _get_pending_questions(self) -> list[str]: async def _get_pending_questions(self) -> list[dict[str, Any]]:
"""Get unanswered questions from channel.""" """Get unanswered questions from channel.
Returns full message info including task_id for routing.
"""
try: try:
result = await self._api_call( result = await self._api_call(
"GET", "GET",
"/messages", "/messages",
params={"message_type": "dialogue", "unanswered": True}, params={"message_type": "dialogue", "unanswered": True},
) )
return [m.get("content", "") for m in result.get("items", [])] return [
{
"content": m.get("content", ""),
"task_id": m.get("task_id"),
"session_id": m.get("session_id"),
"from_agent": m.get("from_agent"),
}
for m in result.get("items", [])
]
except Exception as e: except Exception as e:
self.log.warning("Failed to get pending questions", error=str(e)) self.log.warning("Failed to get pending questions", error=str(e))
return [] return []
@@ -1073,8 +1189,13 @@ class MainPMAgent(Agent, CyclicPhaseRunner[MainPMPhase]):
async def _create_cell_task( async def _create_cell_task(
self, parent_id: UUID, task: dict[str, Any], team: str, pm_slug: str self, parent_id: UUID, task: dict[str, Any], team: str, pm_slug: str
) -> None: ) -> None:
"""Create a task for a Cell PM.""" """Create a task for a Cell PM.
await self._api_call(
Follows blueprint workflow:
CREATE (backlog) → GROUP → SESSION → ACTIVATE (pending) → NOTIFY
"""
# Step 1: Create task with status "backlog"
task_resp = await self._api_call(
"POST", "POST",
"/tasks", "/tasks",
json={ json={
@@ -1084,16 +1205,83 @@ class MainPMAgent(Agent, CyclicPhaseRunner[MainPMPhase]):
"acceptance_criteria": task.get("acceptance_criteria", []), "acceptance_criteria": task.get("acceptance_criteria", []),
"parent_task_id": str(parent_id), "parent_task_id": str(parent_id),
"assigned_to": pm_slug, "assigned_to": pm_slug,
"status": "pending", "status": "backlog", # Backlog until session is ready
}, },
) )
cell_task_id = task_resp.get("id")
if not cell_task_id:
self.log.error("Failed to create cell task", parent_id=str(parent_id))
return
self.log.info( self.log.info(
"Main PM created cell task", "Main PM created cell task (backlog)",
cell_task_id=cell_task_id,
parent_task_id=str(parent_id), parent_task_id=str(parent_id),
team=team, team=team,
assigned_to=pm_slug, assigned_to=pm_slug,
) )
# Step 2: Create group if needed (cross-cell initiatives)
channel_slug = self._get_team_channel(team)
# Step 3: Create session for the cell task
try:
await self._api_call(
"POST",
"/sessions/for-tasks",
json={
"task_ids": [cell_task_id],
"channel_slug": channel_slug,
"scope": f"Cell work for {task.get('title', 'initiative')}",
"relationship_type": "implements",
},
)
self.log.info("Session created for cell task", cell_task_id=cell_task_id)
except Exception as e:
self.log.warning(
"Session creation failed, activating anyway",
cell_task_id=cell_task_id,
error=str(e),
)
# Step 4: Activate task (changes status to pending)
try:
await self._api_call("POST", f"/tasks/{cell_task_id}/activate")
self.log.info("Cell task activated", cell_task_id=cell_task_id)
except Exception as e:
self.log.error(
"Failed to activate cell task",
cell_task_id=cell_task_id,
error=str(e),
)
return
# Step 5: Notify Cell PM
try:
await self._api_call(
"POST",
"/notifications",
json={
"type": "task_assigned",
"priority": "normal",
"to_agents": [pm_slug],
"subject": f"New initiative: {task.get('title', 'Task')}",
"body": (
f"A new initiative has been assigned to your cell.\n\n"
f"Task ID: {cell_task_id}\n"
f"Title: {task.get('title', 'Task')}\n\n"
f"Please triage and delegate to your team."
),
"related_task_id": cell_task_id,
"requires_ack": True,
},
)
except Exception as e:
self.log.warning(
"Failed to notify Cell PM (task still assigned)",
error=str(e),
)
# ========================================================================= # =========================================================================
# MAIN PM PHASES # MAIN PM PHASES
# ========================================================================= # =========================================================================
+10 -2
View File
@@ -271,9 +271,17 @@ Acceptance Criteria,Verify all criteria met,Review implementation|Check each cri
# PLAN: Save test plan to task API (required before start) # PLAN: Save test plan to task API (required before start)
plan_data = { plan_data = {
"approach": f"QA review of {ctx.title}", "approach": f"QA review of {ctx.title}",
"steps": [tc.name for tc in ctx.test_cases], "sub_tasks": [
{
"id": f"test-{i}",
"title": tc.name,
"description": tc.description,
"completed": False,
"order": i,
}
for i, tc in enumerate(ctx.test_cases)
],
"risks": [], "risks": [],
"estimated_sessions": 1,
} }
await self._api_call("PATCH", f"/tasks/{ctx.task_id}", json={"plan": plan_data}) await self._api_call("PATCH", f"/tasks/{ctx.task_id}", json={"plan": plan_data})
+7
View File
@@ -15,6 +15,7 @@ from roboco.api.middleware import setup_middleware
from roboco.api.routes.agents import router as agents_router from roboco.api.routes.agents import router as agents_router
from roboco.api.routes.channels import router as channels_router from roboco.api.routes.channels import router as channels_router
from roboco.api.routes.dashboard import router as dashboard_router from roboco.api.routes.dashboard import router as dashboard_router
from roboco.api.routes.groups import router as groups_router
from roboco.api.routes.health import router as health_router from roboco.api.routes.health import router as health_router
from roboco.api.routes.journals import router as journals_router from roboco.api.routes.journals import router as journals_router
from roboco.api.routes.kanban import router as kanban_router from roboco.api.routes.kanban import router as kanban_router
@@ -159,6 +160,12 @@ def create_app() -> FastAPI:
tags=["Channels"], tags=["Channels"],
) )
app.include_router(
groups_router,
prefix=f"{api_prefix}/groups",
tags=["Groups"],
)
app.include_router( app.include_router(
sessions_router, sessions_router,
prefix=f"{api_prefix}/sessions", prefix=f"{api_prefix}/sessions",
+43 -8
View File
@@ -240,24 +240,59 @@ def _check_recipients(agent_id: str, recipients: list[str]) -> dict[str, Any] |
return None return None
def _validate_send_input(
agent_id: str, data: SendNotificationInput
) -> dict[str, Any] | None:
"""Validate all send notification inputs. Returns error or None."""
for check in [
lambda: _check_send_permission(agent_id),
lambda: _check_recipients(agent_id, data.recipients),
lambda: _validate_notification_type(data.notification_type),
lambda: _validate_priority(data.priority),
]:
if error := check():
return error
return None
async def _resolve_recipients(
recipients: list[str], client: "ApiClient"
) -> tuple[list[str], dict[str, Any] | None]:
"""Resolve recipient slugs to UUIDs. Returns (resolved_list, error_or_none)."""
from roboco.mcp.utils import resolve_agent_uuid_cached
resolved: list[str] = []
unresolved: list[str] = []
for recipient in recipients:
uuid = await resolve_agent_uuid_cached(recipient, client)
if uuid:
resolved.append(uuid)
else:
unresolved.append(recipient)
if unresolved:
return [], format_error_response(
"RECIPIENT_NOT_FOUND",
f"Could not resolve recipient(s): {', '.join(unresolved)}",
)
return resolved, None
async def _handle_send( async def _handle_send(
client: ApiClient, agent_id: str, data: SendNotificationInput client: ApiClient, agent_id: str, data: SendNotificationInput
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Handle sending a notification.""" """Handle sending a notification."""
# Validate permissions and data if error := _validate_send_input(agent_id, data):
if error := _check_send_permission(agent_id):
return error return error
if error := _check_recipients(agent_id, data.recipients):
return error resolved_recipients, error = await _resolve_recipients(data.recipients, client)
if error := _validate_notification_type(data.notification_type): if error:
return error
if error := _validate_priority(data.priority):
return error return error
payload = { payload = {
"type": data.notification_type, "type": data.notification_type,
"priority": data.priority, "priority": data.priority,
"to_agents": data.recipients, "to_agents": resolved_recipients,
"subject": data.subject, "subject": data.subject,
"body": data.body, "body": data.body,
"requires_ack": data.requires_ack, "requires_ack": data.requires_ack,
+30 -4
View File
@@ -1418,16 +1418,42 @@ Start now: roboco_task_get("{task_id}")
This is the FIRST dispatcher called - it classifies unassigned tasks This is the FIRST dispatcher called - it classifies unassigned tasks
and routes them to Board, Main PM, Cell PM, or directly to devs. and routes them to Board, Main PM, Cell PM, or directly to devs.
Also handles already-assigned pending tasks for PM agents.
Monitors: pending tasks with no assigned_to Monitors: pending tasks (both assigned and unassigned)
Spawns: product-owner, main-pm, be-pm, fe-pm, ux-pm (or devs for simple) Spawns: product-owner, main-pm, be-pm, fe-pm, ux-pm (or devs for simple)
""" """
# Get pending tasks that haven't been assigned yet # Get pending tasks
tasks = await self._fetch_tasks(client, "pending") tasks = await self._fetch_tasks(client, "pending")
# PM-level agents that can have direct assignments
pm_agents = {
"main-pm",
"be-pm",
"fe-pm",
"ux-pm",
"product-owner",
"head-marketing",
"auditor",
}
for task in tasks: for task in tasks:
# Skip already assigned tasks assigned_to = task.get("assigned_to")
if task.get("assigned_to"):
# Handle already-assigned tasks for PM agents
if assigned_to:
agent_slug = self._resolve_agent_slug(assigned_to)
if agent_slug in pm_agents and not self._is_agent_active(agent_slug):
logger.info(
"Spawning assigned PM agent",
task_id=task.get("id"),
agent_id=agent_slug,
)
await self.spawn_agent(
agent_id=agent_slug,
task_id=task["id"],
initial_prompt=self._build_pm_triage_prompt(task),
)
continue continue
# Classify the task # Classify the task