feat(classification-spine): implement STATE-WP-0065 repo-anchored model

Replace the ad-hoc coordination-domain spine with the Repo Classification
Standard: 14 market domains, classification columns on managed_repos, and
workplans anchored by repo_id (topic_id optional).

- Add Alembic migration d8e9f0a1b2c3 with data backfill and workstream→workplan rename
- Add api/classification.py validation and register-from-classification tooling
- Expose workplan-first REST/MCP surface with legacy workstream aliases
- Add C-24 consistency rule and legacy domain frontmatter mapping
- Update dashboard repos page with category/capability/stake filters
- Update orientation docs; mark STATE-WP-0065 finished
This commit is contained in:
2026-06-22 13:52:13 +02:00
parent 279be4ffbd
commit 0949d4c0d8
84 changed files with 4494 additions and 1111 deletions

View File

@@ -9,7 +9,7 @@ from api.database import get_session
from api.models.progress_event import ProgressEvent
from api.models.task import Task, TaskStatus
from api.models.token_event import TokenEvent
from api.models.workstream import Workstream
from api.models.workplan import Workplan
from api.schemas.task import (
TaskCountRead,
TaskCreate,
@@ -26,6 +26,7 @@ router = APIRouter(prefix="/tasks", tags=["tasks"])
@router.get("/", response_model=list[TaskRead])
async def list_tasks(
workplan_id: uuid.UUID | None = None,
workstream_id: uuid.UUID | None = None,
status: str | None = None,
assignee: str | None = None,
@@ -37,8 +38,9 @@ async def list_tasks(
session: AsyncSession = Depends(get_session),
) -> list[Task]:
q = select(Task)
if workstream_id:
q = q.where(Task.workstream_id == workstream_id)
scope_id = workplan_id or workstream_id
if scope_id:
q = q.where(Task.workplan_id == scope_id)
if status:
q = q.where(Task.status == TaskStatus(normalize_task_status(status)))
if assignee:
@@ -60,18 +62,20 @@ async def list_tasks(
@router.get("/counts", response_model=list[TaskCountRead])
async def count_tasks(
workplan_id: uuid.UUID | None = None,
workstream_id: uuid.UUID | None = None,
status: str | None = None,
session: AsyncSession = Depends(get_session),
) -> list[TaskCountRead]:
q = select(Task.workstream_id, Task.status, func.count()).group_by(Task.workstream_id, Task.status)
if workstream_id:
q = q.where(Task.workstream_id == workstream_id)
q = select(Task.workplan_id, Task.status, func.count()).group_by(Task.workplan_id, Task.status)
scope_id = workplan_id or workstream_id
if scope_id:
q = q.where(Task.workplan_id == scope_id)
if status:
q = q.where(Task.status == TaskStatus(normalize_task_status(status)))
rows = await session.execute(q)
return [
TaskCountRead(workstream_id=ws_id, status=task_status, count=count)
TaskCountRead(workplan_id=ws_id, status=task_status, count=count)
for ws_id, task_status, count in rows
]
@@ -84,7 +88,7 @@ async def create_task(
task = Task(**body.model_dump())
session.add(task)
if status_value(task.status) == "progress":
ws = await session.get(Workstream, task.workstream_id)
ws = await session.get(Workplan, task.workplan_id)
transition_task_status(
task,
task.status,
@@ -137,7 +141,7 @@ async def bulk_status_sync(
target_status = status_value(update.status)
if update.blocking_reason is not None:
task.blocking_reason = update.blocking_reason
ws = await session.get(Workstream, task.workstream_id)
ws = await session.get(Workplan, task.workplan_id)
transition_task_status(
task,
update.status,
@@ -146,7 +150,7 @@ async def bulk_status_sync(
)
event = ProgressEvent(
task_id=task.id,
workstream_id=task.workstream_id,
workplan_id=task.workplan_id,
event_type="task_status_changed",
summary=f"Task status -> {target_status}: {task.title}",
author=author,
@@ -218,7 +222,7 @@ async def update_task(
for field, value in update_data.items():
setattr(task, field, value)
if new_status is not None:
ws = await session.get(Workstream, task.workstream_id)
ws = await session.get(Workplan, task.workplan_id)
transition_task_status(
task,
status_update,
@@ -247,7 +251,7 @@ async def update_task(
elif "workplan_tokens_in" in token_data and "workplan_tokens_out" in token_data:
# Tier 2: prorate workplan total across task count
count_result = await session.execute(
select(func.count(Task.id)).where(Task.workstream_id == task.workstream_id)
select(func.count(Task.id)).where(Task.workplan_id == task.workplan_id)
)
task_count = max(count_result.scalar() or 1, 1)
tin = token_data["workplan_tokens_in"] // task_count
@@ -273,12 +277,12 @@ async def update_task(
raw_metadata = {"estimation_method": "fixed_task_done_fallback"}
# Resolve repo_id via workstream
ws = await session.get(Workstream, task.workstream_id)
ws = await session.get(Workplan, task.workplan_id)
repo_id = ws.repo_id if ws else None
event = TokenEvent(
task_id=task_id,
workstream_id=task.workstream_id,
workplan_id=task.workplan_id,
repo_id=repo_id,
tokens_in=tin,
tokens_out=tout,