feat(tasks): add needs_human intervention flag (CUST-WP-0009)

- Migration b4c5d6e7f8a9: adds needs_human (bool) + intervention_note (text) to tasks
- API: needs_human filter on GET /tasks/; 422 if flagged without note
- 3 MCP tools: flag_for_human, clear_human_flag, list_human_interventions
- Dashboard: interventions.md with amber cards and "Mark done" button
- Policy router + workstream DoD policy (workstream-dod.md)
- Workstream lifecycle docs page + workplan CUST-WP-0010
- CLAUDE.md: add step 4 (run fix-consistency after workplan writes)
- consistency_check.py: promote C-11 unlinked tasks from INFO to WARN

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 19:44:14 +01:00
parent 5c1b7e7e1d
commit c792ab0bc0
16 changed files with 794 additions and 55 deletions

View File

@@ -5,7 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
from api.database import engine
from api.routers import decisions, extension_points, progress, state, tasks, technical_debt, topics, workstreams, workstream_dependencies
from api.routers import domains, repos, contributions, sbom
from api.routers import domains, repos, contributions, sbom, policy
@asynccontextmanager
@@ -24,7 +24,7 @@ app = FastAPI(
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"],
allow_methods=["GET", "POST", "PATCH", "DELETE"],
allow_methods=["GET", "POST", "PATCH", "DELETE", "PUT"],
allow_headers=["Content-Type"],
)
@@ -41,6 +41,7 @@ app.include_router(progress.router)
app.include_router(contributions.router)
app.include_router(sbom.router)
app.include_router(state.router)
app.include_router(policy.router)
@app.get("/", include_in_schema=False)

View File

@@ -2,7 +2,7 @@ import enum
import uuid
from datetime import date
from sqlalchemy import Date, Enum, ForeignKey, String, Text
from sqlalchemy import Boolean, Date, Enum, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -44,6 +44,8 @@ class Task(Base, TimestampMixin):
assignee: Mapped[str | None] = mapped_column(String(100), nullable=True)
due_date: Mapped[date | None] = mapped_column(Date, nullable=True)
blocking_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
needs_human: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False, index=True)
intervention_note: Mapped[str | None] = mapped_column(Text, nullable=True)
parent_task_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
)

41
api/routers/policy.py Normal file
View File

@@ -0,0 +1,41 @@
import re
from pathlib import Path
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
POLICY_DIR = Path(__file__).parent.parent.parent / "policies"
_VALID_NAME = re.compile(r"^[a-z0-9][a-z0-9-]{0,63}$")
router = APIRouter(prefix="/policy", tags=["policy"])
class PolicyRead(BaseModel):
name: str
content: str
class PolicyUpdate(BaseModel):
content: str
def _policy_path(name: str) -> Path:
if not _VALID_NAME.match(name):
raise HTTPException(status_code=400, detail="Invalid policy name")
path = POLICY_DIR / f"{name}.md"
if not path.exists():
raise HTTPException(status_code=404, detail=f"Policy '{name}' not found")
return path
@router.get("/{name}", response_model=PolicyRead)
def get_policy(name: str) -> PolicyRead:
path = _policy_path(name)
return PolicyRead(name=name, content=path.read_text())
@router.put("/{name}", response_model=PolicyRead)
def update_policy(name: str, body: PolicyUpdate) -> PolicyRead:
path = _policy_path(name)
path.write_text(body.content)
return PolicyRead(name=name, content=body.content)

View File

@@ -1,6 +1,6 @@
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -16,6 +16,7 @@ async def list_tasks(
workstream_id: uuid.UUID | None = None,
status: TaskStatus | None = None,
assignee: str | None = None,
needs_human: bool | None = Query(None),
session: AsyncSession = Depends(get_session),
) -> list[Task]:
q = select(Task)
@@ -25,6 +26,8 @@ async def list_tasks(
q = q.where(Task.status == status)
if assignee:
q = q.where(Task.assignee == assignee)
if needs_human is not None:
q = q.where(Task.needs_human == needs_human)
q = q.order_by(Task.created_at)
result = await session.execute(q)
return list(result.scalars().all())

View File

@@ -1,5 +1,6 @@
import uuid
from datetime import date, datetime
from typing import Self
from pydantic import BaseModel, ConfigDict, model_validator
@@ -15,8 +16,16 @@ class TaskCreate(BaseModel):
assignee: str | None = None
due_date: date | None = None
blocking_reason: str | None = None
needs_human: bool = False
intervention_note: str | None = None
parent_task_id: uuid.UUID | None = None
@model_validator(mode="after")
def intervention_note_required_when_flagged(self) -> Self:
if self.needs_human and not self.intervention_note:
raise ValueError("intervention_note is required when needs_human is True")
return self
class TaskUpdate(BaseModel):
title: str | None = None
@@ -26,14 +35,22 @@ class TaskUpdate(BaseModel):
assignee: str | None = None
due_date: date | None = None
blocking_reason: str | None = None
needs_human: bool | None = None
intervention_note: str | None = None
parent_task_id: uuid.UUID | None = None
@model_validator(mode="after")
def blocking_reason_required_when_blocked(self) -> "TaskUpdate":
def blocking_reason_required_when_blocked(self) -> Self:
if self.status == TaskStatus.blocked and not self.blocking_reason:
raise ValueError("blocking_reason is required when status is blocked")
return self
@model_validator(mode="after")
def intervention_note_required_when_flagged(self) -> Self:
if self.needs_human and not self.intervention_note:
raise ValueError("intervention_note is required when needs_human is True")
return self
class TaskRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -46,6 +63,8 @@ class TaskRead(BaseModel):
assignee: str | None = None
due_date: date | None = None
blocking_reason: str | None = None
needs_human: bool
intervention_note: str | None = None
parent_task_id: uuid.UUID | None = None
created_at: datetime
updated_at: datetime