From 38835e9e79d8ae71a5a1bb079965a775c8d3cba5 Mon Sep 17 00:00:00 2001 From: tegwick Date: Tue, 26 May 2026 01:32:50 +0200 Subject: [PATCH] feat(tasks): adopt canonical task statuses --- AGENTS.md | 10 +- README.md | 10 +- api/models/task.py | 6 +- api/routers/capability_requests.py | 2 +- api/routers/execution.py | 2 +- api/routers/reconciliation.py | 3 +- api/routers/repos.py | 2 +- api/routers/state.py | 29 +++--- api/routers/tasks.py | 9 +- api/schemas/state.py | 9 +- api/schemas/task.py | 24 +++-- api/schemas/workstream.py | 5 +- api/services/lifecycle.py | 15 +-- api/services/recently_on_scope.py | 2 +- api/services/reconciliation.py | 9 +- api/task_status.py | 84 +++++++++++++++++ dashboard/src/components/entity-modal.js | 7 +- dashboard/src/components/field-help.js | 4 +- dashboard/src/components/status-control.js | 6 +- dashboard/src/components/workplan-status.js | 2 +- dashboard/src/data/summary.json.py | 3 +- dashboard/src/docs/capabilities.md | 4 +- dashboard/src/docs/dashboard.md | 2 +- .../src/docs/inter-repo-communication.md | 4 +- dashboard/src/docs/interventions.md | 6 +- dashboard/src/docs/overview.md | 4 +- dashboard/src/docs/progress-log.md | 2 +- dashboard/src/docs/ralph-workplan.md | 6 +- dashboard/src/docs/tasks.md | 28 +++--- dashboard/src/docs/todo.md | 2 +- dashboard/src/index.md | 42 ++++----- dashboard/src/interventions.md | 10 +- dashboard/src/tasks.md | 46 ++++----- dashboard/src/todo.md | 12 +-- dashboard/src/workstreams/[id].md | 8 +- dashboard/test/status-control.test.mjs | 16 ++-- docs/activity-core-delegation.md | 2 +- docs/cron-migration.md | 2 +- docs/task-flow-engine-spec.md | 12 +-- docs/task-state-attached-repo-impact.md | 93 +++++++++++++++++++ docs/task-state-canon-migration.md | 90 ++++++++++++++++++ docs/workplan-state-model-proposal.md | 4 +- flows/task.yaml | 22 ++--- flows/workstream.yaml | 4 +- mcp_server/TOOLS.md | 10 +- mcp_server/server.py | 18 ++-- migrations/versions/0001_initial_schema.py | 2 +- .../a4v5w6x7y8z9_task_status_canon.py | 57 ++++++++++++ scripts/cleanup_stale_tasks.py | 17 ++-- scripts/consistency_check.py | 85 ++++++++++------- scripts/project_rules/agents-codex.template | 10 +- .../project_rules/session-protocol.template | 2 +- scripts/validate_repo_adr.py | 13 ++- tests/test_capability_requests.py | 6 +- tests/test_consistency_check.py | 18 ++-- tests/test_lifecycle.py | 28 +++--- tests/test_reconciliation.py | 16 ++-- tests/test_routers_core.py | 36 +++---- tests/test_task_flow_engine.py | 16 ++-- tests/test_token_passthrough.py | 2 +- ...ATE-WP-0052-task-state-canon-adaptation.md | 34 ++++--- 61 files changed, 692 insertions(+), 342 deletions(-) create mode 100644 api/task_status.py create mode 100644 docs/task-state-attached-repo-impact.md create mode 100644 docs/task-state-canon-migration.md create mode 100644 migrations/versions/a4v5w6x7y8z9_task_status_canon.py diff --git a/AGENTS.md b/AGENTS.md index 43c0374..7696e29 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,8 +63,8 @@ Omit `workstream_id` / `task_id` when not applicable. ```bash curl -s -X PATCH "http://127.0.0.1:8000/tasks/" \ -H "Content-Type: application/json" \ - -d '{"status": "in_progress"}' -# values: todo | in_progress | done | blocked + -d '{"status": "progress"}' +# values: wait | todo | progress | done | cancel ``` ### Flag a task for human review @@ -83,7 +83,7 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/" \ 1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe) 2. Check inbox: `GET /messages/?to_agent=state-hub&unread_only=true`; mark read 3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks -4. Check blocked tasks: `GET /tasks/?needs_human=true` +4. Check human-needed tasks: `GET /tasks/?needs_human=true` **During work:** - Update task statuses in workplan files as tasks progress @@ -146,7 +146,7 @@ derived health labels, not frontmatter statuses. ` ` `task id: STATE-WP-NNNN-T01 -status: todo | in_progress | done | blocked +status: wait | todo | progress | done | cancel priority: high | medium | low state_hub_task_id: "" # written by fix-consistency — do not edit ` ` ` @@ -154,7 +154,7 @@ state_hub_task_id: "" # written by fix-consistency — do not edit Task description text. ``` -Status progression: `todo` → `in_progress` → `done` (or `blocked`) +Status progression: `todo` → `progress` → `done`; use `wait` for waiting/blocked work and `cancel` for stopped work. To create a new workplan: 1. Write the file following the format above diff --git a/README.md b/README.md index 15e9069..e795406 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ decisions (FK: topic_id, workstream_id — at least one required) |------|--------| | `topic_status` | `active` · `paused` · `archived` | | `workstream_status` | `proposed` · `ready` · `active` · `blocked` · `backlog` · `finished` · `archived` | -| `task_status` | `todo` · `in_progress` · `blocked` · `done` · `cancelled` | +| `task_status` | `wait` · `todo` · `progress` · `done` · `cancel` | | `task_priority` | `low` · `medium` · `high` · `critical` | | `decision_type` | `made` · `pending` | | `decision_status` | `open` · `resolved` · `escalated` · `superseded` | @@ -150,7 +150,7 @@ decisions (FK: topic_id, workstream_id — at least one required) ### Governance constraints encoded in schema -- No hard DELETE endpoints — only soft: `archived`, `cancelled`, `superseded` +- No hard DELETE endpoints — only soft: `archived`, `cancel`, `superseded` - `progress_events` has no `updated_at` and no DELETE endpoint (append-only per constitution §5) - `decisions` with financial/legal keywords + `pending` type → auto-set `escalation_note` (§4) @@ -170,12 +170,12 @@ Returns a full snapshot in one call — used by both the MCP server and dashboar "totals": { "topics": { "active": 6, "paused": 0, "archived": 0, "total": 6 }, "workstreams": { "ready": 1, "active": 1, "blocked": 0, "finished": 1, "total": 3 }, - "tasks": { "todo": 9, "in_progress": 0, "blocked": 0, "done": 11, "total": 20 }, + "tasks": { "wait": 0, "todo": 9, "progress": 0, "done": 11, "cancel": 0, "total": 20 }, "decisions": { "open": 1, "resolved": 0, "escalated": 0, "total": 1 } }, "topics": [...], // topics with nested workstream stubs "blocking_decisions": [...], // pending decisions only - "blocked_tasks": [...], + "waiting_tasks": [...], "recent_progress": [...], // last 20 events "open_workstreams": [...] } @@ -187,7 +187,7 @@ Returns a full snapshot in one call — used by both the MCP server and dashboar |--------|-----------| | `/topics` | CRUD (soft-delete: `archived`) | | `/workstreams` | CRUD (soft-delete: `archived`) | -| `/tasks` | CRUD (soft-delete: `cancelled`); `PATCH` updates status | +| `/tasks` | CRUD (soft-delete: `cancel`); `PATCH` updates status | | `/decisions` | CRUD (soft-delete: `superseded`); auto-escalation | | `/progress` | `GET` list + `POST` append — no DELETE | | `/state/summary` | Full snapshot | diff --git a/api/models/task.py b/api/models/task.py index 2bd83da..4295e3e 100644 --- a/api/models/task.py +++ b/api/models/task.py @@ -10,11 +10,11 @@ from api.models.base import Base, TimestampMixin, new_uuid class TaskStatus(str, enum.Enum): + wait = "wait" todo = "todo" - in_progress = "in_progress" - blocked = "blocked" + progress = "progress" done = "done" - cancelled = "cancelled" + cancel = "cancel" class TaskPriority(str, enum.Enum): diff --git a/api/routers/capability_requests.py b/api/routers/capability_requests.py index 6b5fda9..31b80ac 100644 --- a/api/routers/capability_requests.py +++ b/api/routers/capability_requests.py @@ -252,7 +252,7 @@ async def patch_request_status( # Auto-unblock the blocking task if req.blocking_task_id: task = await session.get(Task, req.blocking_task_id) - if task and task.status == "blocked": + if task and task.status == "wait": task.status = "todo" task.blocking_reason = None diff --git a/api/routers/execution.py b/api/routers/execution.py index 28bb363..ed2eb4c 100644 --- a/api/routers/execution.py +++ b/api/routers/execution.py @@ -100,7 +100,7 @@ async def workplan_stack( ] blocked_tasks = [ task_id for task_id in task_deps.get(ws.id, []) - if task_status.get(task_id) not in {"done", "cancelled"} + if task_status.get(task_id) not in {"done", "cancel"} ] eligible = lifecycle_status != "blocked" and not blocked_ws and not blocked_tasks if not include_blocked and not eligible: diff --git a/api/routers/reconciliation.py b/api/routers/reconciliation.py index 98484fd..3c9b3d2 100644 --- a/api/routers/reconciliation.py +++ b/api/routers/reconciliation.py @@ -17,6 +17,7 @@ from api.services.lifecycle import ( transition_task_status, transition_workstream_status, ) +from api.task_status import TERMINAL_TASK_STATUSES from api.services.reconciliation import ( ReconciliationClass, StateChangeClassification, @@ -52,7 +53,7 @@ def _conflict(reason: str, follow_up: str) -> StateChangeClassification: async def _workstream_tasks_terminal(session: AsyncSession, workstream_id: uuid.UUID) -> bool: result = await session.execute(select(Task.status).where(Task.workstream_id == workstream_id)) statuses = [status_value(row[0]) for row in result.all()] - return bool(statuses) and all(status in {"done", "cancelled"} for status in statuses) + return bool(statuses) and all(status in TERMINAL_TASK_STATUSES for status in statuses) def _deferred_message( diff --git a/api/routers/repos.py b/api/routers/repos.py index 04b5e68..0766002 100644 --- a/api/routers/repos.py +++ b/api/routers/repos.py @@ -590,7 +590,7 @@ async def get_repo_dispatch( for ws in workstreams: task_result = await session.execute( select(Task) - .where(Task.workstream_id == ws.id, Task.status.in_(["todo", "in_progress"])) + .where(Task.workstream_id == ws.id, Task.status.in_(["todo", "progress"])) .order_by(Task.created_at) ) tasks = list(task_result.scalars().all()) diff --git a/api/routers/state.py b/api/routers/state.py index cf8b1be..a214e87 100644 --- a/api/routers/state.py +++ b/api/routers/state.py @@ -38,6 +38,7 @@ from api.schemas.task import TaskRead from api.schemas.topic import TopicRead, TopicWithWorkstreams from api.schemas.workstream import WorkstreamRead, WorkstreamWithTaskCounts, WorkstreamWithDeps from api.schemas.workstream_dependency import WorkstreamDepStub +from api.task_status import TERMINAL_TASK_STATUSES, status_value from api.workplan_status import ( CLOSED_WORKSTREAM_STATUSES, OPEN_WORKSTREAM_STATUSES, @@ -111,10 +112,10 @@ async def get_summary( ) blocking = list(blocking_rows.scalars().all()) - blocked_rows = await session.execute( - select(Task).options(noload("*")).where(Task.status == TaskStatus.blocked).order_by(Task.created_at) + waiting_rows = await session.execute( + select(Task).options(noload("*")).where(Task.status == TaskStatus.wait).order_by(Task.created_at) ) - blocked = list(blocked_rows.scalars().all()) + waiting = list(waiting_rows.scalars().all()) recent_rows = await session.execute( select(ProgressEvent).options(noload("*")).order_by(ProgressEvent.created_at.desc()).limit(20) @@ -136,7 +137,7 @@ async def get_summary( select(Task.workstream_id, Task.status, func.count()).group_by(Task.workstream_id, Task.status) ): task_per_ws.setdefault(ws_id, {})[tstat] = cnt - task_statuses_per_ws.setdefault(ws_id, []).extend([_value(tstat)] * cnt) + task_statuses_per_ws.setdefault(ws_id, []).extend([status_value(tstat)] * cnt) # Dependency graph for open workstreams open_ws_ids = [w.id for w in open_ws] @@ -263,11 +264,11 @@ async def get_summary( total=sum(ws_counts.values()), ), tasks=TaskTotals( + wait=task_counts.get(TaskStatus.wait, 0), todo=task_counts.get(TaskStatus.todo, 0), - in_progress=task_counts.get(TaskStatus.in_progress, 0), - blocked=task_counts.get(TaskStatus.blocked, 0), + progress=task_counts.get(TaskStatus.progress, 0), done=task_counts.get(TaskStatus.done, 0), - cancelled=task_counts.get(TaskStatus.cancelled, 0), + cancel=task_counts.get(TaskStatus.cancel, 0), total=sum(task_counts.values()), ), decisions=DecisionTotals( @@ -329,7 +330,8 @@ async def get_summary( for t in topics ], blocking_decisions=[DecisionRead.model_validate(d) for d in blocking], - blocked_tasks=[TaskRead.model_validate(t) for t in blocked], + waiting_tasks=[TaskRead.model_validate(t) for t in waiting], + blocked_tasks=[TaskRead.model_validate(t) for t in waiting], recent_progress=[ProgressEventRead.model_validate(e) for e in recent], next_steps=next_steps, domains=domain_summaries, @@ -343,10 +345,11 @@ async def get_summary( "status": effective_status.get(w.id, w.status), }, tasks_total=sum(task_per_ws.get(w.id, {}).values()), + tasks_wait=task_per_ws.get(w.id, {}).get(TaskStatus.wait, 0), tasks_todo=task_per_ws.get(w.id, {}).get(TaskStatus.todo, 0), - tasks_in_progress=task_per_ws.get(w.id, {}).get(TaskStatus.in_progress, 0), - tasks_blocked=task_per_ws.get(w.id, {}).get(TaskStatus.blocked, 0), + tasks_progress=task_per_ws.get(w.id, {}).get(TaskStatus.progress, 0), tasks_done=task_per_ws.get(w.id, {}).get(TaskStatus.done, 0), + tasks_cancel=task_per_ws.get(w.id, {}).get(TaskStatus.cancel, 0), depends_on=dep_index.get(w.id, {}).get("depends_on", []), blocks=dep_index.get(w.id, {}).get("blocks", []), blocked_reasons=blocked_reasons.get(w.id, []), @@ -521,7 +524,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]: select(Task) .options(noload("*")) .where(Task.workstream_id == decision.workstream_id) - .where(Task.status.in_([TaskStatus.todo, TaskStatus.in_progress])) + .where(Task.status.in_([TaskStatus.todo, TaskStatus.progress, TaskStatus.wait])) ) open_tasks = list(open_tasks_rows.scalars().all()) if not open_tasks: @@ -655,10 +658,6 @@ async def _get_domain_slug_for_topic(topic_id, session: AsyncSession) -> str | N return domain.slug if domain else None -def _value(item): - return item.value if hasattr(item, "value") else item - - @router.get("/next_steps", response_model=list[NextStep]) async def get_next_steps(session: AsyncSession = Depends(get_session)) -> list[NextStep]: """Derive contextual next-action suggestions from current hub state. diff --git a/api/routers/tasks.py b/api/routers/tasks.py index 9ec9e61..62cd134 100644 --- a/api/routers/tasks.py +++ b/api/routers/tasks.py @@ -11,6 +11,7 @@ from api.models.token_event import TokenEvent from api.models.workstream import Workstream from api.schemas.task import TaskCreate, TaskRead, TaskUpdate from api.services.lifecycle import status_value, transition_task_status +from api.task_status import normalize_task_status router = APIRouter(prefix="/tasks", tags=["tasks"]) @@ -18,7 +19,7 @@ router = APIRouter(prefix="/tasks", tags=["tasks"]) @router.get("/", response_model=list[TaskRead]) async def list_tasks( workstream_id: uuid.UUID | None = None, - status: TaskStatus | None = None, + status: str | None = None, assignee: str | None = None, needs_human: bool | None = Query(None), priority: str | None = None, @@ -29,7 +30,7 @@ async def list_tasks( if workstream_id: q = q.where(Task.workstream_id == workstream_id) if status: - q = q.where(Task.status == status) + q = q.where(Task.status == TaskStatus(normalize_task_status(status))) if assignee: q = q.where(Task.assignee == assignee) if needs_human is not None: @@ -50,7 +51,7 @@ async def create_task( ) -> Task: task = Task(**body.model_dump()) session.add(task) - if status_value(task.status) == "in_progress": + if status_value(task.status) == "progress": ws = await session.get(Workstream, task.workstream_id) transition_task_status( task, @@ -198,7 +199,7 @@ async def cancel_task( task = await session.get(Task, task_id) if task is None: raise HTTPException(status_code=404, detail="Task not found") - transition_task_status(task, TaskStatus.cancelled) + transition_task_status(task, TaskStatus.cancel) await session.commit() await session.refresh(task) return task diff --git a/api/schemas/state.py b/api/schemas/state.py index 0d5c632..75d1213 100644 --- a/api/schemas/state.py +++ b/api/schemas/state.py @@ -30,11 +30,11 @@ class WorkstreamTotals(BaseModel): class TaskTotals(BaseModel): + wait: int = 0 todo: int = 0 - in_progress: int = 0 - blocked: int = 0 + progress: int = 0 done: int = 0 - cancelled: int = 0 + cancel: int = 0 total: int = 0 @@ -75,7 +75,8 @@ class StateSummary(BaseModel): totals: Totals topics: list[TopicWithWorkstreams] blocking_decisions: list[DecisionRead] - blocked_tasks: list[TaskRead] + waiting_tasks: list[TaskRead] + blocked_tasks: list[TaskRead] = [] recent_progress: list[ProgressEventRead] open_workstreams: list[WorkstreamWithDeps] next_steps: list[NextStep] = [] diff --git a/api/schemas/task.py b/api/schemas/task.py index 2c9af32..f76924b 100644 --- a/api/schemas/task.py +++ b/api/schemas/task.py @@ -2,12 +2,22 @@ import uuid from datetime import date, datetime from typing import Self -from pydantic import BaseModel, ConfigDict, model_validator +from pydantic import BaseModel, ConfigDict, field_validator, model_validator from api.models.task import TaskPriority, TaskStatus +from api.task_status import normalize_task_status -class TaskCreate(BaseModel): +class TaskStatusMixin(BaseModel): + @field_validator("status", mode="before", check_fields=False) + @classmethod + def _normalize_status(cls, value): + if value is None: + return value + return normalize_task_status(value) + + +class TaskCreate(TaskStatusMixin): workstream_id: uuid.UUID title: str description: str | None = None @@ -27,7 +37,7 @@ class TaskCreate(BaseModel): return self -class TaskUpdate(BaseModel): +class TaskUpdate(TaskStatusMixin): title: str | None = None description: str | None = None status: TaskStatus | None = None @@ -55,9 +65,9 @@ class TaskUpdate(BaseModel): suppress_token_event: bool | None = None @model_validator(mode="after") - 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") + def blocking_reason_required_when_human_waiting(self) -> Self: + if self.status == TaskStatus.wait and self.needs_human and not self.blocking_reason: + raise ValueError("blocking_reason is required when a human-blocked task is waiting") return self @model_validator(mode="after") @@ -67,7 +77,7 @@ class TaskUpdate(BaseModel): return self -class TaskRead(BaseModel): +class TaskRead(TaskStatusMixin): model_config = ConfigDict(from_attributes=True) id: uuid.UUID workstream_id: uuid.UUID diff --git a/api/schemas/workstream.py b/api/schemas/workstream.py index b7e8a0f..da18ebd 100644 --- a/api/schemas/workstream.py +++ b/api/schemas/workstream.py @@ -92,10 +92,11 @@ class WorkstreamRead(WorkstreamStatusMixin): class WorkstreamWithTaskCounts(WorkstreamRead): tasks_total: int = 0 + tasks_wait: int = 0 tasks_todo: int = 0 - tasks_in_progress: int = 0 - tasks_blocked: int = 0 + tasks_progress: int = 0 tasks_done: int = 0 + tasks_cancel: int = 0 class WorkstreamWithDeps(WorkstreamWithTaskCounts): diff --git a/api/services/lifecycle.py b/api/services/lifecycle.py index 3720c62..5ee9383 100644 --- a/api/services/lifecycle.py +++ b/api/services/lifecycle.py @@ -3,12 +3,13 @@ from __future__ import annotations from dataclasses import dataclass from typing import Any +from api.task_status import ACTIVE_TASK_STATUSES, normalize_task_status, status_value from api.workplan_status import normalize_workstream_status -TASK_STARTED_STATUS = "in_progress" +TASK_STARTED_STATUS = "progress" TASK_NOT_STARTED_STATUS = "todo" -TASK_ACTIVE_STATUSES = {"in_progress", "blocked"} +TASK_ACTIVE_STATUSES = ACTIVE_TASK_STATUSES PARENT_ACTIVATION_STATUSES = {"proposed", "ready", "backlog"} @@ -21,12 +22,6 @@ class LifecycleTransitionResult: parent_activated: bool = False -def status_value(status: Any) -> str: - if hasattr(status, "value"): - status = status.value - return str(status or "").strip().lower() - - def should_activate_parent_for_task_start( *, previous_task_status: Any, @@ -109,8 +104,8 @@ def transition_task_status( if previous_task_status is None else previous_task_status ) - normalised_target = status_value(target_status) - task.status = status_coercer(normalised_target) if status_coercer else target_status + normalised_target = normalize_task_status(target_status) + task.status = status_coercer(normalised_target) if status_coercer else normalised_target parent_activated = activate_parent_for_task_start( previous_task_status=previous_status, new_task_status=normalised_target, diff --git a/api/services/recently_on_scope.py b/api/services/recently_on_scope.py index a86fb60..5e28707 100644 --- a/api/services/recently_on_scope.py +++ b/api/services/recently_on_scope.py @@ -123,7 +123,7 @@ async def collect_domain_activity( ] attention_tasks = [ task for task in tasks - if task.needs_human and _enum_value(task.status) not in {TaskStatus.done.value, TaskStatus.cancelled.value} + if task.needs_human and _enum_value(task.status) not in {TaskStatus.done.value, TaskStatus.cancel.value} ] data = { diff --git a/api/services/reconciliation.py b/api/services/reconciliation.py index a75eb0f..d05538f 100644 --- a/api/services/reconciliation.py +++ b/api/services/reconciliation.py @@ -5,6 +5,7 @@ from enum import Enum from typing import Any from api.services.lifecycle import status_value +from api.task_status import CANONICAL_TASK_STATUSES from api.workplan_status import CLOSED_WORKSTREAM_STATUSES, normalize_workstream_status @@ -22,7 +23,7 @@ class StateChangeClassification: WRITE_THROUGH_WORKSTREAM_STATUSES = {"proposed", "ready", "active", "backlog"} -TASK_STATUSES = {"todo", "in_progress", "blocked", "done", "cancelled"} +TASK_STATUSES = set(CANONICAL_TASK_STATUSES) def classify_workstream_status_change( @@ -129,11 +130,11 @@ def classify_task_status_change( "status is unchanged", "no file update required", ) - if target == "blocked" and not (blocking_reason or "").strip(): + if target == "wait" and not (blocking_reason or "").strip(): return StateChangeClassification( ReconciliationClass.HUMAN_CONFIRMATION, - "blocked tasks require a blocking reason", - "capture the blocker before writing status", + "waiting tasks should explain the wait condition", + "capture the wait reason before writing status", ) if target in TASK_STATUSES: return StateChangeClassification( diff --git a/api/task_status.py b/api/task_status.py new file mode 100644 index 0000000..0bec35f --- /dev/null +++ b/api/task_status.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from typing import Any + +CANONICAL_TASK_STATUSES: tuple[str, ...] = ( + "wait", + "todo", + "progress", + "done", + "cancel", +) + +TASK_STATUS_CODES: dict[str, str] = { + "WAIT": "wait", + "TODO": "todo", + "PROG": "progress", + "DONE": "done", + "CNCL": "cancel", +} + +LEGACY_TASK_STATUS_ALIASES: dict[str, str] = { + "blocked": "wait", + "waiting": "wait", + "in_progress": "progress", + "in-progress": "progress", + "prog": "progress", + "cancelled": "cancel", + "canceled": "cancel", + "cncl": "cancel", +} + +OPEN_TASK_STATUSES: frozenset[str] = frozenset({"wait", "todo", "progress"}) +ACTIVE_TASK_STATUSES: frozenset[str] = frozenset({"wait", "progress"}) +TERMINAL_TASK_STATUSES: frozenset[str] = frozenset({"done", "cancel"}) + +TASK_STATUS_ORDER: dict[str, int] = { + "todo": 0, + "wait": 1, + "progress": 1, + "done": 2, + "cancel": 2, +} + +TASK_STATUS_LABELS: dict[str, str] = { + "wait": "wait", + "todo": "todo", + "progress": "progress", + "done": "done", + "cancel": "cancel", +} + + +def raw_status_value(status: Any) -> str: + if hasattr(status, "value"): + status = status.value + return str(status or "").strip() + + +def normalize_task_status(status: Any, *, default: str | None = None) -> str: + """Normalize canon task statuses plus legacy aliases to stored values.""" + value = raw_status_value(status).lower() + if not value: + if default is not None: + return default + raise ValueError("task status is required") + value = LEGACY_TASK_STATUS_ALIASES.get(value, value) + if value not in CANONICAL_TASK_STATUSES: + allowed = ", ".join(CANONICAL_TASK_STATUSES) + aliases = ", ".join(sorted(LEGACY_TASK_STATUS_ALIASES)) + raise ValueError(f"task status must be one of {allowed}; legacy aliases: {aliases}") + return value + + +def status_value(status: Any, *, default: str | None = None) -> str: + """Compatibility wrapper used by lifecycle and reconciliation code.""" + return normalize_task_status(status, default=default) + + +def is_open_task_status(status: Any) -> bool: + return normalize_task_status(status, default="todo") in OPEN_TASK_STATUSES + + +def is_terminal_task_status(status: Any) -> bool: + return normalize_task_status(status, default="todo") in TERMINAL_TASK_STATUSES diff --git a/dashboard/src/components/entity-modal.js b/dashboard/src/components/entity-modal.js index 362b8ab..ef3f26f 100644 --- a/dashboard/src/components/entity-modal.js +++ b/dashboard/src/components/entity-modal.js @@ -141,11 +141,14 @@ const _STATUS_STYLE = { archived: "background:#e2e3e5;color:#383d41", open: "background:#dbeafe;color:#1e40af", in_progress: "background:#fef3c7;color:#92400e", + wait: "background:#fef3c7;color:#92400e", + progress: "background:#ede9fe;color:#5b21b6", addressed: "background:#dcfce7;color:#166534", deferred: "background:#f1f5f9;color:#64748b", wont_fix: "background:#f3f4f6;color:#9ca3af", todo: "background:#f1f5f9;color:#475569", done: "background:#dcfce7;color:#166534", + cancel: "background:#f3f4f6;color:#9ca3af", cancelled: "background:#f3f4f6;color:#9ca3af", resolved: "background:#dcfce7;color:#166534", superseded: "background:#e2e3e5;color:#383d41", @@ -226,8 +229,8 @@ function _buildBody(entity, type) { if (entity.tasks_total !== undefined) { els.push(_divider(), tf("Tasks", `${entity.tasks_done ?? 0} done / ${entity.tasks_total} total` + - (entity.tasks_in_progress > 0 ? ` · ${entity.tasks_in_progress} in progress` : "") + - (entity.tasks_blocked > 0 ? ` · ${entity.tasks_blocked} blocked` : "")) + (entity.tasks_progress > 0 ? ` · ${entity.tasks_progress} progress` : "") + + (entity.tasks_wait > 0 ? ` · ${entity.tasks_wait} wait` : "")) ); } if (entity.depends_on?.length) { diff --git a/dashboard/src/components/field-help.js b/dashboard/src/components/field-help.js index 1c6ad8c..04e45a5 100644 --- a/dashboard/src/components/field-help.js +++ b/dashboard/src/components/field-help.js @@ -153,7 +153,7 @@ export const FIELD_HELP = { }, status: { label: "Status", - description: "Current lifecycle state: todo, in_progress, blocked, done, or cancelled.", + description: "Current lifecycle state. Tasks use wait, todo, progress, done, or cancel.", doc: "/docs/workstream-lifecycle", }, topic_id: { @@ -182,7 +182,7 @@ export const FIELD_HELP = { }, needs_human: { label: "Needs Human", - description: "True if the task is blocked waiting for human input or approval.", + description: "True if the task is waiting for human input or approval.", doc: "/interventions", }, intervention_note: { diff --git a/dashboard/src/components/status-control.js b/dashboard/src/components/status-control.js index 009df1e..26c162d 100644 --- a/dashboard/src/components/status-control.js +++ b/dashboard/src/components/status-control.js @@ -3,7 +3,7 @@ import {WORKSTREAM_STATUSES} from "./workplan-status.js"; const STYLE_ID = "status-control-styles"; -export const TASK_STATUSES = ["todo", "in_progress", "blocked", "done", "cancelled"]; +export const TASK_STATUSES = ["wait", "todo", "progress", "done", "cancel"]; export {WORKSTREAM_STATUSES}; function ensureStyles() { @@ -138,9 +138,9 @@ export function statusControl({ if (nextStatus === currentStatus) return; let blockingReason = null; - if (type === "task" && nextStatus === "blocked") { + if (type === "task" && nextStatus === "wait") { const existingReason = entity?.blocking_reason ?? ""; - const reason = existingReason || window.prompt("Blocking reason required for blocked tasks:"); + const reason = existingReason || window.prompt("Reason this task is waiting:"); if (!reason) { select.value = currentStatus; setMessage("unchanged"); diff --git a/dashboard/src/components/workplan-status.js b/dashboard/src/components/workplan-status.js index a6777d0..a2bf451 100644 --- a/dashboard/src/components/workplan-status.js +++ b/dashboard/src/components/workplan-status.js @@ -33,7 +33,7 @@ export function isOpenWorkstream(status) { export function isStalledWorkstream(w, staleDays = 7) { const staleAt = new Date(Date.now() - staleDays * 24 * 60 * 60 * 1000); - const openTasks = (w.todo ?? 0) + (w.in_progress ?? 0) + (w.blocked ?? 0); + const openTasks = (w.todo ?? 0) + (w.progress ?? 0) + (w.wait ?? 0); return ["active", "blocked"].includes(normalizeWorkstreamStatus(w.status)) && new Date(w.updated_at) < staleAt && (w.done ?? 0) > 0 diff --git a/dashboard/src/data/summary.json.py b/dashboard/src/data/summary.json.py index 2f15c4a..63fd613 100644 --- a/dashboard/src/data/summary.json.py +++ b/dashboard/src/data/summary.json.py @@ -29,11 +29,12 @@ except urllib.error.URLError as e: "archived": 0, "total": 0, }, - "tasks": {"todo": 0, "in_progress": 0, "blocked": 0, "done": 0, "cancelled": 0, "total": 0}, + "tasks": {"wait": 0, "todo": 0, "progress": 0, "done": 0, "cancel": 0, "total": 0}, "decisions": {"open": 0, "resolved": 0, "escalated": 0, "superseded": 0, "total": 0}, }, "topics": [], "blocking_decisions": [], + "waiting_tasks": [], "blocked_tasks": [], "recent_progress": [], "open_workstreams": [], diff --git a/dashboard/src/docs/capabilities.md b/dashboard/src/docs/capabilities.md index 25b54d0..2f05a74 100644 --- a/dashboard/src/docs/capabilities.md +++ b/dashboard/src/docs/capabilities.md @@ -145,8 +145,8 @@ Notifications appear in the [Inbox](/inbox) page and are queryable via A request can optionally link to a **blocking task** via `blocking_task_id`. When the request reaches `completed`, the system automatically patches that -task from `blocked` → `todo` and clears its `blocking_reason`. This means -blocked work resumes without manual intervention. +task from `wait` → `todo` and clears its `blocking_reason`. This means +waiting work resumes without manual intervention. --- diff --git a/dashboard/src/docs/dashboard.md b/dashboard/src/docs/dashboard.md index 765a06c..9bc15e8 100644 --- a/dashboard/src/docs/dashboard.md +++ b/dashboard/src/docs/dashboard.md @@ -241,7 +241,7 @@ TOC sidebar as a persistent KPI card. ### Multi-mode workstream chart The Overview page renders a horizontal stacked bar chart using `@observablehq/plot` -showing task counts (done / in progress / blocked / todo) per workstream. +showing task counts (done / progress / wait / todo) per workstream. A `