From d0fb0a50f47ea596a7613a12b6b66567360d2b3b Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Thu, 5 Feb 2026 14:32:25 +0530 Subject: [PATCH 01/41] chore: ignore worktrees --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 16626a6..81fd7fc 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ node_modules/ # IDE .idea/ .runlogs/ + +# Worktrees +.worktrees/ From 78e89427ba91e63f4d731b2e641b91e6fd36c56f Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Thu, 5 Feb 2026 14:37:12 +0530 Subject: [PATCH 02/41] feat: add lead policy helpers --- backend/app/services/lead_policy.py | 27 ++++++++++++++++ backend/tests/conftest.py | 6 ++++ backend/tests/test_lead_policy.py | 50 +++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 backend/app/services/lead_policy.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_lead_policy.py diff --git a/backend/app/services/lead_policy.py b/backend/app/services/lead_policy.py new file mode 100644 index 0000000..28885af --- /dev/null +++ b/backend/app/services/lead_policy.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +import hashlib +from typing import Mapping + +CONFIDENCE_THRESHOLD = 80 + + +def compute_confidence(rubric_scores: Mapping[str, int]) -> int: + return int(sum(rubric_scores.values())) + + +def approval_required(*, confidence: int, is_external: bool, is_risky: bool) -> bool: + return is_external or is_risky or confidence < CONFIDENCE_THRESHOLD + + +def infer_planning(signals: Mapping[str, bool]) -> bool: + # Require at least two planning signals to avoid spam on general boards. + truthy = [key for key, value in signals.items() if value] + return len(truthy) >= 2 + + +def task_fingerprint(title: str, description: str | None, board_id: str) -> str: + normalized_title = title.strip().lower() + normalized_desc = (description or "").strip().lower() + seed = f"{board_id}::{normalized_title}::{normalized_desc}" + return hashlib.sha256(seed.encode()).hexdigest() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..2a855d9 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,6 @@ +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) diff --git a/backend/tests/test_lead_policy.py b/backend/tests/test_lead_policy.py new file mode 100644 index 0000000..8bc771a --- /dev/null +++ b/backend/tests/test_lead_policy.py @@ -0,0 +1,50 @@ +import hashlib + +from app.services.lead_policy import ( + approval_required, + compute_confidence, + infer_planning, + task_fingerprint, +) + + +def test_compute_confidence_sums_weights(): + rubric = { + "clarity": 20, + "constraints": 15, + "completeness": 10, + "risk": 20, + "dependencies": 10, + "similarity": 5, + } + assert compute_confidence(rubric) == 80 + + +def test_approval_required_for_low_confidence(): + assert approval_required(confidence=79, is_external=False, is_risky=False) + assert not approval_required(confidence=85, is_external=False, is_risky=False) + + +def test_approval_required_for_external_or_risky(): + assert approval_required(confidence=90, is_external=True, is_risky=False) + assert approval_required(confidence=90, is_external=False, is_risky=True) + + +def test_infer_planning_requires_signal_threshold(): + signals = { + "goal_gap": True, + "recent_ambiguity": False, + "research_only": False, + "stalled_inbox": False, + } + assert infer_planning(signals) is False + + signals["recent_ambiguity"] = True + assert infer_planning(signals) is True + + +def test_task_fingerprint_deterministic(): + fp1 = task_fingerprint("Title", "Desc", "board-1") + fp2 = task_fingerprint("Title", "Desc", "board-1") + assert fp1 == fp2 + assert fp1 == hashlib.sha256("board-1::title::desc".encode()).hexdigest() From 89d24282520256b19bbd77cf2d54b7203c80c975 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Thu, 5 Feb 2026 14:39:34 +0530 Subject: [PATCH 03/41] feat: add board goals, memory, approvals, onboarding models --- .../3b9b2f1a6c2d_board_lead_orchestration.py | 145 ++++++++++++++++++ backend/app/models/__init__.py | 8 + backend/app/models/agents.py | 1 + backend/app/models/approvals.py | 22 +++ backend/app/models/board_memory.py | 18 +++ backend/app/models/board_onboarding.py | 20 +++ backend/app/models/boards.py | 7 + backend/app/models/task_fingerprints.py | 16 ++ backend/app/models/tasks.py | 2 + 9 files changed, 239 insertions(+) create mode 100644 backend/alembic/versions/3b9b2f1a6c2d_board_lead_orchestration.py create mode 100644 backend/app/models/approvals.py create mode 100644 backend/app/models/board_memory.py create mode 100644 backend/app/models/board_onboarding.py create mode 100644 backend/app/models/task_fingerprints.py diff --git a/backend/alembic/versions/3b9b2f1a6c2d_board_lead_orchestration.py b/backend/alembic/versions/3b9b2f1a6c2d_board_lead_orchestration.py new file mode 100644 index 0000000..8f9dbce --- /dev/null +++ b/backend/alembic/versions/3b9b2f1a6c2d_board_lead_orchestration.py @@ -0,0 +1,145 @@ +"""board lead orchestration + +Revision ID: 3b9b2f1a6c2d +Revises: 9f2c1a7b0d3e +Create Date: 2026-02-05 14:45:00.000000 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "3b9b2f1a6c2d" +down_revision = "9f2c1a7b0d3e" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("boards", sa.Column("board_type", sa.String(), server_default="goal", nullable=False)) + op.add_column("boards", sa.Column("objective", sa.Text(), nullable=True)) + op.add_column("boards", sa.Column("success_metrics", sa.JSON(), nullable=True)) + op.add_column("boards", sa.Column("target_date", sa.DateTime(), nullable=True)) + op.add_column( + "boards", + sa.Column("goal_confirmed", sa.Boolean(), server_default=sa.text("false"), nullable=False), + ) + op.add_column("boards", sa.Column("goal_source", sa.Text(), nullable=True)) + + op.add_column( + "agents", + sa.Column("is_board_lead", sa.Boolean(), server_default=sa.text("false"), nullable=False), + ) + + op.add_column( + "tasks", + sa.Column("auto_created", sa.Boolean(), server_default=sa.text("false"), nullable=False), + ) + op.add_column("tasks", sa.Column("auto_reason", sa.Text(), nullable=True)) + + op.create_table( + "board_memory", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("board_id", sa.Uuid(), nullable=False), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("tags", sa.JSON(), nullable=True), + sa.Column("source", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["board_id"], ["boards.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_board_memory_board_id", "board_memory", ["board_id"], unique=False) + + op.create_table( + "approvals", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("board_id", sa.Uuid(), nullable=False), + sa.Column("agent_id", sa.Uuid(), nullable=True), + sa.Column("action_type", sa.String(), nullable=False), + sa.Column("payload", sa.JSON(), nullable=True), + sa.Column("confidence", sa.Integer(), nullable=False), + sa.Column("rubric_scores", sa.JSON(), nullable=True), + sa.Column("status", sa.String(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("resolved_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["agent_id"], ["agents.id"]), + sa.ForeignKeyConstraint(["board_id"], ["boards.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_approvals_board_id", "approvals", ["board_id"], unique=False) + op.create_index("ix_approvals_agent_id", "approvals", ["agent_id"], unique=False) + op.create_index("ix_approvals_status", "approvals", ["status"], unique=False) + + op.create_table( + "board_onboarding_sessions", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("board_id", sa.Uuid(), nullable=False), + sa.Column("session_key", sa.String(), nullable=False), + sa.Column("status", sa.String(), nullable=False), + sa.Column("messages", sa.JSON(), nullable=True), + sa.Column("draft_goal", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["board_id"], ["boards.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_board_onboarding_sessions_board_id", + "board_onboarding_sessions", + ["board_id"], + unique=False, + ) + op.create_index( + "ix_board_onboarding_sessions_status", + "board_onboarding_sessions", + ["status"], + unique=False, + ) + + op.create_table( + "task_fingerprints", + sa.Column("id", sa.Uuid(), nullable=False), + sa.Column("board_id", sa.Uuid(), nullable=False), + sa.Column("fingerprint_hash", sa.String(), nullable=False), + sa.Column("task_id", sa.Uuid(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["board_id"], ["boards.id"]), + sa.ForeignKeyConstraint(["task_id"], ["tasks.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + "ix_task_fingerprints_board_hash", + "task_fingerprints", + ["board_id", "fingerprint_hash"], + unique=True, + ) + + +def downgrade() -> None: + op.drop_index("ix_task_fingerprints_board_hash", table_name="task_fingerprints") + op.drop_table("task_fingerprints") + op.drop_index( + "ix_board_onboarding_sessions_status", table_name="board_onboarding_sessions" + ) + op.drop_index( + "ix_board_onboarding_sessions_board_id", table_name="board_onboarding_sessions" + ) + op.drop_table("board_onboarding_sessions") + op.drop_index("ix_approvals_status", table_name="approvals") + op.drop_index("ix_approvals_agent_id", table_name="approvals") + op.drop_index("ix_approvals_board_id", table_name="approvals") + op.drop_table("approvals") + op.drop_index("ix_board_memory_board_id", table_name="board_memory") + op.drop_table("board_memory") + op.drop_column("tasks", "auto_reason") + op.drop_column("tasks", "auto_created") + op.drop_column("agents", "is_board_lead") + op.drop_column("boards", "goal_source") + op.drop_column("boards", "goal_confirmed") + op.drop_column("boards", "target_date") + op.drop_column("boards", "success_metrics") + op.drop_column("boards", "objective") + op.drop_column("boards", "board_type") diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 9f0e5c3..3ed49db 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,15 +1,23 @@ from app.models.activity_events import ActivityEvent from app.models.agents import Agent +from app.models.approvals import Approval +from app.models.board_memory import BoardMemory +from app.models.board_onboarding import BoardOnboardingSession from app.models.boards import Board from app.models.gateways import Gateway from app.models.tasks import Task +from app.models.task_fingerprints import TaskFingerprint from app.models.users import User __all__ = [ "ActivityEvent", "Agent", + "Approval", + "BoardMemory", + "BoardOnboardingSession", "Board", "Gateway", "Task", + "TaskFingerprint", "User", ] diff --git a/backend/app/models/agents.py b/backend/app/models/agents.py index efededb..d7d5642 100644 --- a/backend/app/models/agents.py +++ b/backend/app/models/agents.py @@ -27,5 +27,6 @@ class Agent(SQLModel, table=True): delete_requested_at: datetime | None = Field(default=None) delete_confirm_token_hash: str | None = Field(default=None, index=True) last_seen_at: datetime | None = Field(default=None) + is_board_lead: bool = Field(default=False, index=True) created_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.utcnow) diff --git a/backend/app/models/approvals.py b/backend/app/models/approvals.py new file mode 100644 index 0000000..6fdf662 --- /dev/null +++ b/backend/app/models/approvals.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import UUID, uuid4 + +from sqlalchemy import JSON, Column +from sqlmodel import Field, SQLModel + + +class Approval(SQLModel, table=True): + __tablename__ = "approvals" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + board_id: UUID = Field(foreign_key="boards.id", index=True) + agent_id: UUID | None = Field(default=None, foreign_key="agents.id", index=True) + action_type: str + payload: dict[str, object] | None = Field(default=None, sa_column=Column(JSON)) + confidence: int + rubric_scores: dict[str, int] | None = Field(default=None, sa_column=Column(JSON)) + status: str = Field(default="pending", index=True) + created_at: datetime = Field(default_factory=datetime.utcnow) + resolved_at: datetime | None = None diff --git a/backend/app/models/board_memory.py b/backend/app/models/board_memory.py new file mode 100644 index 0000000..abf6386 --- /dev/null +++ b/backend/app/models/board_memory.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import UUID, uuid4 + +from sqlalchemy import JSON, Column +from sqlmodel import Field, SQLModel + + +class BoardMemory(SQLModel, table=True): + __tablename__ = "board_memory" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + board_id: UUID = Field(foreign_key="boards.id", index=True) + content: str + tags: list[str] | None = Field(default=None, sa_column=Column(JSON)) + source: str | None = None + created_at: datetime = Field(default_factory=datetime.utcnow) diff --git a/backend/app/models/board_onboarding.py b/backend/app/models/board_onboarding.py new file mode 100644 index 0000000..4c63428 --- /dev/null +++ b/backend/app/models/board_onboarding.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import UUID, uuid4 + +from sqlalchemy import JSON, Column +from sqlmodel import Field, SQLModel + + +class BoardOnboardingSession(SQLModel, table=True): + __tablename__ = "board_onboarding_sessions" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + board_id: UUID = Field(foreign_key="boards.id", index=True) + session_key: str + status: str = Field(default="active", index=True) + messages: list[dict[str, object]] | None = Field(default=None, sa_column=Column(JSON)) + draft_goal: dict[str, object] | None = Field(default=None, sa_column=Column(JSON)) + created_at: datetime = Field(default_factory=datetime.utcnow) + updated_at: datetime = Field(default_factory=datetime.utcnow) diff --git a/backend/app/models/boards.py b/backend/app/models/boards.py index 2e0ad89..a7f8b78 100644 --- a/backend/app/models/boards.py +++ b/backend/app/models/boards.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime from uuid import UUID, uuid4 +from sqlalchemy import JSON, Column from sqlmodel import Field from app.models.tenancy import TenantScoped @@ -15,5 +16,11 @@ class Board(TenantScoped, table=True): name: str slug: str = Field(index=True) gateway_id: UUID | None = Field(default=None, foreign_key="gateways.id", index=True) + board_type: str = Field(default="goal", index=True) + objective: str | None = None + success_metrics: dict[str, object] | None = Field(default=None, sa_column=Column(JSON)) + target_date: datetime | None = None + goal_confirmed: bool = Field(default=False) + goal_source: str | None = None created_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.utcnow) diff --git a/backend/app/models/task_fingerprints.py b/backend/app/models/task_fingerprints.py new file mode 100644 index 0000000..3a1cc89 --- /dev/null +++ b/backend/app/models/task_fingerprints.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import UUID, uuid4 + +from sqlmodel import Field, SQLModel + + +class TaskFingerprint(SQLModel, table=True): + __tablename__ = "task_fingerprints" + + id: UUID = Field(default_factory=uuid4, primary_key=True) + board_id: UUID = Field(foreign_key="boards.id", index=True) + fingerprint_hash: str = Field(index=True) + task_id: UUID = Field(foreign_key="tasks.id") + created_at: datetime = Field(default_factory=datetime.utcnow) diff --git a/backend/app/models/tasks.py b/backend/app/models/tasks.py index 1c35df2..a599e30 100644 --- a/backend/app/models/tasks.py +++ b/backend/app/models/tasks.py @@ -23,6 +23,8 @@ class Task(TenantScoped, table=True): created_by_user_id: UUID | None = Field(default=None, foreign_key="users.id", index=True) assigned_agent_id: UUID | None = Field(default=None, foreign_key="agents.id", index=True) + auto_created: bool = Field(default=False) + auto_reason: str | None = None created_at: datetime = Field(default_factory=datetime.utcnow) updated_at: datetime = Field(default_factory=datetime.utcnow) From c58117c4942f156f095b072cb7374df6d05ad593 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Thu, 5 Feb 2026 14:40:48 +0530 Subject: [PATCH 04/41] feat: add board goal schemas and validation --- backend/app/schemas/__init__.py | 17 +++++++++++++ backend/app/schemas/approvals.py | 30 ++++++++++++++++++++++ backend/app/schemas/board_memory.py | 18 ++++++++++++++ backend/app/schemas/board_onboarding.py | 33 +++++++++++++++++++++++++ backend/app/schemas/boards.py | 20 ++++++++++++++- backend/tests/test_board_schema.py | 20 +++++++++++++++ 6 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 backend/app/schemas/approvals.py create mode 100644 backend/app/schemas/board_memory.py create mode 100644 backend/app/schemas/board_onboarding.py create mode 100644 backend/tests/test_board_schema.py diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py index 9716653..f93b704 100644 --- a/backend/app/schemas/__init__.py +++ b/backend/app/schemas/__init__.py @@ -1,5 +1,13 @@ from app.schemas.activity_events import ActivityEventRead from app.schemas.agents import AgentCreate, AgentRead, AgentUpdate +from app.schemas.approvals import ApprovalCreate, ApprovalRead, ApprovalUpdate +from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead +from app.schemas.board_onboarding import ( + BoardOnboardingAnswer, + BoardOnboardingConfirm, + BoardOnboardingRead, + BoardOnboardingStart, +) from app.schemas.boards import BoardCreate, BoardRead, BoardUpdate from app.schemas.gateways import GatewayCreate, GatewayRead, GatewayUpdate from app.schemas.metrics import DashboardMetrics @@ -11,6 +19,15 @@ __all__ = [ "AgentCreate", "AgentRead", "AgentUpdate", + "ApprovalCreate", + "ApprovalRead", + "ApprovalUpdate", + "BoardMemoryCreate", + "BoardMemoryRead", + "BoardOnboardingAnswer", + "BoardOnboardingConfirm", + "BoardOnboardingRead", + "BoardOnboardingStart", "BoardCreate", "BoardRead", "BoardUpdate", diff --git a/backend/app/schemas/approvals.py b/backend/app/schemas/approvals.py new file mode 100644 index 0000000..2277a58 --- /dev/null +++ b/backend/app/schemas/approvals.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from sqlmodel import SQLModel + + +class ApprovalBase(SQLModel): + action_type: str + payload: dict[str, object] | None = None + confidence: int + rubric_scores: dict[str, int] | None = None + status: str = "pending" + + +class ApprovalCreate(ApprovalBase): + agent_id: UUID | None = None + + +class ApprovalUpdate(SQLModel): + status: str | None = None + + +class ApprovalRead(ApprovalBase): + id: UUID + board_id: UUID + agent_id: UUID | None = None + created_at: datetime + resolved_at: datetime | None = None diff --git a/backend/app/schemas/board_memory.py b/backend/app/schemas/board_memory.py new file mode 100644 index 0000000..97eec0a --- /dev/null +++ b/backend/app/schemas/board_memory.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from sqlmodel import SQLModel + + +class BoardMemoryCreate(SQLModel): + content: str + tags: list[str] | None = None + source: str | None = None + + +class BoardMemoryRead(BoardMemoryCreate): + id: UUID + board_id: UUID + created_at: datetime diff --git a/backend/app/schemas/board_onboarding.py b/backend/app/schemas/board_onboarding.py new file mode 100644 index 0000000..5c28ca6 --- /dev/null +++ b/backend/app/schemas/board_onboarding.py @@ -0,0 +1,33 @@ +from __future__ import annotations + +from datetime import datetime +from uuid import UUID + +from sqlmodel import SQLModel + + +class BoardOnboardingStart(SQLModel): + pass + + +class BoardOnboardingAnswer(SQLModel): + answer: str + other_text: str | None = None + + +class BoardOnboardingConfirm(SQLModel): + board_type: str + objective: str | None = None + success_metrics: dict[str, object] | None = None + target_date: datetime | None = None + + +class BoardOnboardingRead(SQLModel): + id: UUID + board_id: UUID + session_key: str + status: str + messages: list[dict[str, object]] | None = None + draft_goal: dict[str, object] | None = None + created_at: datetime + updated_at: datetime diff --git a/backend/app/schemas/boards.py b/backend/app/schemas/boards.py index 458c38b..79b1259 100644 --- a/backend/app/schemas/boards.py +++ b/backend/app/schemas/boards.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime from uuid import UUID +from pydantic import model_validator from sqlmodel import SQLModel @@ -10,16 +11,33 @@ class BoardBase(SQLModel): name: str slug: str gateway_id: UUID | None = None + board_type: str = "goal" + objective: str | None = None + success_metrics: dict[str, object] | None = None + target_date: datetime | None = None + goal_confirmed: bool = False + goal_source: str | None = None class BoardCreate(BoardBase): - pass + @model_validator(mode="after") + def validate_goal_fields(self): + if self.board_type == "goal": + if not self.objective or not self.success_metrics: + raise ValueError("Goal boards require objective and success_metrics") + return self class BoardUpdate(SQLModel): name: str | None = None slug: str | None = None gateway_id: UUID | None = None + board_type: str | None = None + objective: str | None = None + success_metrics: dict[str, object] | None = None + target_date: datetime | None = None + goal_confirmed: bool | None = None + goal_source: str | None = None class BoardRead(BoardBase): diff --git a/backend/tests/test_board_schema.py b/backend/tests/test_board_schema.py new file mode 100644 index 0000000..db18fa6 --- /dev/null +++ b/backend/tests/test_board_schema.py @@ -0,0 +1,20 @@ +import pytest + +from app.schemas.boards import BoardCreate + + +def test_goal_board_requires_objective_and_metrics(): + with pytest.raises(ValueError): + BoardCreate(name="Goal Board", slug="goal", board_type="goal") + + BoardCreate( + name="Goal Board", + slug="goal", + board_type="goal", + objective="Launch onboarding", + success_metrics={"emails": 3}, + ) + + +def test_general_board_allows_missing_objective(): + BoardCreate(name="General", slug="general", board_type="general") From 5471671dc4dda12f268a45064eb8ac3bfdd1c7d9 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Thu, 5 Feb 2026 14:43:25 +0530 Subject: [PATCH 05/41] feat: add board memory, approvals, onboarding APIs --- backend/app/api/approvals.py | 83 ++++++++++ backend/app/api/board_memory.py | 54 +++++++ backend/app/api/board_onboarding.py | 240 ++++++++++++++++++++++++++++ backend/app/api/boards.py | 8 + backend/app/main.py | 6 + 5 files changed, 391 insertions(+) create mode 100644 backend/app/api/approvals.py create mode 100644 backend/app/api/board_memory.py create mode 100644 backend/app/api/board_onboarding.py diff --git a/backend/app/api/approvals.py b/backend/app/api/approvals.py new file mode 100644 index 0000000..7910fd1 --- /dev/null +++ b/backend/app/api/approvals.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlmodel import Session, col, select + +from app.api.deps import ActorContext, get_board_or_404, require_admin_auth, require_admin_or_agent +from app.db.session import get_session +from app.models.approvals import Approval +from app.schemas.approvals import ApprovalCreate, ApprovalRead, ApprovalUpdate + +router = APIRouter(prefix="/boards/{board_id}/approvals", tags=["approvals"]) + +ALLOWED_STATUSES = {"pending", "approved", "rejected"} + + +@router.get("", response_model=list[ApprovalRead]) +def list_approvals( + status_filter: str | None = Query(default=None, alias="status"), + board=Depends(get_board_or_404), + session: Session = Depends(get_session), + actor: ActorContext = Depends(require_admin_or_agent), +) -> list[Approval]: + if actor.actor_type == "agent" and actor.agent: + if actor.agent.board_id and actor.agent.board_id != board.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + statement = select(Approval).where(col(Approval.board_id) == board.id) + if status_filter: + if status_filter not in ALLOWED_STATUSES: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) + statement = statement.where(col(Approval.status) == status_filter) + statement = statement.order_by(col(Approval.created_at).desc()) + return list(session.exec(statement)) + + +@router.post("", response_model=ApprovalRead) +def create_approval( + payload: ApprovalCreate, + board=Depends(get_board_or_404), + session: Session = Depends(get_session), + actor: ActorContext = Depends(require_admin_or_agent), +) -> Approval: + if actor.actor_type == "agent" and actor.agent: + if actor.agent.board_id and actor.agent.board_id != board.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + approval = Approval( + board_id=board.id, + agent_id=payload.agent_id, + action_type=payload.action_type, + payload=payload.payload, + confidence=payload.confidence, + rubric_scores=payload.rubric_scores, + status=payload.status, + ) + session.add(approval) + session.commit() + session.refresh(approval) + return approval + + +@router.patch("/{approval_id}", response_model=ApprovalRead) +def update_approval( + approval_id: str, + payload: ApprovalUpdate, + board=Depends(get_board_or_404), + session: Session = Depends(get_session), + auth=Depends(require_admin_auth), +) -> Approval: + approval = session.get(Approval, approval_id) + if approval is None or approval.board_id != board.id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + updates = payload.model_dump(exclude_unset=True) + if "status" in updates: + if updates["status"] not in ALLOWED_STATUSES: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) + approval.status = updates["status"] + if approval.status != "pending": + approval.resolved_at = datetime.utcnow() + session.add(approval) + session.commit() + session.refresh(approval) + return approval diff --git a/backend/app/api/board_memory.py b/backend/app/api/board_memory.py new file mode 100644 index 0000000..e9b1e98 --- /dev/null +++ b/backend/app/api/board_memory.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlmodel import Session, col, select + +from app.api.deps import ActorContext, get_board_or_404, require_admin_or_agent +from app.db.session import get_session +from app.models.board_memory import BoardMemory +from app.schemas.board_memory import BoardMemoryCreate, BoardMemoryRead + +router = APIRouter(prefix="/boards/{board_id}/memory", tags=["board-memory"]) + + +@router.get("", response_model=list[BoardMemoryRead]) +def list_board_memory( + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), + board=Depends(get_board_or_404), + session: Session = Depends(get_session), + actor: ActorContext = Depends(require_admin_or_agent), +) -> list[BoardMemory]: + if actor.actor_type == "agent" and actor.agent: + if actor.agent.board_id and actor.agent.board_id != board.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + statement = ( + select(BoardMemory) + .where(col(BoardMemory.board_id) == board.id) + .order_by(col(BoardMemory.created_at).desc()) + .offset(offset) + .limit(limit) + ) + return list(session.exec(statement)) + + +@router.post("", response_model=BoardMemoryRead) +def create_board_memory( + payload: BoardMemoryCreate, + board=Depends(get_board_or_404), + session: Session = Depends(get_session), + actor: ActorContext = Depends(require_admin_or_agent), +) -> BoardMemory: + if actor.actor_type == "agent" and actor.agent: + if actor.agent.board_id and actor.agent.board_id != board.id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN) + memory = BoardMemory( + board_id=board.id, + content=payload.content, + tags=payload.tags, + source=payload.source, + ) + session.add(memory) + session.commit() + session.refresh(memory) + return memory diff --git a/backend/app/api/board_onboarding.py b/backend/app/api/board_onboarding.py new file mode 100644 index 0000000..c7a01ea --- /dev/null +++ b/backend/app/api/board_onboarding.py @@ -0,0 +1,240 @@ +from __future__ import annotations + +import json +import re +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlmodel import Session, select + +from app.api.deps import get_board_or_404, require_admin_auth +from app.core.auth import AuthContext +from app.db.session import get_session +from app.integrations.openclaw_gateway import GatewayConfig as GatewayClientConfig +from app.integrations.openclaw_gateway import OpenClawGatewayError, ensure_session, get_chat_history, send_message +from app.models.board_onboarding import BoardOnboardingSession +from app.models.boards import Board +from app.models.gateways import Gateway +from app.schemas.board_onboarding import ( + BoardOnboardingAnswer, + BoardOnboardingConfirm, + BoardOnboardingRead, + BoardOnboardingStart, +) +from app.schemas.boards import BoardRead + +router = APIRouter(prefix="/boards/{board_id}/onboarding", tags=["board-onboarding"]) + +SESSION_PREFIX = "agent:main:onboarding:" + + +def _extract_json(text: str) -> dict[str, object] | None: + try: + return json.loads(text.strip()) + except Exception: + pass + match = re.search(r"```(?:json)?\s*([\s\S]*?)```", text) + if match: + try: + return json.loads(match.group(1).strip()) + except Exception: + pass + first = text.find("{") + last = text.rfind("}") + if first != -1 and last > first: + try: + return json.loads(text[first : last + 1]) + except Exception: + return None + return None + + +def _extract_text(content: object) -> str | None: + if isinstance(content, str): + return content + if isinstance(content, list): + for entry in content: + if isinstance(entry, dict) and entry.get("type") == "text": + text = entry.get("text") + if isinstance(text, str): + return text + if isinstance(content, dict): + text = content.get("text") + if isinstance(text, str): + return text + return None + + +def _get_assistant_messages(history: object) -> list[str]: + messages: list[str] = [] + if isinstance(history, dict): + history = history.get("messages") + if not isinstance(history, list): + return messages + for msg in history: + if not isinstance(msg, dict): + continue + if msg.get("role") != "assistant": + continue + text = _extract_text(msg.get("content")) + if text: + messages.append(text) + return messages + + +def _gateway_config(session: Session, board: Board) -> GatewayClientConfig: + if not board.gateway_id: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) + gateway = session.get(Gateway, board.gateway_id) + if gateway is None or not gateway.url: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY) + return GatewayClientConfig(url=gateway.url, token=gateway.token) + + +def _session_key(board: Board) -> str: + return f"{SESSION_PREFIX}{board.id}" + + +@router.get("", response_model=BoardOnboardingRead) +def get_onboarding( + board: Board = Depends(get_board_or_404), + session: Session = Depends(get_session), + auth: AuthContext = Depends(require_admin_auth), +) -> BoardOnboardingSession: + onboarding = session.exec( + select(BoardOnboardingSession) + .where(BoardOnboardingSession.board_id == board.id) + .order_by(BoardOnboardingSession.created_at.desc()) + ).first() + if onboarding is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + return onboarding + + +@router.post("/start", response_model=BoardOnboardingRead) +async def start_onboarding( + payload: BoardOnboardingStart, + board: Board = Depends(get_board_or_404), + session: Session = Depends(get_session), + auth: AuthContext = Depends(require_admin_auth), +) -> BoardOnboardingSession: + onboarding = session.exec( + select(BoardOnboardingSession) + .where(BoardOnboardingSession.board_id == board.id) + .where(BoardOnboardingSession.status == "active") + ).first() + if onboarding: + return onboarding + + config = _gateway_config(session, board) + session_key = _session_key(board) + prompt = ( + "BOARD ONBOARDING REQUEST\n\n" + f"Board Name: {board.name}\n" + "You are the lead agent. Ask the user 3-6 focused questions to clarify their goal.\n" + "Return questions as JSON: {\"question\": \"...\", \"options\": [...]}.\n" + "When you have enough info, return JSON: {\"status\": \"complete\", \"board_type\": \"goal\"|\"general\", " + "\"objective\": \"...\", \"success_metrics\": {...}, \"target_date\": \"YYYY-MM-DD\"}." + ) + + try: + await ensure_session(session_key, config=config, label=f"Onboarding {board.name}") + await send_message(prompt, session_key=session_key, config=config, deliver=True) + except OpenClawGatewayError as exc: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc + + onboarding = BoardOnboardingSession( + board_id=board.id, + session_key=session_key, + status="active", + messages=[{"role": "user", "content": prompt, "timestamp": datetime.utcnow().isoformat()}], + ) + session.add(onboarding) + session.commit() + session.refresh(onboarding) + return onboarding + + +@router.post("/answer", response_model=BoardOnboardingRead) +async def answer_onboarding( + payload: BoardOnboardingAnswer, + board: Board = Depends(get_board_or_404), + session: Session = Depends(get_session), + auth: AuthContext = Depends(require_admin_auth), +) -> BoardOnboardingSession: + onboarding = session.exec( + select(BoardOnboardingSession) + .where(BoardOnboardingSession.board_id == board.id) + .order_by(BoardOnboardingSession.created_at.desc()) + ).first() + if onboarding is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + + config = _gateway_config(session, board) + answer_text = payload.answer + if payload.other_text: + answer_text = f"{payload.answer}: {payload.other_text}" + + messages = onboarding.messages or [] + messages.append( + {"role": "user", "content": answer_text, "timestamp": datetime.utcnow().isoformat()} + ) + + try: + await ensure_session(onboarding.session_key, config=config, label=f"Onboarding {board.name}") + await send_message( + answer_text, session_key=onboarding.session_key, config=config, deliver=True + ) + history = await get_chat_history(onboarding.session_key, config=config) + except OpenClawGatewayError as exc: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc + + assistant_messages = _get_assistant_messages(history) + if assistant_messages: + last = assistant_messages[-1] + messages.append( + {"role": "assistant", "content": last, "timestamp": datetime.utcnow().isoformat()} + ) + parsed = _extract_json(last) + if parsed and parsed.get("status") == "complete": + onboarding.draft_goal = parsed + onboarding.status = "completed" + + onboarding.messages = messages + onboarding.updated_at = datetime.utcnow() + session.add(onboarding) + session.commit() + session.refresh(onboarding) + return onboarding + + +@router.post("/confirm", response_model=BoardRead) +def confirm_onboarding( + payload: BoardOnboardingConfirm, + board: Board = Depends(get_board_or_404), + session: Session = Depends(get_session), + auth: AuthContext = Depends(require_admin_auth), +) -> Board: + onboarding = session.exec( + select(BoardOnboardingSession) + .where(BoardOnboardingSession.board_id == board.id) + .order_by(BoardOnboardingSession.created_at.desc()) + ).first() + if onboarding is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) + + board.board_type = payload.board_type + board.objective = payload.objective + board.success_metrics = payload.success_metrics + board.target_date = payload.target_date + board.goal_confirmed = True + board.goal_source = "lead_agent_onboarding" + + onboarding.status = "confirmed" + onboarding.updated_at = datetime.utcnow() + + session.add(board) + session.add(onboarding) + session.commit() + session.refresh(board) + return board diff --git a/backend/app/api/boards.py b/backend/app/api/boards.py index 3002b64..7edb986 100644 --- a/backend/app/api/boards.py +++ b/backend/app/api/boards.py @@ -161,6 +161,14 @@ def update_board( ) for key, value in updates.items(): setattr(board, key, value) + if updates.get("board_type") == "goal": + objective = updates.get("objective") or board.objective + metrics = updates.get("success_metrics") or board.success_metrics + if not objective or not metrics: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Goal boards require objective and success_metrics", + ) if not board.gateway_id: raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, diff --git a/backend/app/main.py b/backend/app/main.py index 2bda38c..7c5a506 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,7 +5,10 @@ from fastapi.middleware.cors import CORSMiddleware from app.api.activity import router as activity_router from app.api.agents import router as agents_router +from app.api.approvals import router as approvals_router from app.api.auth import router as auth_router +from app.api.board_memory import router as board_memory_router +from app.api.board_onboarding import router as board_onboarding_router from app.api.boards import router as boards_router from app.api.gateway import router as gateway_router from app.api.gateways import router as gateways_router @@ -59,6 +62,9 @@ api_v1.include_router(gateway_router) api_v1.include_router(gateways_router) api_v1.include_router(metrics_router) api_v1.include_router(boards_router) +api_v1.include_router(board_memory_router) +api_v1.include_router(board_onboarding_router) +api_v1.include_router(approvals_router) api_v1.include_router(tasks_router) api_v1.include_router(users_router) app.include_router(api_v1) From 56b898130bbd33cf5ffe6049f26a7cef45c925b3 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Thu, 5 Feb 2026 14:44:50 +0530 Subject: [PATCH 06/41] feat: add board goal context to agent templates --- backend/app/services/agent_provisioning.py | 7 +++++++ templates/AGENTS.md | 1 + templates/HEARTBEAT.md | 20 ++++++++++++++++++++ templates/USER.md | 8 ++++++++ 4 files changed, 36 insertions(+) diff --git a/backend/app/services/agent_provisioning.py b/backend/app/services/agent_provisioning.py index 2f46c78..191dd0e 100644 --- a/backend/app/services/agent_provisioning.py +++ b/backend/app/services/agent_provisioning.py @@ -129,6 +129,13 @@ def _build_context( "agent_name": agent.name, "agent_id": agent_id, "board_id": str(board.id), + "board_name": board.name, + "board_type": board.board_type, + "board_objective": board.objective or "", + "board_success_metrics": json.dumps(board.success_metrics or {}), + "board_target_date": board.target_date.isoformat() if board.target_date else "", + "board_goal_confirmed": str(board.goal_confirmed).lower(), + "is_board_lead": str(agent.is_board_lead).lower(), "session_key": session_key, "workspace_path": workspace_path, "base_url": base_url, diff --git a/templates/AGENTS.md b/templates/AGENTS.md index 7aa9723..d6d0aa6 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -29,6 +29,7 @@ Write things down. Do not rely on short-term context. ## Heartbeats - HEARTBEAT.md defines what to do on each heartbeat. +- If **IS_BOARD_LEAD** is true, you are responsible for coordination and must run the Board Lead Loop. ## Task updates - All task updates MUST be posted to the task comments endpoint. diff --git a/templates/HEARTBEAT.md b/templates/HEARTBEAT.md index 5491e93..4eb45d4 100644 --- a/templates/HEARTBEAT.md +++ b/templates/HEARTBEAT.md @@ -30,6 +30,26 @@ If any required input is missing, stop and request a provisioning update. - GET $BASE_URL/api/v1/boards/{BOARD_ID}/tasks must succeed. - If any check fails, stop and retry next heartbeat. +## Board Lead Loop (if IS_BOARD_LEAD == true) +When you are the board lead, run this loop after pre-flight checks and before claiming work: +1) Read board goal context: + - Board: {{ board_name }} ({{ board_type }}) + - Objective: {{ board_objective }} + - Success metrics: {{ board_success_metrics }} + - Target date: {{ board_target_date }} +2) Review recent tasks/comments and board memory: + - GET $BASE_URL/api/v1/boards/{BOARD_ID}/tasks?limit=50 + - GET $BASE_URL/api/v1/boards/{BOARD_ID}/memory?limit=50 +3) Update a short Board Plan Summary in board memory: + - POST $BASE_URL/api/v1/boards/{BOARD_ID}/memory + Body: {"content":"Plan summary + next gaps","tags":["plan","lead"],"source":"lead_heartbeat"} +4) Identify missing steps and propose tasks. +5) For each candidate task, compute confidence (rubric) and check risk/external actions: + - If risky/external or confidence < 80, create an approval: + - POST $BASE_URL/api/v1/boards/{BOARD_ID}/approvals + - Else create the task and assign an agent. +6) Post a brief status update in board memory (1-3 bullets). + ## Heartbeat checklist (run in order) 1) Check in: ```bash diff --git a/templates/USER.md b/templates/USER.md index 768f1a1..1048301 100644 --- a/templates/USER.md +++ b/templates/USER.md @@ -11,6 +11,14 @@ {{ user_context }} +## Board Goal + +- **Board name:** {{ board_name }} +- **Board type:** {{ board_type }} +- **Objective:** {{ board_objective }} +- **Success metrics:** {{ board_success_metrics }} +- **Target date:** {{ board_target_date }} + --- The more you know, the better you can help. But remember -- you're learning about a person, not building a dossier. Respect the difference. From 0a1f4392e878db0659561816d214022c1f3f9119 Mon Sep 17 00:00:00 2001 From: Abhimanyu Saharan Date: Thu, 5 Feb 2026 14:59:33 +0530 Subject: [PATCH 07/41] feat: add board goal editor and onboarding chat --- .../src/app/boards/[boardId]/edit/page.tsx | 105 +++++++ frontend/src/app/boards/[boardId]/page.tsx | 69 ++++- frontend/src/components/BoardGoalPanel.tsx | 145 ++++++++++ .../src/components/BoardOnboardingChat.tsx | 266 ++++++++++++++++++ 4 files changed, 581 insertions(+), 4 deletions(-) create mode 100644 frontend/src/components/BoardGoalPanel.tsx create mode 100644 frontend/src/components/BoardOnboardingChat.tsx diff --git a/frontend/src/app/boards/[boardId]/edit/page.tsx b/frontend/src/app/boards/[boardId]/edit/page.tsx index ee7e63e..7f1f8a8 100644 --- a/frontend/src/app/boards/[boardId]/edit/page.tsx +++ b/frontend/src/app/boards/[boardId]/edit/page.tsx @@ -9,7 +9,15 @@ import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { DashboardShell } from "@/components/templates/DashboardShell"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import SearchableSelect from "@/components/ui/searchable-select"; +import { Textarea } from "@/components/ui/textarea"; import { getApiBaseUrl } from "@/lib/api-base"; const apiBase = getApiBaseUrl(); @@ -19,6 +27,10 @@ type Board = { name: string; slug: string; gateway_id?: string | null; + board_type?: string; + objective?: string | null; + success_metrics?: Record | null; + target_date?: string | null; }; type Gateway = { @@ -36,6 +48,13 @@ const slugify = (value: string) => .replace(/[^a-z0-9]+/g, "-") .replace(/(^-|-$)/g, "") || "board"; +const toDateInput = (value?: string | null) => { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + return date.toISOString().slice(0, 10); +}; + export default function EditBoardPage() { const { getToken, isSignedIn } = useAuth(); const router = useRouter(); @@ -47,9 +66,14 @@ export default function EditBoardPage() { const [name, setName] = useState(""); const [gateways, setGateways] = useState([]); const [gatewayId, setGatewayId] = useState(""); + const [boardType, setBoardType] = useState("goal"); + const [objective, setObjective] = useState(""); + const [successMetrics, setSuccessMetrics] = useState(""); + const [targetDate, setTargetDate] = useState(""); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + const [metricsError, setMetricsError] = useState(null); const isFormReady = Boolean(name.trim() && gatewayId); @@ -88,6 +112,12 @@ export default function EditBoardPage() { if (data.gateway_id) { setGatewayId(data.gateway_id); } + setBoardType(data.board_type ?? "goal"); + setObjective(data.objective ?? ""); + setSuccessMetrics( + data.success_metrics ? JSON.stringify(data.success_metrics, null, 2) : "" + ); + setTargetDate(toDateInput(data.target_date)); } catch (err) { setError(err instanceof Error ? err.message : "Something went wrong."); } @@ -126,8 +156,19 @@ export default function EditBoardPage() { setIsLoading(true); setError(null); + setMetricsError(null); try { const token = await getToken(); + let parsedMetrics: Record | null = null; + if (successMetrics.trim()) { + try { + parsedMetrics = JSON.parse(successMetrics) as Record; + } catch { + setMetricsError("Success metrics must be valid JSON."); + setIsLoading(false); + return; + } + } const response = await fetch(`${apiBase}/api/v1/boards/${boardId}`, { method: "PATCH", @@ -139,6 +180,10 @@ export default function EditBoardPage() { name: name.trim(), slug: slugify(name.trim()), gateway_id: gatewayId || null, + board_type: boardType, + objective: objective.trim() || null, + success_metrics: parsedMetrics, + target_date: targetDate ? new Date(targetDate).toISOString() : null, }), }); if (!response.ok) { @@ -219,6 +264,66 @@ export default function EditBoardPage() { +
+
+ + +
+
+ + setTargetDate(event.target.value)} + disabled={isLoading} + /> +
+
+ +
+ +