feat(tasks): adopt canonical task statuses

This commit is contained in:
2026-05-26 01:32:50 +02:00
parent da5aee6e38
commit 38835e9e79
61 changed files with 692 additions and 342 deletions

View File

@@ -63,8 +63,8 @@ Omit `workstream_id` / `task_id` when not applicable.
```bash ```bash
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"status": "in_progress"}' -d '{"status": "progress"}'
# values: todo | in_progress | done | blocked # values: wait | todo | progress | done | cancel
``` ```
### Flag a task for human review ### Flag a task for human review
@@ -83,7 +83,7 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe) 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 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 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:** **During work:**
- Update task statuses in workplan files as tasks progress - Update task statuses in workplan files as tasks progress
@@ -146,7 +146,7 @@ derived health labels, not frontmatter statuses.
` ` `task ` ` `task
id: STATE-WP-NNNN-T01 id: STATE-WP-NNNN-T01
status: todo | in_progress | done | blocked status: wait | todo | progress | done | cancel
priority: high | medium | low priority: high | medium | low
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
` ` ` ` ` `
@@ -154,7 +154,7 @@ state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
Task description text. 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: To create a new workplan:
1. Write the file following the format above 1. Write the file following the format above

View File

@@ -142,7 +142,7 @@ decisions (FK: topic_id, workstream_id — at least one required)
|------|--------| |------|--------|
| `topic_status` | `active` · `paused` · `archived` | | `topic_status` | `active` · `paused` · `archived` |
| `workstream_status` | `proposed` · `ready` · `active` · `blocked` · `backlog` · `finished` · `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` | | `task_priority` | `low` · `medium` · `high` · `critical` |
| `decision_type` | `made` · `pending` | | `decision_type` | `made` · `pending` |
| `decision_status` | `open` · `resolved` · `escalated` · `superseded` | | `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 ### 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) - `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) - `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": { "totals": {
"topics": { "active": 6, "paused": 0, "archived": 0, "total": 6 }, "topics": { "active": 6, "paused": 0, "archived": 0, "total": 6 },
"workstreams": { "ready": 1, "active": 1, "blocked": 0, "finished": 1, "total": 3 }, "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 } "decisions": { "open": 1, "resolved": 0, "escalated": 0, "total": 1 }
}, },
"topics": [...], // topics with nested workstream stubs "topics": [...], // topics with nested workstream stubs
"blocking_decisions": [...], // pending decisions only "blocking_decisions": [...], // pending decisions only
"blocked_tasks": [...], "waiting_tasks": [...],
"recent_progress": [...], // last 20 events "recent_progress": [...], // last 20 events
"open_workstreams": [...] "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`) | | `/topics` | CRUD (soft-delete: `archived`) |
| `/workstreams` | 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 | | `/decisions` | CRUD (soft-delete: `superseded`); auto-escalation |
| `/progress` | `GET` list + `POST` append — no DELETE | | `/progress` | `GET` list + `POST` append — no DELETE |
| `/state/summary` | Full snapshot | | `/state/summary` | Full snapshot |

View File

@@ -10,11 +10,11 @@ from api.models.base import Base, TimestampMixin, new_uuid
class TaskStatus(str, enum.Enum): class TaskStatus(str, enum.Enum):
wait = "wait"
todo = "todo" todo = "todo"
in_progress = "in_progress" progress = "progress"
blocked = "blocked"
done = "done" done = "done"
cancelled = "cancelled" cancel = "cancel"
class TaskPriority(str, enum.Enum): class TaskPriority(str, enum.Enum):

View File

@@ -252,7 +252,7 @@ async def patch_request_status(
# Auto-unblock the blocking task # Auto-unblock the blocking task
if req.blocking_task_id: if req.blocking_task_id:
task = await session.get(Task, 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.status = "todo"
task.blocking_reason = None task.blocking_reason = None

View File

@@ -100,7 +100,7 @@ async def workplan_stack(
] ]
blocked_tasks = [ blocked_tasks = [
task_id for task_id in task_deps.get(ws.id, []) 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 eligible = lifecycle_status != "blocked" and not blocked_ws and not blocked_tasks
if not include_blocked and not eligible: if not include_blocked and not eligible:

View File

@@ -17,6 +17,7 @@ from api.services.lifecycle import (
transition_task_status, transition_task_status,
transition_workstream_status, transition_workstream_status,
) )
from api.task_status import TERMINAL_TASK_STATUSES
from api.services.reconciliation import ( from api.services.reconciliation import (
ReconciliationClass, ReconciliationClass,
StateChangeClassification, 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: 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)) result = await session.execute(select(Task.status).where(Task.workstream_id == workstream_id))
statuses = [status_value(row[0]) for row in result.all()] 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( def _deferred_message(

View File

@@ -590,7 +590,7 @@ async def get_repo_dispatch(
for ws in workstreams: for ws in workstreams:
task_result = await session.execute( task_result = await session.execute(
select(Task) 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) .order_by(Task.created_at)
) )
tasks = list(task_result.scalars().all()) tasks = list(task_result.scalars().all())

View File

@@ -38,6 +38,7 @@ from api.schemas.task import TaskRead
from api.schemas.topic import TopicRead, TopicWithWorkstreams from api.schemas.topic import TopicRead, TopicWithWorkstreams
from api.schemas.workstream import WorkstreamRead, WorkstreamWithTaskCounts, WorkstreamWithDeps from api.schemas.workstream import WorkstreamRead, WorkstreamWithTaskCounts, WorkstreamWithDeps
from api.schemas.workstream_dependency import WorkstreamDepStub from api.schemas.workstream_dependency import WorkstreamDepStub
from api.task_status import TERMINAL_TASK_STATUSES, status_value
from api.workplan_status import ( from api.workplan_status import (
CLOSED_WORKSTREAM_STATUSES, CLOSED_WORKSTREAM_STATUSES,
OPEN_WORKSTREAM_STATUSES, OPEN_WORKSTREAM_STATUSES,
@@ -111,10 +112,10 @@ async def get_summary(
) )
blocking = list(blocking_rows.scalars().all()) blocking = list(blocking_rows.scalars().all())
blocked_rows = await session.execute( waiting_rows = await session.execute(
select(Task).options(noload("*")).where(Task.status == TaskStatus.blocked).order_by(Task.created_at) 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( recent_rows = await session.execute(
select(ProgressEvent).options(noload("*")).order_by(ProgressEvent.created_at.desc()).limit(20) 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) select(Task.workstream_id, Task.status, func.count()).group_by(Task.workstream_id, Task.status)
): ):
task_per_ws.setdefault(ws_id, {})[tstat] = cnt 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 # Dependency graph for open workstreams
open_ws_ids = [w.id for w in open_ws] open_ws_ids = [w.id for w in open_ws]
@@ -263,11 +264,11 @@ async def get_summary(
total=sum(ws_counts.values()), total=sum(ws_counts.values()),
), ),
tasks=TaskTotals( tasks=TaskTotals(
wait=task_counts.get(TaskStatus.wait, 0),
todo=task_counts.get(TaskStatus.todo, 0), todo=task_counts.get(TaskStatus.todo, 0),
in_progress=task_counts.get(TaskStatus.in_progress, 0), progress=task_counts.get(TaskStatus.progress, 0),
blocked=task_counts.get(TaskStatus.blocked, 0),
done=task_counts.get(TaskStatus.done, 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()), total=sum(task_counts.values()),
), ),
decisions=DecisionTotals( decisions=DecisionTotals(
@@ -329,7 +330,8 @@ async def get_summary(
for t in topics for t in topics
], ],
blocking_decisions=[DecisionRead.model_validate(d) for d in blocking], 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], recent_progress=[ProgressEventRead.model_validate(e) for e in recent],
next_steps=next_steps, next_steps=next_steps,
domains=domain_summaries, domains=domain_summaries,
@@ -343,10 +345,11 @@ async def get_summary(
"status": effective_status.get(w.id, w.status), "status": effective_status.get(w.id, w.status),
}, },
tasks_total=sum(task_per_ws.get(w.id, {}).values()), 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_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_progress=task_per_ws.get(w.id, {}).get(TaskStatus.progress, 0),
tasks_blocked=task_per_ws.get(w.id, {}).get(TaskStatus.blocked, 0),
tasks_done=task_per_ws.get(w.id, {}).get(TaskStatus.done, 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", []), depends_on=dep_index.get(w.id, {}).get("depends_on", []),
blocks=dep_index.get(w.id, {}).get("blocks", []), blocks=dep_index.get(w.id, {}).get("blocks", []),
blocked_reasons=blocked_reasons.get(w.id, []), blocked_reasons=blocked_reasons.get(w.id, []),
@@ -521,7 +524,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
select(Task) select(Task)
.options(noload("*")) .options(noload("*"))
.where(Task.workstream_id == decision.workstream_id) .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()) open_tasks = list(open_tasks_rows.scalars().all())
if not open_tasks: 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 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]) @router.get("/next_steps", response_model=list[NextStep])
async def get_next_steps(session: AsyncSession = Depends(get_session)) -> list[NextStep]: async def get_next_steps(session: AsyncSession = Depends(get_session)) -> list[NextStep]:
"""Derive contextual next-action suggestions from current hub state. """Derive contextual next-action suggestions from current hub state.

View File

@@ -11,6 +11,7 @@ from api.models.token_event import TokenEvent
from api.models.workstream import Workstream from api.models.workstream import Workstream
from api.schemas.task import TaskCreate, TaskRead, TaskUpdate from api.schemas.task import TaskCreate, TaskRead, TaskUpdate
from api.services.lifecycle import status_value, transition_task_status from api.services.lifecycle import status_value, transition_task_status
from api.task_status import normalize_task_status
router = APIRouter(prefix="/tasks", tags=["tasks"]) router = APIRouter(prefix="/tasks", tags=["tasks"])
@@ -18,7 +19,7 @@ router = APIRouter(prefix="/tasks", tags=["tasks"])
@router.get("/", response_model=list[TaskRead]) @router.get("/", response_model=list[TaskRead])
async def list_tasks( async def list_tasks(
workstream_id: uuid.UUID | None = None, workstream_id: uuid.UUID | None = None,
status: TaskStatus | None = None, status: str | None = None,
assignee: str | None = None, assignee: str | None = None,
needs_human: bool | None = Query(None), needs_human: bool | None = Query(None),
priority: str | None = None, priority: str | None = None,
@@ -29,7 +30,7 @@ async def list_tasks(
if workstream_id: if workstream_id:
q = q.where(Task.workstream_id == workstream_id) q = q.where(Task.workstream_id == workstream_id)
if status: if status:
q = q.where(Task.status == status) q = q.where(Task.status == TaskStatus(normalize_task_status(status)))
if assignee: if assignee:
q = q.where(Task.assignee == assignee) q = q.where(Task.assignee == assignee)
if needs_human is not None: if needs_human is not None:
@@ -50,7 +51,7 @@ async def create_task(
) -> Task: ) -> Task:
task = Task(**body.model_dump()) task = Task(**body.model_dump())
session.add(task) session.add(task)
if status_value(task.status) == "in_progress": if status_value(task.status) == "progress":
ws = await session.get(Workstream, task.workstream_id) ws = await session.get(Workstream, task.workstream_id)
transition_task_status( transition_task_status(
task, task,
@@ -198,7 +199,7 @@ async def cancel_task(
task = await session.get(Task, task_id) task = await session.get(Task, task_id)
if task is None: if task is None:
raise HTTPException(status_code=404, detail="Task not found") 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.commit()
await session.refresh(task) await session.refresh(task)
return task return task

View File

@@ -30,11 +30,11 @@ class WorkstreamTotals(BaseModel):
class TaskTotals(BaseModel): class TaskTotals(BaseModel):
wait: int = 0
todo: int = 0 todo: int = 0
in_progress: int = 0 progress: int = 0
blocked: int = 0
done: int = 0 done: int = 0
cancelled: int = 0 cancel: int = 0
total: int = 0 total: int = 0
@@ -75,7 +75,8 @@ class StateSummary(BaseModel):
totals: Totals totals: Totals
topics: list[TopicWithWorkstreams] topics: list[TopicWithWorkstreams]
blocking_decisions: list[DecisionRead] blocking_decisions: list[DecisionRead]
blocked_tasks: list[TaskRead] waiting_tasks: list[TaskRead]
blocked_tasks: list[TaskRead] = []
recent_progress: list[ProgressEventRead] recent_progress: list[ProgressEventRead]
open_workstreams: list[WorkstreamWithDeps] open_workstreams: list[WorkstreamWithDeps]
next_steps: list[NextStep] = [] next_steps: list[NextStep] = []

View File

@@ -2,12 +2,22 @@ import uuid
from datetime import date, datetime from datetime import date, datetime
from typing import Self 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.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 workstream_id: uuid.UUID
title: str title: str
description: str | None = None description: str | None = None
@@ -27,7 +37,7 @@ class TaskCreate(BaseModel):
return self return self
class TaskUpdate(BaseModel): class TaskUpdate(TaskStatusMixin):
title: str | None = None title: str | None = None
description: str | None = None description: str | None = None
status: TaskStatus | None = None status: TaskStatus | None = None
@@ -55,9 +65,9 @@ class TaskUpdate(BaseModel):
suppress_token_event: bool | None = None suppress_token_event: bool | None = None
@model_validator(mode="after") @model_validator(mode="after")
def blocking_reason_required_when_blocked(self) -> Self: def blocking_reason_required_when_human_waiting(self) -> Self:
if self.status == TaskStatus.blocked and not self.blocking_reason: if self.status == TaskStatus.wait and self.needs_human and not self.blocking_reason:
raise ValueError("blocking_reason is required when status is blocked") raise ValueError("blocking_reason is required when a human-blocked task is waiting")
return self return self
@model_validator(mode="after") @model_validator(mode="after")
@@ -67,7 +77,7 @@ class TaskUpdate(BaseModel):
return self return self
class TaskRead(BaseModel): class TaskRead(TaskStatusMixin):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: uuid.UUID id: uuid.UUID
workstream_id: uuid.UUID workstream_id: uuid.UUID

View File

@@ -92,10 +92,11 @@ class WorkstreamRead(WorkstreamStatusMixin):
class WorkstreamWithTaskCounts(WorkstreamRead): class WorkstreamWithTaskCounts(WorkstreamRead):
tasks_total: int = 0 tasks_total: int = 0
tasks_wait: int = 0
tasks_todo: int = 0 tasks_todo: int = 0
tasks_in_progress: int = 0 tasks_progress: int = 0
tasks_blocked: int = 0
tasks_done: int = 0 tasks_done: int = 0
tasks_cancel: int = 0
class WorkstreamWithDeps(WorkstreamWithTaskCounts): class WorkstreamWithDeps(WorkstreamWithTaskCounts):

View File

@@ -3,12 +3,13 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any 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 from api.workplan_status import normalize_workstream_status
TASK_STARTED_STATUS = "in_progress" TASK_STARTED_STATUS = "progress"
TASK_NOT_STARTED_STATUS = "todo" TASK_NOT_STARTED_STATUS = "todo"
TASK_ACTIVE_STATUSES = {"in_progress", "blocked"} TASK_ACTIVE_STATUSES = ACTIVE_TASK_STATUSES
PARENT_ACTIVATION_STATUSES = {"proposed", "ready", "backlog"} PARENT_ACTIVATION_STATUSES = {"proposed", "ready", "backlog"}
@@ -21,12 +22,6 @@ class LifecycleTransitionResult:
parent_activated: bool = False 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( def should_activate_parent_for_task_start(
*, *,
previous_task_status: Any, previous_task_status: Any,
@@ -109,8 +104,8 @@ def transition_task_status(
if previous_task_status is None if previous_task_status is None
else previous_task_status else previous_task_status
) )
normalised_target = status_value(target_status) normalised_target = normalize_task_status(target_status)
task.status = status_coercer(normalised_target) if status_coercer else target_status task.status = status_coercer(normalised_target) if status_coercer else normalised_target
parent_activated = activate_parent_for_task_start( parent_activated = activate_parent_for_task_start(
previous_task_status=previous_status, previous_task_status=previous_status,
new_task_status=normalised_target, new_task_status=normalised_target,

View File

@@ -123,7 +123,7 @@ async def collect_domain_activity(
] ]
attention_tasks = [ attention_tasks = [
task for task in 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 = { data = {

View File

@@ -5,6 +5,7 @@ from enum import Enum
from typing import Any from typing import Any
from api.services.lifecycle import status_value 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 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"} 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( def classify_workstream_status_change(
@@ -129,11 +130,11 @@ def classify_task_status_change(
"status is unchanged", "status is unchanged",
"no file update required", "no file update required",
) )
if target == "blocked" and not (blocking_reason or "").strip(): if target == "wait" and not (blocking_reason or "").strip():
return StateChangeClassification( return StateChangeClassification(
ReconciliationClass.HUMAN_CONFIRMATION, ReconciliationClass.HUMAN_CONFIRMATION,
"blocked tasks require a blocking reason", "waiting tasks should explain the wait condition",
"capture the blocker before writing status", "capture the wait reason before writing status",
) )
if target in TASK_STATUSES: if target in TASK_STATUSES:
return StateChangeClassification( return StateChangeClassification(

84
api/task_status.py Normal file
View File

@@ -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

View File

@@ -141,11 +141,14 @@ const _STATUS_STYLE = {
archived: "background:#e2e3e5;color:#383d41", archived: "background:#e2e3e5;color:#383d41",
open: "background:#dbeafe;color:#1e40af", open: "background:#dbeafe;color:#1e40af",
in_progress: "background:#fef3c7;color:#92400e", in_progress: "background:#fef3c7;color:#92400e",
wait: "background:#fef3c7;color:#92400e",
progress: "background:#ede9fe;color:#5b21b6",
addressed: "background:#dcfce7;color:#166534", addressed: "background:#dcfce7;color:#166534",
deferred: "background:#f1f5f9;color:#64748b", deferred: "background:#f1f5f9;color:#64748b",
wont_fix: "background:#f3f4f6;color:#9ca3af", wont_fix: "background:#f3f4f6;color:#9ca3af",
todo: "background:#f1f5f9;color:#475569", todo: "background:#f1f5f9;color:#475569",
done: "background:#dcfce7;color:#166534", done: "background:#dcfce7;color:#166534",
cancel: "background:#f3f4f6;color:#9ca3af",
cancelled: "background:#f3f4f6;color:#9ca3af", cancelled: "background:#f3f4f6;color:#9ca3af",
resolved: "background:#dcfce7;color:#166534", resolved: "background:#dcfce7;color:#166534",
superseded: "background:#e2e3e5;color:#383d41", superseded: "background:#e2e3e5;color:#383d41",
@@ -226,8 +229,8 @@ function _buildBody(entity, type) {
if (entity.tasks_total !== undefined) { if (entity.tasks_total !== undefined) {
els.push(_divider(), els.push(_divider(),
tf("Tasks", `${entity.tasks_done ?? 0} done / ${entity.tasks_total} total` + tf("Tasks", `${entity.tasks_done ?? 0} done / ${entity.tasks_total} total` +
(entity.tasks_in_progress > 0 ? ` · ${entity.tasks_in_progress} in progress` : "") + (entity.tasks_progress > 0 ? ` · ${entity.tasks_progress} progress` : "") +
(entity.tasks_blocked > 0 ? ` · ${entity.tasks_blocked} blocked` : "")) (entity.tasks_wait > 0 ? ` · ${entity.tasks_wait} wait` : ""))
); );
} }
if (entity.depends_on?.length) { if (entity.depends_on?.length) {

View File

@@ -153,7 +153,7 @@ export const FIELD_HELP = {
}, },
status: { status: {
label: "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", doc: "/docs/workstream-lifecycle",
}, },
topic_id: { topic_id: {
@@ -182,7 +182,7 @@ export const FIELD_HELP = {
}, },
needs_human: { needs_human: {
label: "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", doc: "/interventions",
}, },
intervention_note: { intervention_note: {

View File

@@ -3,7 +3,7 @@ import {WORKSTREAM_STATUSES} from "./workplan-status.js";
const STYLE_ID = "status-control-styles"; 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}; export {WORKSTREAM_STATUSES};
function ensureStyles() { function ensureStyles() {
@@ -138,9 +138,9 @@ export function statusControl({
if (nextStatus === currentStatus) return; if (nextStatus === currentStatus) return;
let blockingReason = null; let blockingReason = null;
if (type === "task" && nextStatus === "blocked") { if (type === "task" && nextStatus === "wait") {
const existingReason = entity?.blocking_reason ?? ""; 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) { if (!reason) {
select.value = currentStatus; select.value = currentStatus;
setMessage("unchanged"); setMessage("unchanged");

View File

@@ -33,7 +33,7 @@ export function isOpenWorkstream(status) {
export function isStalledWorkstream(w, staleDays = 7) { export function isStalledWorkstream(w, staleDays = 7) {
const staleAt = new Date(Date.now() - staleDays * 24 * 60 * 60 * 1000); 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)) return ["active", "blocked"].includes(normalizeWorkstreamStatus(w.status))
&& new Date(w.updated_at) < staleAt && new Date(w.updated_at) < staleAt
&& (w.done ?? 0) > 0 && (w.done ?? 0) > 0

View File

@@ -29,11 +29,12 @@ except urllib.error.URLError as e:
"archived": 0, "archived": 0,
"total": 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}, "decisions": {"open": 0, "resolved": 0, "escalated": 0, "superseded": 0, "total": 0},
}, },
"topics": [], "topics": [],
"blocking_decisions": [], "blocking_decisions": [],
"waiting_tasks": [],
"blocked_tasks": [], "blocked_tasks": [],
"recent_progress": [], "recent_progress": [],
"open_workstreams": [], "open_workstreams": [],

View File

@@ -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`. A request can optionally link to a **blocking task** via `blocking_task_id`.
When the request reaches `completed`, the system automatically patches that When the request reaches `completed`, the system automatically patches that
task from `blocked` → `todo` and clears its `blocking_reason`. This means task from `wait` → `todo` and clears its `blocking_reason`. This means
blocked work resumes without manual intervention. waiting work resumes without manual intervention.
--- ---

View File

@@ -241,7 +241,7 @@ TOC sidebar as a persistent KPI card.
### Multi-mode workstream chart ### Multi-mode workstream chart
The Overview page renders a horizontal stacked bar chart using `@observablehq/plot` 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 `<select>` dropdown switches between: A `<select>` dropdown switches between:
- **Lifecycle modes**: proposed, ready, active, blocked, backlog, finished, archived - **Lifecycle modes**: proposed, ready, active, blocked, backlog, finished, archived

View File

@@ -129,8 +129,8 @@ The session orientation protocol (every repo's CLAUDE.md) surfaces todos from
two sources: two sources:
**Internal todos** (Step 2 of orientation) — workplan files in `workplans/` **Internal todos** (Step 2 of orientation) — workplan files in `workplans/`
whose stored workstation/status label is `active`, with tasks in `todo` or whose stored workstation/status label is `active`, with tasks in `wait`,
`in_progress`. `todo`, or `progress`.
**Ecosystem todos targeting this repo** (Step 1 of orientation) — **Ecosystem todos targeting this repo** (Step 1 of orientation) —
`get_state_summary()` returns all open tasks across all workstreams. The session `get_state_summary()` returns all open tasks across all workstreams. The session

View File

@@ -10,7 +10,7 @@ The Interventions page lists every task that has been flagged `needs_human=true`
## What is a human intervention? ## What is a human intervention?
A human intervention is a task that an agent has determined cannot proceed without direct human action — for example, approving a financial decision, confirming a legal commitment, or resolving a sensitive ambiguity. Flagging a task does not change its work status; the task continues to be `todo`, `in_progress`, or `blocked` while awaiting attention. A human intervention is a task that an agent has determined cannot proceed without direct human action — for example, approving a financial decision, confirming a legal commitment, or resolving a sensitive ambiguity. Flagging a task does not change its work status; the task continues to be `wait`, `todo`, or `progress` while awaiting attention.
--- ---
@@ -26,7 +26,7 @@ A human intervention is a task that an agent has determined cannot proceed witho
## Open section ## Open section
Tasks are sorted by priority (critical → high → medium → low), then by status (blocked → in_progress → todo). Each card shows: Tasks are sorted by priority (critical → high → medium → low), then by status (wait → progress → todo). Each card shows:
| Element | Meaning | | Element | Meaning |
|---|---| |---|---|
@@ -43,7 +43,7 @@ Tasks are sorted by priority (critical → high → medium → low), then by sta
Click **Mark done** on any open card. An inline form appears requiring a **resolution comment** — a short note describing what was done. The comment is mandatory; clicking **Confirm** without entering text highlights the field in red and does nothing. Click **Mark done** on any open card. An inline form appears requiring a **resolution comment** — a short note describing what was done. The comment is mandatory; clicking **Confirm** without entering text highlights the field in red and does nothing.
Once confirmed, the API call sets `status = done`, `needs_human = false`, and replaces the action note with your resolution comment. The card moves to the **Completed / Cancelled** section on the next poll. Once confirmed, the API call sets `status = done`, `needs_human = false`, and replaces the action note with your resolution comment. The card moves to the **Completed / Canceled** section on the next poll.
Click **Cancel** to dismiss the form without making changes. Click **Cancel** to dismiss the form without making changes.

View File

@@ -20,8 +20,8 @@ by repository. Each bar is broken into four task-status segments:
| Colour | Segment | | Colour | Segment |
|--------|---------| |--------|---------|
| green | done | | green | done |
| blue | in progress | | purple | progress |
| orange-red | blocked | | orange | wait |
| light grey | todo | | light grey | todo |
The left axis shows the `domain / repository` label once per repository group. The left axis shows the `domain / repository` label once per repository group.

View File

@@ -42,7 +42,7 @@ These types are used by the State Hub's built-in write operations:
| `workstream_status_changed` | Workstream moved between canonical lifecycle states | | `workstream_status_changed` | Workstream moved between canonical lifecycle states |
| `workstation_advanced` | Flow-aware movement via `advance_workstation()` succeeded | | `workstation_advanced` | Flow-aware movement via `advance_workstation()` succeeded |
| `task_created` | A new task was added to a workstream | | `task_created` | A new task was added to a workstream |
| `task_status_changed` | Task moved to todo / in_progress / blocked / done / cancelled | | `task_status_changed` | Task moved to wait / todo / progress / done / cancel |
| `decision_recorded` | A decision (pending or made) was recorded | | `decision_recorded` | A decision (pending or made) was recorded |
| `decision_resolved` | A pending decision was resolved | | `decision_resolved` | A pending decision was resolved |

View File

@@ -127,7 +127,7 @@ priority: medium
| Field | Values | Description | | Field | Values | Description |
|-------|--------|-------------| |-------|--------|-------------|
| `id` | string | Unique task identifier | | `id` | string | Unique task identifier |
| `status` | `todo` \| `in_progress` \| `done` | Task state | | `status` | `wait` \| `todo` \| `progress` \| `done` \| `cancel` | Task state |
| `priority` | `high` \| `medium` \| `low` | Execution order hint | | `priority` | `high` \| `medium` \| `low` | Execution order hint |
--- ---
@@ -137,8 +137,8 @@ priority: medium
As Claude completes tasks it edits the workplan file directly: As Claude completes tasks it edits the workplan file directly:
``` ```
status: todo → status: in_progress (when starting) status: todo → status: progress (when starting)
status: in_progress → status: done (when verified complete) status: progress → status: done (when verified complete)
``` ```
When every task is `done`, Claude also updates the frontmatter: When every task is `done`, Claude also updates the frontmatter:

View File

@@ -5,7 +5,7 @@ title: Tasks — Reference
# Tasks — Reference # Tasks — Reference
The Tasks page shows all tasks across every workstream and domain, with live The Tasks page shows all tasks across every workstream and domain, with live
filtering, a workstation distribution chart, and a blocked-tasks highlight filtering, a workstation distribution chart, and a waiting-tasks highlight
section. section.
--- ---
@@ -17,11 +17,11 @@ compatibility.
| Workstation | Meaning | | Workstation | Meaning |
|--------|---------| |--------|---------|
| **wait** | Waiting on another actor, event, decision, input, or condition |
| **todo** | Not yet started | | **todo** | Not yet started |
| **in_progress** | Actively being worked on | | **progress** | Actively being worked on |
| **blocked** | Cannot proceed — has a blocking reason |
| **done** | Completed | | **done** | Completed |
| **cancelled** | Dropped; not counted toward totals | | **cancel** | Stopped; not counted toward totals |
--- ---
@@ -56,37 +56,37 @@ per stored workstation/status label, colour-coded:
| Colour | Status | | Colour | Status |
|--------|--------| |--------|--------|
| orange | wait |
| grey-blue | todo | | grey-blue | todo |
| blue | in_progress | | purple | progress |
| red | blocked |
| green | done | | green | done |
| light grey | cancelled | | light grey | cancel |
--- ---
## Blocked Tasks section ## Waiting Tasks section
Shows cards for every task currently in the `blocked` workstation within the Shows cards for every task currently in the `wait` workstation within the
active filter. Each card displays: active filter. Each card displays:
- Priority badge and status - Priority badge and status
- Domain and workstream context - Domain and workstream context
- Task title - Task title
- Blocking reason (amber background) - Wait reason (amber background)
--- ---
## KPI sidebar card ## KPI sidebar card
Shows four counts for the unfiltered dataset: open (todo + in_progress + Shows counts for the unfiltered dataset: open (`wait` + `todo` + `progress`),
blocked), blocked, in progress, done, and a done-% of total. waiting, progress, done, and a done-% of total.
--- ---
## Sorting ## Sorting
Tasks are sorted by status (blocked first, then in_progress, todo, done, Tasks are sorted by status (wait first, then progress, todo, done, cancel) then
cancelled) then by priority (critical → high → medium → low) within each by priority (critical → high → medium → low) within each
status group. status group.
--- ---

View File

@@ -18,7 +18,7 @@ boundary rule and routing workflows.
### Internal ### Internal
Open tasks (`todo`, `in_progress`, `blocked`) in **custodian domain workstreams** Open tasks (`wait`, `todo`, `progress`) in **custodian domain workstreams**
whose title does not contain a `[repo:]` routing prefix. whose title does not contain a `[repo:]` routing prefix.
These are tasks this agent is directly responsible for and can address within These are tasks this agent is directly responsible for and can address within

View File

@@ -57,12 +57,12 @@ const pageState = (async function*() {
const counts = {}; const counts = {};
for (const t of taskList) { for (const t of taskList) {
const wid = t.workstream_id; const wid = t.workstream_id;
if (!counts[wid]) counts[wid] = {done: 0, in_progress: 0, blocked: 0, todo: 0, total: 0}; if (!counts[wid]) counts[wid] = {done: 0, progress: 0, wait: 0, todo: 0, total: 0};
counts[wid].total++; counts[wid].total++;
if (t.status === "done") counts[wid].done++; if (t.status === "done") counts[wid].done++;
else if (t.status === "in_progress") counts[wid].in_progress++; else if (t.status === "progress") counts[wid].progress++;
else if (t.status === "blocked") counts[wid].blocked++; else if (t.status === "wait") counts[wid].wait++;
else if (t.status === "todo") counts[wid].todo++; else if (t.status === "todo") counts[wid].todo++;
} }
wsAll = wsList.map(w => { wsAll = wsList.map(w => {
const repo = repoMap[w.repo_id]; const repo = repoMap[w.repo_id];
@@ -78,7 +78,7 @@ const pageState = (async function*() {
workplan_archived: workplan.archived ?? false, workplan_archived: workplan.archived ?? false,
health_labels: workplan.health_labels ?? [], health_labels: workplan.health_labels ?? [],
href: `./workstreams/${w.id}`, href: `./workstreams/${w.id}`,
...(counts[w.id] ?? {done: 0, in_progress: 0, blocked: 0, todo: 0, total: 0}), ...(counts[w.id] ?? {done: 0, progress: 0, wait: 0, todo: 0, total: 0}),
}; };
}); });
} catch (e) { } catch (e) {
@@ -233,13 +233,13 @@ for (const w of chartWs) {
_seen.add(group); _seen.add(group);
} }
const statusOrder = ["done", "in progress", "blocked", "todo"]; const statusOrder = ["done", "progress", "wait", "todo"];
const statusColors = ["#4caf50", "steelblue", "#ff7043", "#e0e0e0"]; const statusColors = ["#4caf50", "#8b5cf6", "#f59e0b", "#e0e0e0"];
const _taskRows = chartWs.flatMap(w => [ const _taskRows = chartWs.flatMap(w => [
{id: w.id, title: w.title, status: "done", count: w.done ?? 0, domain: w.domain, repo: w.repo_label, workplan: w.workplan_filename, href: w.href}, {id: w.id, title: w.title, status: "done", count: w.done ?? 0, domain: w.domain, repo: w.repo_label, workplan: w.workplan_filename, href: w.href},
{id: w.id, title: w.title, status: "in progress", count: w.in_progress ?? 0, domain: w.domain, repo: w.repo_label, workplan: w.workplan_filename, href: w.href}, {id: w.id, title: w.title, status: "progress", count: w.progress ?? 0, domain: w.domain, repo: w.repo_label, workplan: w.workplan_filename, href: w.href},
{id: w.id, title: w.title, status: "blocked", count: w.blocked ?? 0, domain: w.domain, repo: w.repo_label, workplan: w.workplan_filename, href: w.href}, {id: w.id, title: w.title, status: "wait", count: w.wait ?? 0, domain: w.domain, repo: w.repo_label, workplan: w.workplan_filename, href: w.href},
{id: w.id, title: w.title, status: "todo", count: w.todo ?? 0, domain: w.domain, repo: w.repo_label, workplan: w.workplan_filename, href: w.href}, {id: w.id, title: w.title, status: "todo", count: w.todo ?? 0, domain: w.domain, repo: w.repo_label, workplan: w.workplan_filename, href: w.href},
]).filter(d => d.count > 0); ]).filter(d => d.count > 0);
@@ -370,7 +370,7 @@ display(html`<div class="grid grid-cols-3" style="gap:1rem;margin-bottom:1.5rem"
## Status ## Status
```js ```js
const blockedTasks = summary.blocked_tasks ?? []; const waitingTasks = summary.waiting_tasks ?? summary.blocked_tasks ?? [];
const wsById = Object.fromEntries((summary.open_workstreams ?? []).map(w => [w.id, w])); const wsById = Object.fromEntries((summary.open_workstreams ?? []).map(w => [w.id, w]));
const todayCount = (summary.recent_progress ?? []).filter(e => const todayCount = (summary.recent_progress ?? []).filter(e =>
e.created_at?.startsWith(new Date().toISOString().slice(0, 10))).length; e.created_at?.startsWith(new Date().toISOString().slice(0, 10))).length;
@@ -388,9 +388,9 @@ const statusEl = html`<div>
<p class="big-num">${decCount}</p> <p class="big-num">${decCount}</p>
<small>${decisions.escalated ?? 0} escalated</small> <small>${decisions.escalated ?? 0} escalated</small>
</a> </a>
<div class="card card-link ${blockedTasks.length > 0 ? 'warn' : ''}" data-toggle="blocked-panel"> <div class="card card-link ${waitingTasks.length > 0 ? 'warn' : ''}" data-toggle="waiting-panel">
<h3>Blocked Tasks</h3> <h3>Waiting Tasks</h3>
<p class="big-num">${blockedTasks.length}</p> <p class="big-num">${waitingTasks.length}</p>
<small>of ${tasks.total ?? 0} total · click to expand</small> <small>of ${tasks.total ?? 0} total · click to expand</small>
</div> </div>
<a class="card card-link" href="#recent-activity"> <a class="card card-link" href="#recent-activity">
@@ -400,10 +400,10 @@ const statusEl = html`<div>
</a> </a>
</div> </div>
<div id="blocked-panel" style="display:none;margin-bottom:1rem"> <div id="waiting-panel" style="display:none;margin-bottom:1rem">
${blockedTasks.length === 0 ${waitingTasks.length === 0
? html`<p class="dim" style="padding:0.5rem 0">No tasks currently blocked.</p>` ? html`<p class="dim" style="padding:0.5rem 0">No tasks currently waiting.</p>`
: html`<div class="bt-list">${blockedTasks.map(t => { : html`<div class="bt-list">${waitingTasks.map(t => {
const wsName = wsById[t.workstream_id]?.title ?? t.workstream_id?.slice(0,8) ?? "—"; const wsName = wsById[t.workstream_id]?.title ?? t.workstream_id?.slice(0,8) ?? "—";
return html`<div class="bt-row"> return html`<div class="bt-row">
<div class="bt-meta">${wsName}</div> <div class="bt-meta">${wsName}</div>
@@ -415,11 +415,11 @@ const statusEl = html`<div>
</div> </div>
</div>`; </div>`;
statusEl.querySelector('[data-toggle="blocked-panel"]').addEventListener('click', () => { statusEl.querySelector('[data-toggle="waiting-panel"]').addEventListener('click', () => {
const panel = statusEl.querySelector('#blocked-panel'); const panel = statusEl.querySelector('#waiting-panel');
const isOpen = panel.style.display !== 'none'; const isOpen = panel.style.display !== 'none';
panel.style.display = isOpen ? 'none' : 'block'; panel.style.display = isOpen ? 'none' : 'block';
statusEl.querySelector('[data-toggle="blocked-panel"] small').textContent = statusEl.querySelector('[data-toggle="waiting-panel"] small').textContent =
isOpen ? `of ${tasks.total ?? 0} total · click to expand` : `of ${tasks.total ?? 0} total · click to collapse`; isOpen ? `of ${tasks.total ?? 0} total · click to expand` : `of ${tasks.total ?? 0} total · click to collapse`;
}); });

View File

@@ -51,7 +51,7 @@ const _ts = interventionState.ts;
``` ```
```js ```js
const OPEN_STATUSES = new Set(["todo", "in_progress", "blocked"]); const OPEN_STATUSES = new Set(["wait", "todo", "progress"]);
// open = currently flagged for human action // open = currently flagged for human action
// closed = previously flagged (intervention_note records the resolution comment) // closed = previously flagged (intervention_note records the resolution comment)
const open = tasks.filter(t => t.needs_human === true); const open = tasks.filter(t => t.needs_human === true);
@@ -125,7 +125,7 @@ Tasks flagged `needs_human=true` — actions only a human can take.
import {openActionConfirm} from "./components/action-confirm.js"; import {openActionConfirm} from "./components/action-confirm.js";
const PRIORITY_ORDER = {critical: 0, high: 1, medium: 2, low: 3}; const PRIORITY_ORDER = {critical: 0, high: 1, medium: 2, low: 3};
const STATUS_ORDER = {blocked: 0, in_progress: 1, todo: 2}; const STATUS_ORDER = {wait: 0, progress: 1, todo: 2};
function sortTasks(arr) { function sortTasks(arr) {
return [...arr].sort((a, b) => { return [...arr].sort((a, b) => {
@@ -217,11 +217,11 @@ if (closed.length === 0) {
.task-priority-medium { background: #dbeafe; color: #1e40af; } .task-priority-medium { background: #dbeafe; color: #1e40af; }
.task-priority-low { background: #f1f5f9; color: #475569; } .task-priority-low { background: #f1f5f9; color: #475569; }
.task-status-chip { display: inline-block; padding: 0.1rem 0.45rem; border-radius: 10px; font-size: 0.7rem; font-weight: 500; } .task-status-chip { display: inline-block; padding: 0.1rem 0.45rem; border-radius: 10px; font-size: 0.7rem; font-weight: 500; }
.status-chip-blocked { background: #fee2e2; color: #991b1b; } .status-chip-wait { background: #fef3c7; color: #92400e; }
.status-chip-in_progress { background: #dbeafe; color: #1e40af; } .status-chip-progress { background: #ede9fe; color: #5b21b6; }
.status-chip-todo { background: #f1f5f9; color: #475569; } .status-chip-todo { background: #f1f5f9; color: #475569; }
.status-chip-done { background: #dcfce7; color: #166534; } .status-chip-done { background: #dcfce7; color: #166534; }
.status-chip-cancelled { background: #f1f5f9; color: #64748b; } .status-chip-cancel { background: #f1f5f9; color: #64748b; }
.task-context { color: var(--theme-foreground-muted, #666); } .task-context { color: var(--theme-foreground-muted, #666); }
.task-ws-name { font-style: italic; } .task-ws-name { font-style: italic; }
.dim { color: gray; font-style: italic; } .dim { color: gray; font-style: italic; }

View File

@@ -50,7 +50,7 @@ const _ts = taskState.ts;
```js ```js
import {MultiSelect} from "./components/multiselect.js"; import {MultiSelect} from "./components/multiselect.js";
const STATUSES = ["todo", "in_progress", "blocked", "done", "cancelled"]; const STATUSES = ["wait", "todo", "progress", "done", "cancel"];
const PRIORITIES = ["critical", "high", "medium", "low"]; const PRIORITIES = ["critical", "high", "medium", "low"];
const _domainsResp = await fetch(`${API}/domains/?status=active`).catch(() => null); const _domainsResp = await fetch(`${API}/domains/?status=active`).catch(() => null);
const DOMAINS = _domainsResp?.ok const DOMAINS = _domainsResp?.ok
@@ -95,11 +95,11 @@ import {openEntityModal, buildEntityTable} from "./components/entity-modal.js";
import {statusControl, TASK_STATUSES} from "./components/status-control.js"; import {statusControl, TASK_STATUSES} from "./components/status-control.js";
// ── KPI sidebar card ───────────────────────────────────────────────────────── // ── KPI sidebar card ─────────────────────────────────────────────────────────
const _open = data.filter(t => ["todo", "in_progress", "blocked"].includes(t.status)); const _open = data.filter(t => ["wait", "todo", "progress"].includes(t.status));
const _blocked = data.filter(t => t.status === "blocked"); const _waiting = data.filter(t => t.status === "wait");
const _inProg = data.filter(t => t.status === "in_progress"); const _inProg = data.filter(t => t.status === "progress");
const _done = data.filter(t => t.status === "done"); const _done = data.filter(t => t.status === "done");
const _total = data.filter(t => t.status !== "cancelled").length; const _total = data.filter(t => t.status !== "cancel").length;
const _donePct = _total > 0 ? Math.round(_done.length / _total * 100) : 0; const _donePct = _total > 0 ? Math.round(_done.length / _total * 100) : 0;
const _kpiBox = html`<div class="kpi-infobox"> const _kpiBox = html`<div class="kpi-infobox">
@@ -111,13 +111,13 @@ const _kpiBox = html`<div class="kpi-infobox">
</div> </div>
</div> </div>
<div class="kpi-row"> <div class="kpi-row">
<span class="kpi-row-label">blocked</span> <span class="kpi-row-label">waiting</span>
<div class="kpi-row-right"> <div class="kpi-row-right">
<div class="kpi-row-value" style="color:${_blocked.length > 0 ? '#dc2626' : 'inherit'}">${_blocked.length}</div> <div class="kpi-row-value" style="color:${_waiting.length > 0 ? '#d97706' : 'inherit'}">${_waiting.length}</div>
</div> </div>
</div> </div>
<div class="kpi-row"> <div class="kpi-row">
<span class="kpi-row-label">in progress</span> <span class="kpi-row-label">progress</span>
<div class="kpi-row-right"> <div class="kpi-row-right">
<div class="kpi-row-value">${_inProg.length}</div> <div class="kpi-row-value">${_inProg.length}</div>
</div> </div>
@@ -154,11 +154,11 @@ injectTocTop("live-indicator", _liveEl);
import * as Plot from "npm:@observablehq/plot"; import * as Plot from "npm:@observablehq/plot";
const STATUS_COLOR = { const STATUS_COLOR = {
wait: "#f59e0b",
todo: "#94a3b8", todo: "#94a3b8",
in_progress: "#3b82f6", progress: "#8b5cf6",
blocked: "#ef4444",
done: "#22c55e", done: "#22c55e",
cancelled: "#cbd5e1", cancel: "#cbd5e1",
}; };
const byStatus = STATUSES const byStatus = STATUSES
@@ -178,16 +178,16 @@ display(byStatus.length === 0
); );
``` ```
## Blocked Tasks ## Waiting Tasks
```js ```js
const _blockedInFilter = filtered.filter(t => t.status === "blocked"); const _waitingInFilter = filtered.filter(t => t.status === "wait");
if (_blockedInFilter.length === 0) { if (_waitingInFilter.length === 0) {
display(html`<p class="dim">No blocked tasks in current filter. ✓</p>`); display(html`<p class="dim">No waiting tasks in current filter. ✓</p>`);
} else { } else {
display(html`<div class="task-blocked-list">${_blockedInFilter.map(t => html` display(html`<div class="task-waiting-list">${_waitingInFilter.map(t => html`
<div class="task-blocked-item entity-row" onclick=${() => openEntityModal(t, "task")}> <div class="task-waiting-item entity-row" onclick=${() => openEntityModal(t, "task")}>
<div class="task-item-header"> <div class="task-item-header">
<span class="task-badge task-priority-${t.priority}">${t.priority}</span> <span class="task-badge task-priority-${t.priority}">${t.priority}</span>
<span class="task-context">${t.domain}</span> <span class="task-context">${t.domain}</span>
@@ -196,7 +196,7 @@ if (_blockedInFilter.length === 0) {
${t.assignee ? html`<span class="task-assignee">@${t.assignee}</span>` : ""} ${t.assignee ? html`<span class="task-assignee">@${t.assignee}</span>` : ""}
</div> </div>
<div class="task-title">${t.title}</div> <div class="task-title">${t.title}</div>
${t.blocking_reason ? html`<div class="task-blocking-reason">⊘ ${t.blocking_reason}</div>` : ""} ${t.blocking_reason ? html`<div class="task-wait-reason">⊘ ${t.blocking_reason}</div>` : ""}
</div> </div>
`)}</div>`); `)}</div>`);
} }
@@ -209,7 +209,7 @@ display(_filtersForm);
display(html`<p><strong>${filtered.length}</strong> tasks shown.</p>`); display(html`<p><strong>${filtered.length}</strong> tasks shown.</p>`);
const PRIORITY_ORDER = {critical: 0, high: 1, medium: 2, low: 3}; const PRIORITY_ORDER = {critical: 0, high: 1, medium: 2, low: 3};
const STATUS_ORDER = {blocked: 0, in_progress: 1, todo: 2, done: 3, cancelled: 4}; const STATUS_ORDER = {wait: 0, progress: 1, todo: 2, done: 3, cancel: 4};
const sorted = [...filtered].sort((a, b) => { const sorted = [...filtered].sort((a, b) => {
const sd = (STATUS_ORDER[a.status] ?? 9) - (STATUS_ORDER[b.status] ?? 9); const sd = (STATUS_ORDER[a.status] ?? 9) - (STATUS_ORDER[b.status] ?? 9);
@@ -246,9 +246,9 @@ display(buildEntityTable(
/* ── Filters ──────────────────────────────────────────────────────────────── */ /* ── Filters ──────────────────────────────────────────────────────────────── */
/* ── Blocked task cards ───────────────────────────────────────────────────── */ /* ── Waiting task cards ───────────────────────────────────────────────────── */
.task-blocked-list { display: flex; flex-direction: column; gap: 0.5rem; } .task-waiting-list { display: flex; flex-direction: column; gap: 0.5rem; }
.task-blocked-item { border-left: 3px solid #ef4444; border-radius: 0 6px 6px 0; background: var(--theme-background-alt); padding: 0.65rem 0.9rem; } .task-waiting-item { border-left: 3px solid #f59e0b; border-radius: 0 6px 6px 0; background: var(--theme-background-alt); padding: 0.65rem 0.9rem; }
.task-item-header { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin-bottom: 0.3rem; font-size: 0.75rem; } .task-item-header { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin-bottom: 0.3rem; font-size: 0.75rem; }
.task-badge { display: inline-block; padding: 0.1rem 0.45rem; border-radius: 10px; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; } .task-badge { display: inline-block; padding: 0.1rem 0.45rem; border-radius: 10px; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
.task-priority-critical { background: #fee2e2; color: #991b1b; } .task-priority-critical { background: #fee2e2; color: #991b1b; }
@@ -260,7 +260,7 @@ display(buildEntityTable(
.task-due { color: #dc2626; font-weight: 600; } .task-due { color: #dc2626; font-weight: 600; }
.task-assignee { color: var(--theme-foreground-muted, #888); } .task-assignee { color: var(--theme-foreground-muted, #888); }
.task-title { font-weight: 600; font-size: 0.95rem; margin-bottom: 0.15rem; } .task-title { font-weight: 600; font-size: 0.95rem; margin-bottom: 0.15rem; }
.task-blocking-reason { font-size: 0.8rem; color: #b45309; background: #fef3c7; border-radius: 4px; padding: 0.2rem 0.5rem; margin-top: 0.25rem; } .task-wait-reason { font-size: 0.8rem; color: #b45309; background: #fef3c7; border-radius: 4px; padding: 0.2rem 0.5rem; margin-top: 0.25rem; }
/* ── Utility ──────────────────────────────────────────────────────────────── */ /* ── Utility ──────────────────────────────────────────────────────────────── */
.dim { color: gray; font-style: italic; } .dim { color: gray; font-style: italic; }

View File

@@ -60,7 +60,7 @@ const _ts = todoState.ts;
```js ```js
// ── Classify tasks ──────────────────────────────────────────────────────────── // ── Classify tasks ────────────────────────────────────────────────────────────
const OPEN_STATUSES = new Set(["todo", "in_progress", "blocked"]); const OPEN_STATUSES = new Set(["wait", "todo", "progress"]);
// Internal: custodian domain, open, no [repo:] routing prefix // Internal: custodian domain, open, no [repo:] routing prefix
const internal = tasks.filter(t => const internal = tasks.filter(t =>
@@ -141,7 +141,7 @@ without a cross-repo routing prefix.
```js ```js
const PRIORITY_ORDER = {critical: 0, high: 1, medium: 2, low: 3}; const PRIORITY_ORDER = {critical: 0, high: 1, medium: 2, low: 3};
const STATUS_ORDER = {blocked: 0, in_progress: 1, todo: 2}; const STATUS_ORDER = {wait: 0, progress: 1, todo: 2};
function sortTasks(arr) { function sortTasks(arr) {
return [...arr].sort((a, b) => { return [...arr].sort((a, b) => {
@@ -249,8 +249,8 @@ if (improvements.length === 0) {
/* ── Task list ────────────────────────────────────────────────────────────── */ /* ── Task list ────────────────────────────────────────────────────────────── */
.task-list { display: flex; flex-direction: column; gap: 0.5rem; } .task-list { display: flex; flex-direction: column; gap: 0.5rem; }
.task-item { border-left: 3px solid var(--theme-foreground-faint, #ccc); border-radius: 0 6px 6px 0; background: var(--theme-background-alt); padding: 0.65rem 0.9rem; } .task-item { border-left: 3px solid var(--theme-foreground-faint, #ccc); border-radius: 0 6px 6px 0; background: var(--theme-background-alt); padding: 0.65rem 0.9rem; }
.task-item.status-blocked { border-left-color: #ef4444; } .task-item.status-wait { border-left-color: #f59e0b; }
.task-item.status-in_progress { border-left-color: #3b82f6; } .task-item.status-progress { border-left-color: #8b5cf6; }
.task-item.status-todo { border-left-color: #94a3b8; } .task-item.status-todo { border-left-color: #94a3b8; }
.task-item-header { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin-bottom: 0.3rem; font-size: 0.75rem; } .task-item-header { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem; margin-bottom: 0.3rem; font-size: 0.75rem; }
.task-badge { display: inline-block; padding: 0.1rem 0.45rem; border-radius: 10px; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; } .task-badge { display: inline-block; padding: 0.1rem 0.45rem; border-radius: 10px; font-size: 0.7rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
@@ -259,8 +259,8 @@ if (improvements.length === 0) {
.task-priority-medium { background: #dbeafe; color: #1e40af; } .task-priority-medium { background: #dbeafe; color: #1e40af; }
.task-priority-low { background: #f1f5f9; color: #475569; } .task-priority-low { background: #f1f5f9; color: #475569; }
.task-status-chip { display: inline-block; padding: 0.1rem 0.45rem; border-radius: 10px; font-size: 0.7rem; font-weight: 500; } .task-status-chip { display: inline-block; padding: 0.1rem 0.45rem; border-radius: 10px; font-size: 0.7rem; font-weight: 500; }
.status-chip-blocked { background: #fee2e2; color: #991b1b; } .status-chip-wait { background: #fef3c7; color: #92400e; }
.status-chip-in_progress { background: #dbeafe; color: #1e40af; } .status-chip-progress { background: #ede9fe; color: #5b21b6; }
.status-chip-todo { background: #f1f5f9; color: #475569; } .status-chip-todo { background: #f1f5f9; color: #475569; }
.task-context { color: var(--theme-foreground-muted, #666); } .task-context { color: var(--theme-foreground-muted, #666); }
.task-ws-name { font-style: italic; } .task-ws-name { font-style: italic; }

View File

@@ -44,7 +44,7 @@ if (raw.error) {
<div><span>Tasks</span><strong>${taskRows.length}</strong></div> <div><span>Tasks</span><strong>${taskRows.length}</strong></div>
</div>`); </div>`);
const statusOrder = {blocked: 0, in_progress: 1, todo: 2, done: 3, cancelled: 4}; const statusOrder = {wait: 0, progress: 1, todo: 2, done: 3, cancel: 4};
const sortedTasks = [...taskRows].sort((a, b) => { const sortedTasks = [...taskRows].sort((a, b) => {
const statusCompare = (statusOrder[a.status] ?? 9) - (statusOrder[b.status] ?? 9); const statusCompare = (statusOrder[a.status] ?? 9) - (statusOrder[b.status] ?? 9);
if (statusCompare !== 0) return statusCompare; if (statusCompare !== 0) return statusCompare;
@@ -131,8 +131,8 @@ if (raw.error) {
white-space: nowrap; white-space: nowrap;
} }
.task-status-done { background: #e8f5e9; color: #1b5e20; } .task-status-done { background: #e8f5e9; color: #1b5e20; }
.task-status-in_progress { background: #e3f2fd; color: #0d47a1; } .task-status-progress { background: #ede9fe; color: #5b21b6; }
.task-status-blocked { background: #fff3e0; color: #bf360c; } .task-status-wait { background: #fff3e0; color: #bf360c; }
.task-status-todo { background: #f1f5f9; color: #334155; } .task-status-todo { background: #f1f5f9; color: #334155; }
.task-status-cancelled { background: #f3f4f6; color: #6b7280; } .task-status-cancel { background: #f3f4f6; color: #6b7280; }
</style> </style>

View File

@@ -72,7 +72,7 @@ function okResponse(overrides = {}) {
target_id: "00000000-0000-0000-0000-000000000001", target_id: "00000000-0000-0000-0000-000000000001",
actor: "dashboard", actor: "dashboard",
current_status: "todo", current_status: "todo",
target_status: "in_progress", target_status: "progress",
file_backed: true, file_backed: true,
archived_file: false, archived_file: false,
task_linked: true, task_linked: true,
@@ -102,23 +102,23 @@ test("status control posts dashboard changes through reconciliation", async () =
const root = statusControl({ const root = statusControl({
entity, entity,
type: "task", type: "task",
statuses: ["todo", "in_progress"], statuses: ["todo", "progress"],
onSaved: (updated, result) => { onSaved: (updated, result) => {
saved = {updated, result}; saved = {updated, result};
}, },
}); });
const [select, message] = root.children; const [select, message] = root.children;
select.value = "in_progress"; select.value = "progress";
await select.listeners.change(); await select.listeners.change();
assert.equal(requests.length, 1); assert.equal(requests.length, 1);
assert.equal(requests[0].url, "http://127.0.0.1:8000/reconciliation/state-change"); assert.equal(requests[0].url, "http://127.0.0.1:8000/reconciliation/state-change");
assert.equal(requests[0].body.target_type, "task"); assert.equal(requests[0].body.target_type, "task");
assert.equal(requests[0].body.target_status, "in_progress"); assert.equal(requests[0].body.target_status, "progress");
assert.equal(requests[0].body.expected_current_status, "todo"); assert.equal(requests[0].body.expected_current_status, "todo");
assert.equal(requests[0].body.apply, true); assert.equal(requests[0].body.apply, true);
assert.equal(entity.status, "in_progress"); assert.equal(entity.status, "progress");
assert.equal(message.textContent, "synced"); assert.equal(message.textContent, "synced");
assert.equal(saved.result.write_through_result, "applied"); assert.equal(saved.result.write_through_result, "applied");
}); });
@@ -131,7 +131,7 @@ test("status control keeps local state on reconciliation conflicts", async () =>
ok: true, ok: true,
json: async () => okResponse({ json: async () => okResponse({
current_status: "done", current_status: "done",
target_status: "in_progress", target_status: "progress",
reconciliation_class: "deferred", reconciliation_class: "deferred",
reason: "cached task status changed from expected 'todo' to 'done'", reason: "cached task status changed from expected 'todo' to 'done'",
follow_up: "refresh the dashboard and retry the state change if it is still intended", follow_up: "refresh the dashboard and retry the state change if it is still intended",
@@ -147,14 +147,14 @@ test("status control keeps local state on reconciliation conflicts", async () =>
const root = statusControl({ const root = statusControl({
entity, entity,
type: "task", type: "task",
statuses: ["todo", "in_progress"], statuses: ["todo", "progress"],
onSaved: () => { onSaved: () => {
saved = true; saved = true;
}, },
}); });
const [select, message] = root.children; const [select, message] = root.children;
select.value = "in_progress"; select.value = "progress";
await select.listeners.change(); await select.listeners.change();
assert.equal(requests.length, 1); assert.equal(requests.length, 1);

View File

@@ -54,7 +54,7 @@ state hub publishes:
| `org.statehub.workstream.completed` | `api/routers/workstreams.py:update_workstream` (on transition) | | `org.statehub.workstream.completed` | `api/routers/workstreams.py:update_workstream` (on transition) |
| `org.statehub.decision.resolved` | `api/routers/decisions.py:resolve_decision_action` | | `org.statehub.decision.resolved` | `api/routers/decisions.py:resolve_decision_action` |
| `org.statehub.domain.goal.activated` | `api/routers/domain_goals.py:activate_domain_goal` | | `org.statehub.domain.goal.activated` | `api/routers/domain_goals.py:activate_domain_goal` |
| `org.statehub.task.stale` | `scripts/cleanup_stale_tasks.py` (per cancelled task) | | `org.statehub.task.stale` | `scripts/cleanup_stale_tasks.py` (per canceled task) |
All events use the shared `EventEnvelope` schema (`api/events/envelope.py`) All events use the shared `EventEnvelope` schema (`api/events/envelope.py`)
and are published via `publish_event(subject, envelope)`. Publishing is and are published via `publish_event(subject, envelope)`. Publishing is

View File

@@ -78,7 +78,7 @@ Notes:
# activity-definitions/state-hub-stale-task-cleanup.yaml # activity-definitions/state-hub-stale-task-cleanup.yaml
id: the-custodian.state-hub-stale-task-cleanup id: the-custodian.state-hub-stale-task-cleanup
description: | description: |
Daily sweep that cancels tasks still 'todo|in_progress|blocked' inside Daily sweep that cancels tasks still `wait|todo|progress` inside
finished or archived workstreams. Each cancellation also emits finished or archived workstreams. Each cancellation also emits
org.statehub.task.stale on NATS for downstream reaction. org.statehub.task.stale on NATS for downstream reaction.
trigger: trigger:

View File

@@ -46,8 +46,8 @@ exit_assertions:
- id: tasks.all_done - id: tasks.all_done
target: tasks.*.status target: tasks.*.status
op: all_eq op: all_eq
value: [done, cancelled] value: [done, cancel]
description: All child tasks are done or cancelled. description: All child tasks are done or canceled.
``` ```
Schema: Schema:
@@ -127,7 +127,7 @@ exit_blocked: true
blocking_assertions: blocking_assertions:
- id: tasks.all_done - id: tasks.all_done
passed: false passed: false
reason: "Expected all values at tasks.*.status to be in ['done', 'cancelled']; got ['done', 'todo']." reason: "Expected all values at tasks.*.status to be in ['done', 'cancel']; got ['done', 'todo']."
reachable: reachable:
- ready - ready
- active - active
@@ -136,7 +136,7 @@ unreachable:
blocking: blocking:
id: tasks.all_done id: tasks.all_done
passed: false passed: false
reason: "Expected all values at tasks.*.status to be in ['done', 'cancelled']; got ['done', 'todo']." reason: "Expected all values at tasks.*.status to be in ['done', 'cancel']; got ['done', 'todo']."
``` ```
Schema: Schema:
@@ -152,13 +152,13 @@ Schema:
### Workstreams ### Workstreams
Workstreams can express readiness for closure by asserting that child tasks Workstreams can express readiness for closure by asserting that child tasks
are `done` or `cancelled`. They can express dependency blocking by checking that are `done` or `cancel`. They can express dependency blocking by checking that
all dependency workstreams have reached `finished` or `archived`. all dependency workstreams have reached `finished` or `archived`.
### Tasks ### Tasks
Tasks can express human intervention with the existing `needs_human` flag. Tasks can express human intervention with the existing `needs_human` flag.
Returning from `blocked` to `in_progress` is an entry assertion over that same Returning from `wait` to `progress` is an entry assertion over that same
flag. Lightweight completion remains unconstrained because curator intent is flag. Lightweight completion remains unconstrained because curator intent is
the deciding signal. the deciding signal.

View File

@@ -0,0 +1,93 @@
# Task State Attached Repo Impact
Date: 2026-05-25
Interface change: `649102a2-4373-4621-9848-cc257e67c262`
Status: published
## Notice Channel
State Hub published a breaking schema interface change from `state-hub` to all
active attached repos except `state-hub` itself.
The notice points target repos to `docs/task-state-canon-migration.md` and asks
their local agents to update any State Hub task-status references:
```text
blocked -> wait
in_progress -> progress
cancelled/canceled -> cancel
todo -> todo
done -> done
```
Workstream/workplan lifecycle status is unchanged and still includes
`blocked`.
## Classification
Classification was based on `GET /repos/` plus a read-only local scan of active
repo paths. `legacy-literals` means a repo contains old words such as
`blocked`, `in_progress`, `cancelled`, `blocked_tasks`, or `TaskStatus`; the
hit can be task state, workstream state, capability/debt status, or prose, so
the owning repo agent must review context before editing.
| Repo | Local path | Classification |
|------|------------|----------------|
| activity-core | yes | file-backed; legacy-literals |
| artifact-store | yes | file-backed; legacy-literals |
| can-you-assist | yes | file-backed; legacy-literals |
| citation-engine | yes | general notice only |
| citation-evidence | yes | file-backed |
| citation-work | yes | general notice only |
| domain-tree | yes | file-backed; legacy-literals |
| evidence-anchor | yes | general notice only |
| evidence-binder | yes | general notice only |
| evidence-source | yes | general notice only |
| flex-auth | yes | file-backed; legacy-literals |
| guide-board | yes | file-backed; legacy-literals |
| helix-forge | yes | file-backed; legacy-literals |
| ihp-railiance-probe | yes | file-backed; legacy-literals |
| info-tech-canon | yes | canon source; file-backed |
| infospace-bench | yes | file-backed; legacy-literals |
| inter-hub | yes | file-backed; legacy-literals |
| issue-core | yes | file-backed; likely State Hub/task lifecycle client; legacy-literals |
| kaizen-agentic | no | general notice only |
| key-cape | yes | file-backed; legacy-literals |
| kontextual-engine | yes | file-backed; legacy-literals |
| llm-connect | yes | file-backed; legacy-literals |
| marki-docx | no | general notice only |
| markitect-filter | yes | file-backed; legacy-literals |
| markitect-project | yes | file-backed; legacy-literals |
| markitect-quarkdown | yes | file-backed; legacy-literals |
| markitect-tool | yes | file-backed; legacy-literals |
| net-kingdom | yes | file-backed; legacy-literals |
| open-cmis-tck | yes | file-backed; legacy-literals |
| open-reuse | yes | file-backed; legacy-literals |
| ops-bridge | yes | file-backed; legacy-literals |
| ops-warden | yes | file-backed; legacy-literals |
| phase-memory | yes | file-backed; legacy-literals |
| railiance-apps | yes | file-backed; legacy-literals |
| railiance-cluster | yes | file-backed; legacy-literals |
| railiance-enablement | yes | file-backed; legacy-literals |
| railiance-fabric | yes | file-backed; legacy-literals |
| railiance-hosts | yes | file-backed; legacy-literals |
| railiance-infra | yes | file-backed; legacy-literals |
| railiance-platform | yes | file-backed; legacy-literals |
| repo-scoping | yes | file-backed; legacy-literals |
| the-custodian | yes | file-backed; likely State Hub/task lifecycle client; legacy-literals |
| user-engine | yes | file-backed |
| vergabe-teilnahme | yes | file-backed; legacy-literals |
| vergabe_teilnahme | yes | file-backed; legacy-literals |
## Requested Repo-Agent Action
Each repo agent should:
1. Read its State Hub inbox notice for interface change
`649102a2-4373-4621-9848-cc257e67c262`.
2. Search local agent instructions, workplans, scripts, docs, tests,
dashboards, and API clients for State Hub task-status values.
3. Replace task-state usage with canonical values and leave unrelated
workstream/capability/debt statuses unchanged.
4. Report completion by replying in State Hub or resolving the interface change
for that repo once local adaptation is complete.

View File

@@ -0,0 +1,90 @@
# Task State Canon Migration
Date: 2026-05-25
Status: implementation source
## Canon Source
State Hub follows the task lifecycle defined by InfoTechCanon:
- `info-tech-canon/infospace/models/task/InfoTechCanonTaskModel.md`
- `info-tech-canon/seeds/InfoTechCanonTaskModel_RC1_seed.md`
The canon defines symbolic codes and lowercase stored values:
| Code | Stored value | Meaning |
|------|--------------|---------|
| `WAIT` | `wait` | Blocked, paused, or waiting on another actor, event, decision, input, or condition. |
| `TODO` | `todo` | Ready or planned work that is not actively being worked. |
| `PROG` | `progress` | Active work in focus. |
| `DONE` | `done` | Completed successfully. |
| `CNCL` | `cancel` | Stopped because the work is no longer relevant, wanted, or valid. |
State Hub stores lowercase values, not uppercase codes.
## State Hub Profile
State Hub task status is separate from workstream/workplan lifecycle status.
Workstream frontmatter continues to use:
```text
proposed, ready, active, blocked, backlog, finished, archived
```
Task status now uses:
```text
wait, todo, progress, done, cancel
```
Compatibility aliases accepted during the migration window:
| Legacy value | Canon value |
|--------------|-------------|
| `blocked` | `wait` |
| `waiting` | `wait` |
| `in_progress` | `progress` |
| `cancelled` | `cancel` |
| `canceled` | `cancel` |
`done` and `cancel` are terminal for normal planning and completion checks.
`cancel` is final. `done` can be reopened only by an explicit workflow/profile
rule and should not be treated as normal progress churn.
## Wait Versus Human Intervention
`wait` is broader than the old `blocked` task status. It can mean blocked,
paused, waiting on another actor, waiting on an event, waiting on input, or
waiting on a decision.
State Hub keeps these fields separate:
- `status=wait`: the task is not actively progressing;
- `blocking_reason`: explanatory text about the wait condition;
- `needs_human=true`: the wait requires direct human action;
- `intervention_note`: human-facing instruction or resolution note.
A task can be `wait` without `needs_human=true`. A human intervention is
represented by `needs_human=true`, not by status alone.
## Attached Repo Adaptation Brief
Repos attached to State Hub should update local agent instructions, workplan
examples, scripts, docs, dashboards, and tests if they mention State Hub task
statuses.
Migration map:
```text
blocked -> wait
in_progress -> progress
cancelled -> cancel
canceled -> cancel
todo -> todo
done -> done
```
During the compatibility window, State Hub accepts legacy aliases in API and
workplan-file inputs, but writebacks prefer canonical values. Attached repos
should report completion by replying to their State Hub inbox message or by
closing the targeted `[repo:<slug>]` ecosystem task created for the adaptation.

View File

@@ -16,8 +16,8 @@ This makes file-backed workplans harder to reason about. A workplan can be a
good proposal but not ready to execute; a ready workplan can become stale after good proposal but not ready to execute; a ready workplan can become stale after
the repo changes; parked backlog work should not clutter the current work view. the repo changes; parked backlog work should not clutter the current work view.
Task status should remain separate. Tasks can keep using Task status should remain separate. Tasks use the InfoTechCanon-aligned
`todo`, `in_progress`, `blocked`, `done`, and `cancelled`. `wait`, `todo`, `progress`, `done`, and `cancel` lifecycle.
## Proposed Canonical Workplan States ## Proposed Canonical Workplan States

View File

@@ -1,11 +1,15 @@
id: custodian.task.v1 id: custodian.task.v1
entity_type: task entity_type: task
workstations: workstations:
- name: wait
description: Task is waiting on another actor, event, decision, input, or condition.
entry_assertions: []
exit_assertions: []
- name: todo - name: todo
description: Task is known but not currently underway. description: Task is known but not currently underway.
entry_assertions: [] entry_assertions: []
exit_assertions: [] exit_assertions: []
- name: in_progress - name: progress
description: Task is being actively worked. description: Task is being actively worked.
entry_assertions: entry_assertions:
- id: task.needs_human_false - id: task.needs_human_false
@@ -19,25 +23,11 @@ workstations:
op: all_eq op: all_eq
value: false value: false
description: Human intervention is not currently required. description: Human intervention is not currently required.
- name: blocked
description: Task is blocked by a human decision or unavailable input.
entry_assertions:
- id: task.needs_human_true
target: needs_human
op: all_eq
value: true
description: The task requires human intervention.
exit_assertions:
- id: task.needs_human_false
target: needs_human
op: all_eq
value: false
description: Human intervention has been cleared.
- name: done - name: done
description: Task is complete. description: Task is complete.
entry_assertions: [] entry_assertions: []
exit_assertions: [] exit_assertions: []
- name: cancelled - name: cancel
description: Task is intentionally not being completed. description: Task is intentionally not being completed.
entry_assertions: [] entry_assertions: []
exit_assertions: [] exit_assertions: []

View File

@@ -50,8 +50,8 @@ workstations:
op: all_eq op: all_eq
value: value:
- done - done
- cancelled - cancel
description: All child tasks are done or cancelled. description: All child tasks are done or canceled.
exit_assertions: [] exit_assertions: []
- name: archived - name: archived
description: Closed work has been moved out of the active set. description: Closed work has been moved out of the active set.

View File

@@ -25,10 +25,10 @@ Do not use them as a substitute for formal work definition inside the domain rep
| Tool | Key Args | When to use | | Tool | Key Args | When to use |
|------|----------|-------------| |------|----------|-------------|
| `get_domain_summary(domain_slug)` | `domain_slug`: e.g. `"railiance"` | **Domain session start.** Scoped snapshot: active workstreams, blocking decisions, last 5 events, repo SBOM status, compact capabilities list — ~10% of get_state_summary() token cost. | | `get_domain_summary(domain_slug)` | `domain_slug`: e.g. `"railiance"` | **Domain session start.** Scoped snapshot: active workstreams, blocking decisions, last 5 events, repo SBOM status, compact capabilities list — ~10% of get_state_summary() token cost. |
| `get_state_summary()` | — | **Cross-domain work / custodian sessions.** Full snapshot: totals, all blocking decisions, all blocked tasks, all open workstreams, last 20 events. Large (~10k tokens). | | `get_state_summary()` | — | **Cross-domain work / custodian sessions.** Full snapshot: totals, all blocking decisions, waiting tasks, all open workstreams, last 20 events. Large (~10k tokens). |
| `get_topic(slug)` | `slug`: e.g. `"markitect"` | Deep-dive on one topic + its workstreams + recent events. | | `get_topic(slug)` | `slug`: e.g. `"markitect"` | Deep-dive on one topic + its workstreams + recent events. |
| `list_tasks(workstream_id, status?)` | `workstream_id`: UUID (required); `status?`: todo/in_progress/blocked/done/cancelled | List all tasks in a workstream. Use this to look up task UUIDs before calling `update_task_status`, or to verify which workplan tasks are already synced to the DB. | | `list_tasks(workstream_id, status?)` | `workstream_id`: UUID (required); `status?`: wait/todo/progress/done/cancel | List all tasks in a workstream. Use this to look up task UUIDs before calling `update_task_status`, or to verify which workplan tasks are already synced to the DB. |
| `list_blocked_tasks(workstream_id?)` | optional filter | Surface all impediments, optionally scoped to one workstream. | | `list_blocked_tasks(workstream_id?)` | optional filter | Legacy name: surfaces `wait` tasks, optionally scoped to one workstream. |
| `list_pending_decisions(topic_id?)` | optional filter | Decisions holding up work, sorted by deadline. | | `list_pending_decisions(topic_id?)` | optional filter | Decisions holding up work, sorted by deadline. |
| `get_recent_progress(limit, since?)` | `limit` default 20; `since` ISO datetime | Reconstruct recent session history. | | `get_recent_progress(limit, since?)` | `limit` default 20; `since` ISO datetime | Reconstruct recent session history. |
| `get_capability_profile(domain_slug?)` | `domain_slug`: optional domain slug | **Capability deep-dive.** Returns repos → capabilities tree for one domain or all active domains. Includes descriptions and keywords. For cross-domain architectural discussion or when a worker needs to understand what a domain provides without checking out its repos. | | `get_capability_profile(domain_slug?)` | `domain_slug`: optional domain slug | **Capability deep-dive.** Returns repos → capabilities tree for one domain or all active domains. Includes descriptions and keywords. For cross-domain architectural discussion or when a worker needs to understand what a domain provides without checking out its repos. |
@@ -56,7 +56,7 @@ Do not use them as a substitute for formal work definition inside the domain rep
|------|----------|-------| |------|----------|-------|
| `create_workstream(topic_id, title, ...)` | `slug?`; `owner?`; `description?`; `due_date?` | Creates workstream under a topic. Use `get_state_summary()` to find topic IDs. | | `create_workstream(topic_id, title, ...)` | `slug?`; `owner?`; `description?`; `due_date?` | Creates workstream under a topic. Use `get_state_summary()` to find topic IDs. |
| `create_task(workstream_id, title, ...)` | `priority`: low/medium/high/critical; `assignee?`; `due_date?` | Creates task under a workstream. | | `create_task(workstream_id, title, ...)` | `priority`: low/medium/high/critical; `assignee?`; `due_date?` | Creates task under a workstream. |
| `update_task_status(task_id, status, ...)` | `status`: todo/in_progress/blocked/done/cancelled; `blocking_reason` required when blocked | | | `update_task_status(task_id, status, ...)` | `status`: wait/todo/progress/done/cancel; `blocking_reason?` describes wait conditions | Legacy aliases `blocked`, `in_progress`, `cancelled`, and `canceled` are accepted during migration. |
| `update_workstream_status(workstream_id, status)` | `status`: proposed/ready/active/blocked/backlog/finished/archived | Thin shortcut — use `update_workstream` for full field control. | | `update_workstream_status(workstream_id, status)` | `status`: proposed/ready/active/blocked/backlog/finished/archived | Thin shortcut — use `update_workstream` for full field control. |
| `update_workstream(workstream_id, ...)` | `title?`; `description?`; `owner?`; `due_date?`; `repo_goal_id?`; `status?` | Patch any subset of workstream fields. Pass empty string for `repo_goal_id` to clear the link. | | `update_workstream(workstream_id, ...)` | `title?`; `description?`; `owner?`; `due_date?`; `repo_goal_id?`; `status?` | Patch any subset of workstream fields. Pass empty string for `repo_goal_id` to clear the link. |
@@ -115,7 +115,7 @@ Agents should call `record_token_event` (or pass `tokens_in`/`tokens_out` via
| `state://topics` | Active topics list | | `state://topics` | Active topics list |
| `state://workstreams/{topic_slug}` | Workstreams for a topic (by slug) | | `state://workstreams/{topic_slug}` | Workstreams for a topic (by slug) |
| `state://decisions/blocking` | All pending decisions | | `state://decisions/blocking` | All pending decisions |
| `state://tasks/blocked` | All blocked tasks | | `state://tasks/blocked` | Legacy resource name; returns all `wait` tasks |
--- ---

View File

@@ -129,8 +129,8 @@ def resource_blocking_decisions() -> str:
@mcp.resource("state://tasks/blocked") @mcp.resource("state://tasks/blocked")
def resource_blocked_tasks() -> str: def resource_blocked_tasks() -> str:
"""All tasks with status=blocked.""" """All tasks with status=wait. Legacy resource name kept for compatibility."""
return json.dumps(_get("/tasks", {"status": "blocked"}), indent=2) return json.dumps(_get("/tasks", {"status": "wait"}), indent=2)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -142,7 +142,7 @@ def get_state_summary() -> str:
"""Primary orientation tool. Call at the start of every session. """Primary orientation tool. Call at the start of every session.
Returns a full snapshot: topic/workstream/task/decision totals, blocking Returns a full snapshot: topic/workstream/task/decision totals, blocking
decisions, blocked tasks, open workstreams, and the 20 most recent events. decisions, waiting tasks, open workstreams, and the 20 most recent events.
NOTE: This response is large (~10k tokens). When working inside a single NOTE: This response is large (~10k tokens). When working inside a single
registered domain repo, use get_domain_summary(domain_slug) instead — registered domain repo, use get_domain_summary(domain_slug) instead —
@@ -315,7 +315,7 @@ def list_tasks(workstream_id: str, status: str | None = None) -> str:
Args: Args:
workstream_id: UUID of the workstream (required). workstream_id: UUID of the workstream (required).
status: Optional filter — todo | in_progress | blocked | done | cancelled. status: Optional filter — wait | todo | progress | done | cancel.
Returns [{id, title, status, priority, assignee, due_date, needs_human}] for every Returns [{id, title, status, priority, assignee, due_date, needs_human}] for every
matching task. Use this to look up task UUIDs before calling update_task_status, matching task. Use this to look up task UUIDs before calling update_task_status,
@@ -326,8 +326,8 @@ def list_tasks(workstream_id: str, status: str | None = None) -> str:
@mcp.tool() @mcp.tool()
def list_blocked_tasks(workstream_id: str | None = None) -> str: def list_blocked_tasks(workstream_id: str | None = None) -> str:
"""List all tasks with status=blocked, optionally filtered by workstream_id.""" """List all waiting tasks, optionally filtered by workstream_id."""
return json.dumps(_get("/tasks", {"status": "blocked", "workstream_id": workstream_id}), indent=2) return json.dumps(_get("/tasks", {"status": "wait", "workstream_id": workstream_id}), indent=2)
@mcp.tool() @mcp.tool()
@@ -512,7 +512,7 @@ def update_task_status(
agent: Optional[str] = None, agent: Optional[str] = None,
session_id: Optional[str] = None, session_id: Optional[str] = None,
) -> str: ) -> str:
"""Update a task's status. blocking_reason is required when status='blocked'. """Update a task's status. Canonical status values are wait/todo/progress/done/cancel.
When status='done', always records a token event using the best available data: When status='done', always records a token event using the best available data:
Tier 1 (best): pass tokens_in + tokens_out — exact counts from the session Tier 1 (best): pass tokens_in + tokens_out — exact counts from the session
@@ -526,8 +526,8 @@ def update_task_status(
Args: Args:
task_id: UUID of the task task_id: UUID of the task
status: todo | in_progress | blocked | done | cancelled status: wait | todo | progress | done | cancel
blocking_reason: required when status=blocked blocking_reason: optional wait-condition detail
tokens_in: exact input token count for this task (Tier 1) tokens_in: exact input token count for this task (Tier 1)
tokens_out: exact output token count for this task (Tier 1) tokens_out: exact output token count for this task (Tier 1)
workplan_tokens_in: total input tokens for the whole workplan (Tier 2) workplan_tokens_in: total input tokens for the whole workplan (Tier 2)

View File

@@ -29,7 +29,7 @@ def upgrade() -> None:
"active", "blocked", "completed", "archived", name="workstreamstatus", create_type=True "active", "blocked", "completed", "archived", name="workstreamstatus", create_type=True
) )
task_status = postgresql.ENUM( task_status = postgresql.ENUM(
"todo", "in_progress", "blocked", "done", "cancelled", name="taskstatus", create_type=True "wait", "todo", "progress", "done", "cancel", name="taskstatus", create_type=True
) )
task_priority = postgresql.ENUM( task_priority = postgresql.ENUM(
"low", "medium", "high", "critical", name="taskpriority", create_type=True "low", "medium", "high", "critical", name="taskpriority", create_type=True

View File

@@ -0,0 +1,57 @@
"""adapt task status enum to InfoTechCanon task lifecycle
Revision ID: a4v5w6x7y8z9
Revises: z3u4v5w6x7y8
Create Date: 2026-05-25
"""
from alembic import op
revision = "a4v5w6x7y8z9"
down_revision = "z3u4v5w6x7y8"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("ALTER TABLE tasks ALTER COLUMN status DROP DEFAULT")
op.execute("ALTER TYPE taskstatus RENAME TO taskstatus_old")
op.execute("CREATE TYPE taskstatus AS ENUM ('wait', 'todo', 'progress', 'done', 'cancel')")
op.execute(
"""
ALTER TABLE tasks
ALTER COLUMN status TYPE taskstatus
USING (
CASE status::text
WHEN 'blocked' THEN 'wait'
WHEN 'in_progress' THEN 'progress'
WHEN 'cancelled' THEN 'cancel'
ELSE status::text
END
)::taskstatus
"""
)
op.execute("ALTER TABLE tasks ALTER COLUMN status SET DEFAULT 'todo'")
op.execute("DROP TYPE taskstatus_old")
def downgrade() -> None:
op.execute("ALTER TABLE tasks ALTER COLUMN status DROP DEFAULT")
op.execute("ALTER TYPE taskstatus RENAME TO taskstatus_new")
op.execute("CREATE TYPE taskstatus AS ENUM ('todo', 'in_progress', 'blocked', 'done', 'cancelled')")
op.execute(
"""
ALTER TABLE tasks
ALTER COLUMN status TYPE taskstatus
USING (
CASE status::text
WHEN 'wait' THEN 'blocked'
WHEN 'progress' THEN 'in_progress'
WHEN 'cancel' THEN 'cancelled'
ELSE status::text
END
)::taskstatus
"""
)
op.execute("ALTER TABLE tasks ALTER COLUMN status SET DEFAULT 'todo'")
op.execute("DROP TYPE taskstatus_new")

View File

@@ -7,7 +7,7 @@ Run via make: make cleanup-stale
Cron example: 0 3 * * * cd ~/state-hub && .venv/bin/python scripts/cleanup_stale_tasks.py Cron example: 0 3 * * * cd ~/state-hub && .venv/bin/python scripts/cleanup_stale_tasks.py
Exit codes: Exit codes:
0 — ran successfully (zero or more tasks cancelled) 0 — ran successfully (zero or more tasks canceled)
1 — API unreachable or unexpected error 1 — API unreachable or unexpected error
""" """
@@ -23,6 +23,7 @@ from datetime import datetime, timezone
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from api.workplan_status import CLOSED_WORKSTREAM_STATUSES, normalize_workstream_status from api.workplan_status import CLOSED_WORKSTREAM_STATUSES, normalize_workstream_status
from api.task_status import OPEN_TASK_STATUSES, normalize_task_status
try: try:
from api.events import EventEnvelope, publish_event, shutdown_publisher from api.events import EventEnvelope, publish_event, shutdown_publisher
@@ -32,7 +33,7 @@ except Exception: # pragma: no cover — event publishing is optional
shutdown_publisher = None # type: ignore[assignment] shutdown_publisher = None # type: ignore[assignment]
API = "http://127.0.0.1:8000" API = "http://127.0.0.1:8000"
STALE_STATUSES = {"todo", "in_progress", "blocked"} STALE_STATUSES = set(OPEN_TASK_STATUSES)
CLOSED_WS_STATUS = set(CLOSED_WORKSTREAM_STATUSES) CLOSED_WS_STATUS = set(CLOSED_WORKSTREAM_STATUSES)
@@ -91,7 +92,7 @@ def main() -> int:
stale = [ stale = [
t for t in tasks t for t in tasks
if t["status"] in STALE_STATUSES if normalize_task_status(t["status"]) in STALE_STATUSES
and t["workstream_id"] in closed_ws and t["workstream_id"] in closed_ws
] ]
@@ -115,10 +116,10 @@ def main() -> int:
try: try:
patch( patch(
f"/tasks/{t['id']}/", f"/tasks/{t['id']}/",
{"status": "cancelled", "blocking_reason": reason}, {"status": "cancel", "blocking_reason": reason},
) )
cancelled.append(t) cancelled.append(t)
print(f" cancelled [{t['priority']:8}] {t['title'][:70]}") print(f" canceled [{t['priority']:8}] {t['title'][:70]}")
if EventEnvelope is not None: if EventEnvelope is not None:
subject = "org.statehub.task.stale" subject = "org.statehub.task.stale"
nats_events.append(( nats_events.append((
@@ -155,11 +156,11 @@ def main() -> int:
by_ws.setdefault(closed_ws[t["workstream_id"]]["title"], []).append(t["title"]) by_ws.setdefault(closed_ws[t["workstream_id"]]["title"], []).append(t["title"])
summary = ( summary = (
f"Stale-task cleanup: cancelled {len(cancelled)} task(s) " f"Stale-task cleanup: canceled {len(cancelled)} task(s) "
f"across {len(by_ws)} finished workstream(s)" f"across {len(by_ws)} finished workstream(s)"
) )
detail = { detail = {
"cancelled_count": len(cancelled), "canceled_count": len(cancelled),
"by_workstream": {ws: titles for ws, titles in by_ws.items()}, "by_workstream": {ws: titles for ws, titles in by_ws.items()},
"error_count": len(errors), "error_count": len(errors),
} }
@@ -173,7 +174,7 @@ def main() -> int:
print(f"[cleanup-stale] Completed with {len(errors)} error(s).") print(f"[cleanup-stale] Completed with {len(errors)} error(s).")
return 1 return 1
print(f"[cleanup-stale] Done. {len(cancelled)} task(s) cancelled.") print(f"[cleanup-stale] Done. {len(cancelled)} task(s) canceled.")
return 0 return 0

View File

@@ -25,7 +25,7 @@ Checks:
C-19 workstream-planning-drift WARN Yes planning_priority/planning_order differs between file and DB C-19 workstream-planning-drift WARN Yes planning_priority/planning_order differs between file and DB
C-20 workstream-dependency-missing WARN Yes Workplan dependency frontmatter missing from DB graph C-20 workstream-dependency-missing WARN Yes Workplan dependency frontmatter missing from DB graph
C-22 task-description-drift WARN Yes Task description/content differs between file and DB C-22 task-description-drift WARN Yes Task description/content differs between file and DB
C-23 workstream-active-task-planning-status WARN Yes Workstream/workplan is planning while a task is in progress or blocked C-23 workstream-active-task-planning-status WARN Yes Workstream/workplan is planning while a task is progress or wait
Usage: Usage:
python scripts/consistency_check.py --repo SLUG [--fix] [--no-writeback] [--json] [--api-base URL] python scripts/consistency_check.py --repo SLUG [--fix] [--no-writeback] [--json] [--api-base URL]
@@ -67,6 +67,13 @@ from api.workplan_status import ( # noqa: E402
ready_review_status, ready_review_status,
) )
from api.services.lifecycle import should_activate_parent_for_active_tasks # noqa: E402 from api.services.lifecycle import should_activate_parent_for_active_tasks # noqa: E402
from api.task_status import ( # noqa: E402
CANONICAL_TASK_STATUSES,
OPEN_TASK_STATUSES,
TASK_STATUS_ORDER,
TERMINAL_TASK_STATUSES,
normalize_task_status,
)
try: try:
import yaml as _yaml import yaml as _yaml
@@ -90,7 +97,7 @@ _HEADING_RE = re.compile(r"^(#{1,4})\s+(.+?)$", re.MULTILINE)
_ARCHIVED_WP_RE = re.compile(r"^\d{6}-(.+\.md)$") _ARCHIVED_WP_RE = re.compile(r"^\d{6}-(.+\.md)$")
VALID_WP_STATUSES = set(CANONICAL_WORKSTREAM_STATUSES) VALID_WP_STATUSES = set(CANONICAL_WORKSTREAM_STATUSES)
SUPPORTED_WP_STATUSES = set(SUPPORTED_WORKSTREAM_STATUSES) SUPPORTED_WP_STATUSES = set(SUPPORTED_WORKSTREAM_STATUSES)
VALID_TASK_STATUSES = {"todo", "in_progress", "blocked", "done", "cancelled"} VALID_TASK_STATUSES = set(CANONICAL_TASK_STATUSES)
VALID_TASK_PRIORITIES = {"low", "medium", "high", "critical"} VALID_TASK_PRIORITIES = {"low", "medium", "high", "critical"}
VALID_DEP_RELATIONSHIPS = {"blocks", "starts_after", "informs", "soft_dependency"} VALID_DEP_RELATIONSHIPS = {"blocks", "starts_after", "informs", "soft_dependency"}
DEFAULT_REMOTE_ALL_MAX_SECONDS = int(os.environ.get("CONSISTENCY_REMOTE_ALL_MAX_SECONDS", "300")) DEFAULT_REMOTE_ALL_MAX_SECONDS = int(os.environ.get("CONSISTENCY_REMOTE_ALL_MAX_SECONDS", "300"))
@@ -99,14 +106,7 @@ DEFAULT_REMOTE_ALL_MAX_SECONDS = int(os.environ.get("CONSISTENCY_REMOTE_ALL_MAX_
FILE_TO_DB_WORKSTREAM_STATUS: dict[str, str] = dict(LEGACY_WORKSTREAM_STATUS_ALIASES) FILE_TO_DB_WORKSTREAM_STATUS: dict[str, str] = dict(LEGACY_WORKSTREAM_STATUS_ALIASES)
# Ordinal ranking for task statuses used by the no-regress rule (T01/C-15). # Ordinal ranking for task statuses used by the no-regress rule (T01/C-15).
# blocked and in_progress share rank 1 — both are "in flight". STATUS_ORDER: dict[str, int] = dict(TASK_STATUS_ORDER)
STATUS_ORDER: dict[str, int] = {
"todo": 0,
"in_progress": 1,
"blocked": 1,
"done": 2,
"cancelled": 2,
}
def normalise_workstream_status(status: str, *, has_started: bool | None = None) -> str: def normalise_workstream_status(status: str, *, has_started: bool | None = None) -> str:
@@ -114,6 +114,13 @@ def normalise_workstream_status(status: str, *, has_started: bool | None = None)
return _normalize_workstream_status(status, has_started=has_started) return _normalize_workstream_status(status, has_started=has_started)
def normalise_task_status(status: Any, *, default: str = "todo") -> str:
try:
return normalize_task_status(status, default=default)
except ValueError:
return default
def canonical_workplan_filename(path: Path) -> str: def canonical_workplan_filename(path: Path) -> str:
"""Return the workplan filename without an archive completion-date prefix.""" """Return the workplan filename without an archive completion-date prefix."""
return _ARCHIVED_WP_RE.sub(r"\1", path.name) return _ARCHIVED_WP_RE.sub(r"\1", path.name)
@@ -193,7 +200,7 @@ RENORMALIZATION_RULES: tuple[RenormalizationRule, ...] = (
invariant="Planning-state workplans cannot contain active task work.", invariant="Planning-state workplans cannot contain active task work.",
detection=( detection=(
"Workplan status is proposed, ready, or backlog while a linked " "Workplan status is proposed, ready, or backlog while a linked "
"task is in_progress or blocked." "task is progress or wait."
), ),
repair="Patch the DB workstream and workplan frontmatter to status=active.", repair="Patch the DB workstream and workplan frontmatter to status=active.",
test_anchor="tests/test_consistency_check.py::TestLifecycleRenormalization", test_anchor="tests/test_consistency_check.py::TestLifecycleRenormalization",
@@ -997,7 +1004,7 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
t_sh_id = "" if _raw_sh is None else str(_raw_sh).strip().strip('"') t_sh_id = "" if _raw_sh is None else str(_raw_sh).strip().strip('"')
if t_sh_id in ("~", "null", "None", "none"): if t_sh_id in ("~", "null", "None", "none"):
t_sh_id = "" t_sh_id = ""
t_status = str(task.get("status", "")).strip() t_status = normalise_task_status(task.get("status", "todo"))
if t_sh_id: if t_sh_id:
file_task_sh_ids.add(t_sh_id) file_task_sh_ids.add(t_sh_id)
@@ -1012,7 +1019,7 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
) )
continue continue
# C-10 / C-15: task status drift # C-10 / C-15: task status drift
db_t_status = db_task.get("status", "") db_t_status = normalise_task_status(db_task.get("status", "todo"))
if t_status and db_t_status and t_status != db_t_status: if t_status and db_t_status and t_status != db_t_status:
db_rank = STATUS_ORDER.get(db_t_status, 0) db_rank = STATUS_ORDER.get(db_t_status, 0)
file_rank = STATUS_ORDER.get(t_status, 0) file_rank = STATUS_ORDER.get(t_status, 0)
@@ -1099,7 +1106,7 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
for db_t in db_tasks: for db_t in db_tasks:
if db_t["id"] not in file_task_sh_ids: if db_t["id"] not in file_task_sh_ids:
db_t_status = db_t.get("status", "") db_t_status = db_t.get("status", "")
open_task = db_t_status not in ("done", "cancelled") open_task = db_t_status not in TERMINAL_TASK_STATUSES
# Auto-cancel fixable when workstream is finished and task is open # Auto-cancel fixable when workstream is finished and task is open
fixable = ws_finished and open_task fixable = ws_finished and open_task
report.add( report.add(
@@ -1122,14 +1129,14 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
if normalise_workstream_status(db_status) == "active" and isinstance(db_tasks, list) and db_tasks: if normalise_workstream_status(db_status) == "active" and isinstance(db_tasks, list) and db_tasks:
non_terminal = [ non_terminal = [
t for t in db_tasks t for t in db_tasks
if t.get("status") not in ("done", "cancelled") if normalise_task_status(t.get("status", "todo")) not in TERMINAL_TASK_STATUSES
] ]
if not non_terminal: if not non_terminal:
report.add( report.add(
severity="WARN", check_id="C-13", severity="WARN", check_id="C-13",
message=( message=(
f"All {len(db_tasks)} DB tasks for '{ws.get('slug')}' are " f"All {len(db_tasks)} DB tasks for '{ws.get('slug')}' are "
f"done/cancelled but workstream status is still 'active'" f"done/cancel but workstream status is still 'active'"
f"worker likely forgot update_workstream_status()" f"worker likely forgot update_workstream_status()"
), ),
file_path=fname, file_path=fname,
@@ -1358,8 +1365,8 @@ def _git_commit_writeback(
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_BRIEF_HEADER = "<!-- custodian-brief: generated by fix-consistency — do not edit manually -->" _BRIEF_HEADER = "<!-- custodian-brief: generated by fix-consistency — do not edit manually -->"
_TASK_STATUS_ICON = {"done": "", "cancelled": "", "in_progress": "", "blocked": "!", "todo": "·"} _TASK_STATUS_ICON = {"done": "", "cancel": "", "progress": "", "wait": "!", "todo": "·"}
_OPEN_STATUSES = {"todo", "in_progress", "blocked"} _OPEN_STATUSES = set(OPEN_TASK_STATUSES)
def _write_custodian_brief(api_base: str, repo_slug: str, repo_path: str) -> bool: def _write_custodian_brief(api_base: str, repo_slug: str, repo_path: str) -> bool:
@@ -1429,14 +1436,24 @@ def _write_custodian_brief(api_base: str, repo_slug: str, repo_path: str) -> boo
if not isinstance(tasks, list): if not isinstance(tasks, list):
tasks = [] tasks = []
done = sum(1 for t in tasks if t.get("status") in ("done", "cancelled")) done = sum(
1 for t in tasks
if normalise_task_status(t.get("status", "todo")) in TERMINAL_TASK_STATUSES
)
total = len(tasks) total = len(tasks)
pct = f"{done}/{total}" if total else "no tasks" pct = f"{done}/{total}" if total else "no tasks"
open_tasks = [t for t in tasks if t.get("status") in _OPEN_STATUSES] open_tasks = [
# Show blocked first, then in_progress, then todo (cap at 5) t for t in tasks
priority_order = {"blocked": 0, "in_progress": 1, "todo": 2} if normalise_task_status(t.get("status", "todo")) in _OPEN_STATUSES
open_tasks.sort(key=lambda t: priority_order.get(t.get("status", "todo"), 9)) ]
# Show wait first, then progress, then todo (cap at 5).
priority_order = {"wait": 0, "progress": 1, "todo": 2}
open_tasks.sort(
key=lambda t: priority_order.get(
normalise_task_status(t.get("status", "todo")), 9
)
)
lines += [ lines += [
"", "",
@@ -1448,14 +1465,14 @@ def _write_custodian_brief(api_base: str, repo_slug: str, repo_path: str) -> boo
lines.append("") lines.append("")
lines.append("**Open tasks:**") lines.append("**Open tasks:**")
for t in open_tasks[:7]: for t in open_tasks[:7]:
icon = _TASK_STATUS_ICON.get(t.get("status", "todo"), "·") status = normalise_task_status(t.get("status", "todo"))
icon = _TASK_STATUS_ICON.get(status, "·")
title = t.get("title", t["id"]) title = t.get("title", t["id"])
tid = t["id"] tid = t["id"]
status = t.get("status", "")
blocker = t.get("blocking_reason", "") blocker = t.get("blocking_reason", "")
task_line = f"- {icon} {title} `{tid[:8]}`" task_line = f"- {icon} {title} `{tid[:8]}`"
if status == "blocked" and blocker: if status == "wait" and blocker:
task_line += f"\n *(blocked: {blocker})*" task_line += f"\n *(wait: {blocker})*"
lines.append(task_line) lines.append(task_line)
if len(open_tasks) > 7: if len(open_tasks) > 7:
lines.append(f"- … and {len(open_tasks) - 7} more open tasks") lines.append(f"- … and {len(open_tasks) - 7} more open tasks")
@@ -1685,9 +1702,7 @@ def fix_repo(
t_id = str(task.get("id", "")).strip() t_id = str(task.get("id", "")).strip()
if not t_id: if not t_id:
continue continue
t_status = str(task.get("status", "todo")).strip() t_status = normalise_task_status(task.get("status", "todo"))
if t_status not in VALID_TASK_STATUSES:
t_status = "todo"
t_priority = str(task.get("priority", "medium")).strip() t_priority = str(task.get("priority", "medium")).strip()
if t_priority not in VALID_TASK_PRIORITIES: if t_priority not in VALID_TASK_PRIORITIES:
t_priority = "medium" t_priority = "medium"
@@ -1767,9 +1782,7 @@ def fix_repo(
f"C-11 skipped: task '{t_id}' in {ws_status} workstream — not created" f"C-11 skipped: task '{t_id}' in {ws_status} workstream — not created"
) )
else: else:
t_status = str(task.get("status", "todo")).strip() t_status = normalise_task_status(task.get("status", "todo"))
if t_status not in VALID_TASK_STATUSES:
t_status = "todo"
t_priority = str(task.get("priority", "medium")).strip() t_priority = str(task.get("priority", "medium")).strip()
if t_priority not in VALID_TASK_PRIORITIES: if t_priority not in VALID_TASK_PRIORITIES:
t_priority = "medium" t_priority = "medium"
@@ -1795,10 +1808,10 @@ def fix_repo(
elif issue.check_id == "C-12": elif issue.check_id == "C-12":
task_id = ctx["task_id"] task_id = ctx["task_id"]
if ctx.get("ws_finished"): if ctx.get("ws_finished"):
result = _api_patch(api_base, f"/tasks/{task_id}", {"status": "cancelled"}) result = _api_patch(api_base, f"/tasks/{task_id}", {"status": "cancel"})
if result is not None: if result is not None:
report.fixes_applied.append( report.fixes_applied.append(
f"C-12 fixed: orphan task {task_id[:8]}… cancelled (workstream finished)" f"C-12 fixed: orphan task {task_id[:8]}… canceled (workstream finished)"
) )
elif issue.check_id == "C-22": elif issue.check_id == "C-22":
@@ -2013,7 +2026,7 @@ def archive_closed_workplans(
tasks = get_tasks_from_workplan(meta, body) tasks = get_tasks_from_workplan(meta, body)
open_tasks = [ open_tasks = [
t for t in tasks t for t in tasks
if str(t.get("status", "")).strip() not in ("done", "cancelled") if normalise_task_status(t.get("status", "todo")) not in TERMINAL_TASK_STATUSES
] ]
if open_tasks: if open_tasks:
continue continue

View File

@@ -63,8 +63,8 @@ Omit `workstream_id` / `task_id` when not applicable.
```bash ```bash
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{"status": "in_progress"}' -d '{"status": "progress"}'
# values: todo | in_progress | done | blocked # values: wait | todo | progress | done | cancel
``` ```
### Flag a task for human review ### Flag a task for human review
@@ -83,7 +83,7 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe) 1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe)
2. Check inbox: `GET /messages/?to_agent={REPO_SLUG}&unread_only=true`; mark read 2. Check inbox: `GET /messages/?to_agent={REPO_SLUG}&unread_only=true`; mark read
3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks 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:** **During work:**
- Update task statuses in workplan files as tasks progress - Update task statuses in workplan files as tasks progress
@@ -146,7 +146,7 @@ derived health labels, not frontmatter statuses.
` ` `task ` ` `task
id: {WP_PREFIX}-NNNN-T01 id: {WP_PREFIX}-NNNN-T01
status: todo | in_progress | done | blocked status: wait | todo | progress | done | cancel
priority: high | medium | low priority: high | medium | low
state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
` ` ` ` ` `
@@ -154,7 +154,7 @@ state_hub_task_id: "<uuid>" # written by fix-consistency — do not edit
Task description text. 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: To create a new workplan:
1. Write the file following the format above 1. Write the file following the format above

View File

@@ -39,7 +39,7 @@ curl -s -X PATCH "http://127.0.0.1:8000/messages/<id>/read" \
ls workplans/ ls workplans/
``` ```
For each file with `status: ready`, `active`, or `blocked`, note pending For each file with `status: ready`, `active`, or `blocked`, note pending
`todo`/`in_progress` tasks. `wait`/`todo`/`progress` tasks.
**Step 4 — Present brief** **Step 4 — Present brief**

View File

@@ -49,6 +49,7 @@ from api.workplan_status import ( # noqa: E402
SUPPORTED_WORKSTREAM_STATUSES, SUPPORTED_WORKSTREAM_STATUSES,
normalize_workstream_status, normalize_workstream_status,
) )
from api.task_status import CANONICAL_TASK_STATUSES, normalize_task_status # noqa: E402
try: try:
import yaml as _yaml import yaml as _yaml
@@ -70,7 +71,7 @@ except ImportError:
REQUIRED_FRONTMATTER = {"id", "type", "title", "domain", "status", "owner", "created"} REQUIRED_FRONTMATTER = {"id", "type", "title", "domain", "status", "owner", "created"}
VALID_WP_STATUSES = set(CANONICAL_WORKSTREAM_STATUSES) VALID_WP_STATUSES = set(CANONICAL_WORKSTREAM_STATUSES)
SUPPORTED_WP_STATUSES = set(SUPPORTED_WORKSTREAM_STATUSES) SUPPORTED_WP_STATUSES = set(SUPPORTED_WORKSTREAM_STATUSES)
VALID_TASK_STATUSES = {"todo", "in_progress", "blocked", "done", "cancelled"} VALID_TASK_STATUSES = set(CANONICAL_TASK_STATUSES)
VALID_TASK_PRIORITIES = {"low", "medium", "high", "critical"} VALID_TASK_PRIORITIES = {"low", "medium", "high", "critical"}
_WP_ID_RE = re.compile(r"^(?:[A-Z]+-WP-\d+|ADHOC-\d{4}-\d{2}-\d{2})$") _WP_ID_RE = re.compile(r"^(?:[A-Z]+-WP-\d+|ADHOC-\d{4}-\d{2}-\d{2})$")
@@ -264,10 +265,12 @@ def _check_workplan_file(wp_file: Path, report: Report) -> dict | None:
t_status = str(task.get("status", "")) t_status = str(task.get("status", ""))
if not t_status: if not t_status:
report.add(Level.FAIL, "task-status", "Missing 'status' field", tref) report.add(Level.FAIL, "task-status", "Missing 'status' field", tref)
elif t_status not in VALID_TASK_STATUSES: else:
report.add(Level.FAIL, "task-status-value", try:
f"status {t_status!r} not in {sorted(VALID_TASK_STATUSES)}", tref) normalize_task_status(t_status)
except ValueError:
report.add(Level.FAIL, "task-status-value",
f"status {t_status!r} not in {sorted(VALID_TASK_STATUSES)}", tref)
t_prio = str(task.get("priority", "")) t_prio = str(task.get("priority", ""))
if not t_prio: if not t_prio:
report.add(Level.WARN, "task-priority", "Missing 'priority' field", tref) report.add(Level.WARN, "task-priority", "Missing 'priority' field", tref)

View File

@@ -35,7 +35,7 @@ async def _create_workstream(client, topic_id):
return r.json() return r.json()
async def _create_task(client, workstream_id, title="Test task", status="blocked"): async def _create_task(client, workstream_id, title="Test task", status="wait"):
r = await client.post("/tasks/", json={ r = await client.post("/tasks/", json={
"workstream_id": workstream_id, "title": title, "workstream_id": workstream_id, "title": title,
}) })
@@ -43,7 +43,7 @@ async def _create_task(client, workstream_id, title="Test task", status="blocked
task = r.json() task = r.json()
if status != "todo": if status != "todo":
patch = {"status": status} patch = {"status": status}
if status == "blocked": if status == "wait":
patch["blocking_reason"] = "Waiting for capability request" patch["blocking_reason"] = "Waiting for capability request"
r2 = await client.patch(f"/tasks/{task['id']}", json=patch) r2 = await client.patch(f"/tasks/{task['id']}", json=patch)
assert r2.status_code == 200, r2.text assert r2.status_code == 200, r2.text
@@ -229,7 +229,7 @@ class TestCapabilityRequestLifecycle:
await _setup_two_domains(client) await _setup_two_domains(client)
topic = await _create_topic(client, "custodian") topic = await _create_topic(client, "custodian")
ws = await _create_workstream(client, topic["id"]) ws = await _create_workstream(client, topic["id"])
task = await _create_task(client, ws["id"], status="blocked") task = await _create_task(client, ws["id"], status="wait")
req = await _create_request(client, blocking_task_id=task["id"]) req = await _create_request(client, blocking_task_id=task["id"])

View File

@@ -558,11 +558,11 @@ class TestStatusOrder:
def test_todo_is_lowest(self): def test_todo_is_lowest(self):
assert STATUS_ORDER["todo"] == 0 assert STATUS_ORDER["todo"] == 0
def test_done_and_cancelled_are_highest(self): def test_done_and_cancel_are_highest(self):
assert STATUS_ORDER["done"] == STATUS_ORDER["cancelled"] == 2 assert STATUS_ORDER["done"] == STATUS_ORDER["cancel"] == 2
def test_in_progress_and_blocked_are_mid(self): def test_progress_and_wait_are_mid(self):
assert STATUS_ORDER["in_progress"] == STATUS_ORDER["blocked"] == 1 assert STATUS_ORDER["progress"] == STATUS_ORDER["wait"] == 1
def test_db_ahead_detected(self): def test_db_ahead_detected(self):
"""done (DB) vs todo (file) — DB is ahead.""" """done (DB) vs todo (file) — DB is ahead."""
@@ -573,8 +573,8 @@ class TestStatusOrder:
assert STATUS_ORDER["todo"] < STATUS_ORDER["done"] assert STATUS_ORDER["todo"] < STATUS_ORDER["done"]
def test_same_rank_treated_as_db_ahead(self): def test_same_rank_treated_as_db_ahead(self):
"""in_progress (DB) vs blocked (file) — same rank, no regression.""" """progress (DB) vs wait (file) — same rank, no regression."""
assert STATUS_ORDER["in_progress"] >= STATUS_ORDER["blocked"] assert STATUS_ORDER["progress"] >= STATUS_ORDER["wait"]
def test_todo_to_done_is_regression(self): def test_todo_to_done_is_regression(self):
"""Applying file=todo to DB=done would be a regression.""" """Applying file=todo to DB=done would be a regression."""
@@ -718,7 +718,7 @@ class TestPatchTaskStatusInFile:
content = ( content = (
"---\nid: WP-001\n---\n" "---\nid: WP-001\n---\n"
"```task\nid: T01\nstatus: todo\n```\n" "```task\nid: T01\nstatus: todo\n```\n"
"```task\nid: T02\nstatus: in_progress\n```\n" "```task\nid: T02\nstatus: progress\n```\n"
) )
f = self._make_workplan(tmp_path, content) f = self._make_workplan(tmp_path, content)
_patch_task_status_in_file(f, "T02", "done") _patch_task_status_in_file(f, "T02", "done")
@@ -783,7 +783,7 @@ class TestLifecycleRenormalization:
"## Implement Demo\n\n" "## Implement Demo\n\n"
"```task\n" "```task\n"
"id: STATE-WP-0001-T01\n" "id: STATE-WP-0001-T01\n"
"status: in_progress\n" "status: progress\n"
"priority: high\n" "priority: high\n"
"state_hub_task_id: \"task-1\"\n" "state_hub_task_id: \"task-1\"\n"
"```\n", "```\n",
@@ -805,7 +805,7 @@ class TestLifecycleRenormalization:
task = { task = {
"id": "task-1", "id": "task-1",
"title": "Implement Demo", "title": "Implement Demo",
"status": "in_progress", "status": "progress",
"description": None, "description": None,
} }

View File

@@ -16,7 +16,7 @@ from api.services.lifecycle import (
def test_task_start_activates_planning_parent(parent_status): def test_task_start_activates_planning_parent(parent_status):
assert should_activate_parent_for_task_start( assert should_activate_parent_for_task_start(
previous_task_status="todo", previous_task_status="todo",
new_task_status="in_progress", new_task_status="progress",
parent_workstream_status=parent_status, parent_workstream_status=parent_status,
) )
@@ -25,15 +25,15 @@ def test_task_start_activates_planning_parent(parent_status):
def test_task_start_does_not_rewrite_non_planning_parent(parent_status): def test_task_start_does_not_rewrite_non_planning_parent(parent_status):
assert not should_activate_parent_for_task_start( assert not should_activate_parent_for_task_start(
previous_task_status="todo", previous_task_status="todo",
new_task_status="in_progress", new_task_status="progress",
parent_workstream_status=parent_status, parent_workstream_status=parent_status,
) )
def test_task_start_requires_todo_to_in_progress_transition(): def test_task_start_requires_todo_to_progress_transition():
assert not should_activate_parent_for_task_start( assert not should_activate_parent_for_task_start(
previous_task_status="in_progress", previous_task_status="progress",
new_task_status="in_progress", new_task_status="progress",
parent_workstream_status="ready", parent_workstream_status="ready",
) )
assert not should_activate_parent_for_task_start( assert not should_activate_parent_for_task_start(
@@ -44,22 +44,22 @@ def test_task_start_requires_todo_to_in_progress_transition():
def test_has_active_task_status_ignores_terminal_and_todo_statuses(): def test_has_active_task_status_ignores_terminal_and_todo_statuses():
assert has_active_task_status(["todo", "done", "cancelled"]) is False assert has_active_task_status(["todo", "done", "cancel"]) is False
assert has_active_task_status(["todo", "blocked"]) is True assert has_active_task_status(["todo", "wait"]) is True
assert has_active_task_status(["in_progress"]) is True assert has_active_task_status(["progress"]) is True
def test_active_task_state_activates_planning_parent_for_renormalization(): def test_active_task_state_activates_planning_parent_for_renormalization():
assert should_activate_parent_for_active_tasks( assert should_activate_parent_for_active_tasks(
parent_workstream_status="proposed", parent_workstream_status="proposed",
task_statuses=["todo", "in_progress"], task_statuses=["todo", "progress"],
) )
def test_active_task_state_does_not_unblock_blocked_parent(): def test_active_task_state_does_not_unblock_blocked_parent():
assert not should_activate_parent_for_active_tasks( assert not should_activate_parent_for_active_tasks(
parent_workstream_status="blocked", parent_workstream_status="blocked",
task_statuses=["in_progress"], task_statuses=["progress"],
) )
@@ -67,7 +67,7 @@ def test_status_value_unwraps_enum_like_values():
class Status: class Status:
value = "In_Progress" value = "In_Progress"
assert status_value(Status()) == "in_progress" assert status_value(Status()) == "progress"
def test_transition_workstream_status_normalizes_aliases(): def test_transition_workstream_status_normalizes_aliases():
@@ -92,10 +92,10 @@ def test_transition_task_status_activates_parent_once():
task = Task() task = Task()
ws = Workstream() ws = Workstream()
result = transition_task_status(task, "in_progress", parent_workstream=ws) result = transition_task_status(task, "progress", parent_workstream=ws)
assert task.status == "in_progress" assert task.status == "progress"
assert ws.status == "active" assert ws.status == "active"
assert result.parent_activated is True assert result.parent_activated is True
assert result.previous_status == "todo" assert result.previous_status == "todo"
assert result.target_status == "in_progress" assert result.target_status == "progress"

View File

@@ -68,7 +68,7 @@ def test_archived_workstream_file_requires_confirmation():
def test_task_status_update_writes_through_to_task_block(): def test_task_status_update_writes_through_to_task_block():
result = classify_task_status_change( result = classify_task_status_change(
current_status="todo", current_status="todo",
target_status="in_progress", target_status="progress",
file_backed=True, file_backed=True,
task_linked=True, task_linked=True,
) )
@@ -80,7 +80,7 @@ def test_task_status_update_writes_through_to_task_block():
def test_task_without_file_is_deferred(): def test_task_without_file_is_deferred():
result = classify_task_status_change( result = classify_task_status_change(
current_status="todo", current_status="todo",
target_status="in_progress", target_status="progress",
file_backed=False, file_backed=False,
task_linked=True, task_linked=True,
) )
@@ -92,7 +92,7 @@ def test_task_without_file_is_deferred():
def test_unlinked_task_is_deferred_until_link_repaired(): def test_unlinked_task_is_deferred_until_link_repaired():
result = classify_task_status_change( result = classify_task_status_change(
current_status="todo", current_status="todo",
target_status="in_progress", target_status="progress",
file_backed=True, file_backed=True,
task_linked=False, task_linked=False,
) )
@@ -101,22 +101,22 @@ def test_unlinked_task_is_deferred_until_link_repaired():
assert "not linked" in result.reason assert "not linked" in result.reason
def test_blocked_task_requires_blocking_reason(): def test_wait_task_requires_blocking_reason():
result = classify_task_status_change( result = classify_task_status_change(
current_status="todo", current_status="todo",
target_status="blocked", target_status="wait",
file_backed=True, file_backed=True,
task_linked=True, task_linked=True,
) )
assert result.reconciliation_class == ReconciliationClass.HUMAN_CONFIRMATION assert result.reconciliation_class == ReconciliationClass.HUMAN_CONFIRMATION
assert "blocking reason" in result.reason assert "wait condition" in result.reason
def test_blocked_task_with_reason_writes_through(): def test_wait_task_with_reason_writes_through():
result = classify_task_status_change( result = classify_task_status_change(
current_status="todo", current_status="todo",
target_status="blocked", target_status="wait",
file_backed=True, file_backed=True,
task_linked=True, task_linked=True,
blocking_reason="Waiting on dependency", blocking_reason="Waiting on dependency",

View File

@@ -207,7 +207,7 @@ class TestTasks:
r = await client.delete(f"/tasks/{task['id']}") r = await client.delete(f"/tasks/{task['id']}")
assert r.status_code == 200 assert r.status_code == 200
assert r.json()["status"] == "cancelled" assert r.json()["status"] == "cancel"
async def test_filter_by_priority(self, client): async def test_filter_by_priority(self, client):
await _create_domain(client) await _create_domain(client)
@@ -238,7 +238,7 @@ class TestTasks:
) )
task = await _create_task(client, ws["id"]) task = await _create_task(client, ws["id"])
r = await client.patch(f"/tasks/{task['id']}", json={"status": "in_progress"}) r = await client.patch(f"/tasks/{task['id']}", json={"status": "progress"})
assert r.status_code == 200 assert r.status_code == 200
r = await client.get(f"/workstreams/{ws['id']}") r = await client.get(f"/workstreams/{ws['id']}")
@@ -251,7 +251,7 @@ class TestTasks:
ws = await _create_workstream(client, topic["id"], slug="blocked-ws", status="blocked") ws = await _create_workstream(client, topic["id"], slug="blocked-ws", status="blocked")
task = await _create_task(client, ws["id"]) task = await _create_task(client, ws["id"])
r = await client.patch(f"/tasks/{task['id']}", json={"status": "in_progress"}) r = await client.patch(f"/tasks/{task['id']}", json={"status": "progress"})
assert r.status_code == 200 assert r.status_code == 200
r = await client.get(f"/workstreams/{ws['id']}") r = await client.get(f"/workstreams/{ws['id']}")
@@ -326,13 +326,13 @@ class TestStateSummary:
topic = await _create_topic(client) topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"]) ws = await _create_workstream(client, topic["id"])
task = await _create_task(client, ws["id"]) task = await _create_task(client, ws["id"])
# Mark as blocked so it shows in blocked_tasks # Mark as wait so it shows in waiting_tasks
await client.patch(f"/tasks/{task['id']}", await client.patch(f"/tasks/{task['id']}",
json={"status": "blocked", "blocking_reason": "waiting on dep"}) json={"status": "wait", "blocking_reason": "waiting on dep"})
r = await client.get("/state/summary") r = await client.get("/state/summary")
body = r.json() body = r.json()
assert len(body["blocked_tasks"]) >= 1 assert len(body["waiting_tasks"]) >= 1
async def test_summary_derives_blocked_workstream_from_flow_engine(self, client): async def test_summary_derives_blocked_workstream_from_flow_engine(self, client):
await _create_domain(client) await _create_domain(client)
@@ -449,7 +449,7 @@ class TestReconciliationEndpoints:
assert body["reconciliation_class"] == "human_confirmation" assert body["reconciliation_class"] == "human_confirmation"
assert "open work" in body["reason"] assert "open work" in body["reason"]
async def test_classify_task_blocked_without_reason_needs_confirmation(self, client): async def test_classify_task_wait_without_reason_needs_confirmation(self, client):
await _create_domain(client) await _create_domain(client)
topic = await _create_topic(client) topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"]) ws = await _create_workstream(client, topic["id"])
@@ -458,7 +458,7 @@ class TestReconciliationEndpoints:
r = await client.post("/reconciliation/state-change", json={ r = await client.post("/reconciliation/state-change", json={
"target_type": "task", "target_type": "task",
"target_id": task["id"], "target_id": task["id"],
"target_status": "blocked", "target_status": "wait",
"file_backed": True, "file_backed": True,
"task_linked": True, "task_linked": True,
}) })
@@ -467,7 +467,7 @@ class TestReconciliationEndpoints:
body = r.json() body = r.json()
assert body["current_status"] == "todo" assert body["current_status"] == "todo"
assert body["reconciliation_class"] == "human_confirmation" assert body["reconciliation_class"] == "human_confirmation"
assert "blocking reason" in body["reason"] assert "wait condition" in body["reason"]
async def test_classify_unknown_workstream_returns_404(self, client): async def test_classify_unknown_workstream_returns_404(self, client):
r = await client.post("/reconciliation/state-change", json={ r = await client.post("/reconciliation/state-change", json={
@@ -582,7 +582,7 @@ class TestReconciliationEndpoints:
r = await client.post("/reconciliation/state-change", json={ r = await client.post("/reconciliation/state-change", json={
"target_type": "task", "target_type": "task",
"target_id": task["id"], "target_id": task["id"],
"target_status": "in_progress", "target_status": "progress",
"actor": "dashboard", "actor": "dashboard",
"apply": True, "apply": True,
}) })
@@ -592,10 +592,10 @@ class TestReconciliationEndpoints:
assert body["reconciliation_class"] == "write_through" assert body["reconciliation_class"] == "write_through"
assert body["write_through_result"] == "applied" assert body["write_through_result"] == "applied"
assert body["workplan_path"] == "workplans/STATE-WP-9999-demo.md" assert body["workplan_path"] == "workplans/STATE-WP-9999-demo.md"
assert "status: in_progress" in wp.read_text(encoding="utf-8") assert "status: progress" in wp.read_text(encoding="utf-8")
r = await client.get(f"/tasks/{task['id']}") r = await client.get(f"/tasks/{task['id']}")
assert r.json()["status"] == "in_progress" assert r.json()["status"] == "progress"
async def test_apply_task_start_write_through_activates_parent_file_and_db(self, client, tmp_path): async def test_apply_task_start_write_through_activates_parent_file_and_db(self, client, tmp_path):
await _create_domain(client) await _create_domain(client)
@@ -630,7 +630,7 @@ class TestReconciliationEndpoints:
r = await client.post("/reconciliation/state-change", json={ r = await client.post("/reconciliation/state-change", json={
"target_type": "task", "target_type": "task",
"target_id": task["id"], "target_id": task["id"],
"target_status": "in_progress", "target_status": "progress",
"actor": "dashboard", "actor": "dashboard",
"expected_current_status": "todo", "expected_current_status": "todo",
"apply": True, "apply": True,
@@ -641,13 +641,13 @@ class TestReconciliationEndpoints:
assert body["write_through_result"] == "applied" assert body["write_through_result"] == "applied"
text = wp.read_text(encoding="utf-8") text = wp.read_text(encoding="utf-8")
assert "status: active" in text assert "status: active" in text
assert "status: in_progress" in text assert "status: progress" in text
r = await client.get(f"/workstreams/{ws['id']}") r = await client.get(f"/workstreams/{ws['id']}")
assert r.json()["status"] == "active" assert r.json()["status"] == "active"
r = await client.get(f"/tasks/{task['id']}") r = await client.get(f"/tasks/{task['id']}")
assert r.json()["status"] == "in_progress" assert r.json()["status"] == "progress"
async def test_apply_task_confirmation_case_creates_reconciliation_message(self, client, tmp_path): async def test_apply_task_confirmation_case_creates_reconciliation_message(self, client, tmp_path):
await _create_domain(client) await _create_domain(client)
@@ -682,7 +682,7 @@ class TestReconciliationEndpoints:
r = await client.post("/reconciliation/state-change", json={ r = await client.post("/reconciliation/state-change", json={
"target_type": "task", "target_type": "task",
"target_id": task["id"], "target_id": task["id"],
"target_status": "blocked", "target_status": "wait",
"apply": True, "apply": True,
}) })
@@ -698,7 +698,7 @@ class TestReconciliationEndpoints:
r = await client.get("/messages/?to_agent=state-hub&unread_only=true") r = await client.get("/messages/?to_agent=state-hub&unread_only=true")
messages = r.json() messages = r.json()
assert len(messages) == 1 assert len(messages) == 1
assert "blocking reason" in messages[0]["body"] assert "wait reason" in messages[0]["body"] or "wait condition" in messages[0]["body"]
async def test_apply_workstream_stale_expected_status_creates_conflict_message(self, client, tmp_path): async def test_apply_workstream_stale_expected_status_creates_conflict_message(self, client, tmp_path):
await _create_domain(client) await _create_domain(client)
@@ -792,7 +792,7 @@ class TestReconciliationEndpoints:
r = await client.post("/reconciliation/state-change", json={ r = await client.post("/reconciliation/state-change", json={
"target_type": "task", "target_type": "task",
"target_id": task["id"], "target_id": task["id"],
"target_status": "in_progress", "target_status": "progress",
"expected_current_status": "todo", "expected_current_status": "todo",
"apply": True, "apply": True,
}) })

View File

@@ -18,7 +18,7 @@ def test_all_assertions_satisfied_reports_reachable_workstations():
id="tasks.all_done", id="tasks.all_done",
target="tasks.*.status", target="tasks.*.status",
op="all_eq", op="all_eq",
value=["done", "cancelled"], value=["done", "cancel"],
) )
], ],
), ),
@@ -26,7 +26,7 @@ def test_all_assertions_satisfied_reports_reachable_workstations():
) )
result = FlowEngine().evaluate( result = FlowEngine().evaluate(
{"status": "active", "tasks": [{"status": "done"}, {"status": "cancelled"}]}, {"status": "active", "tasks": [{"status": "done"}, {"status": "cancel"}]},
flow, flow,
) )
@@ -47,7 +47,7 @@ def test_failing_exit_assertion_identifies_blocking_assertion():
id="tasks.all_done", id="tasks.all_done",
target="tasks.*.status", target="tasks.*.status",
op="all_eq", op="all_eq",
value=["done", "cancelled"], value=["done", "cancel"],
) )
], ],
) )
@@ -87,7 +87,7 @@ def test_can_reach_checks_current_exit_assertions_before_target_entry():
id="tasks.all_done", id="tasks.all_done",
target="tasks.*.status", target="tasks.*.status",
op="all_eq", op="all_eq",
value=["done", "cancelled"], value=["done", "cancel"],
) )
], ],
), ),
@@ -144,7 +144,7 @@ def test_empty_assertions_make_all_workstations_reachable():
entity_type="task", entity_type="task",
workstations=[ workstations=[
WorkstationDef(name="todo"), WorkstationDef(name="todo"),
WorkstationDef(name="in_progress"), WorkstationDef(name="progress"),
WorkstationDef(name="done"), WorkstationDef(name="done"),
], ],
) )
@@ -152,7 +152,7 @@ def test_empty_assertions_make_all_workstations_reachable():
result = FlowEngine().evaluate({"status": "todo"}, flow) result = FlowEngine().evaluate({"status": "todo"}, flow)
assert result.exit_blocked is False assert result.exit_blocked is False
assert result.reachable == ["todo", "in_progress", "done"] assert result.reachable == ["todo", "progress", "done"]
def test_circular_reference_in_target_path_does_not_loop_forever(): def test_circular_reference_in_target_path_does_not_loop_forever():
@@ -194,10 +194,10 @@ def test_yaml_flow_definitions_load_and_evaluate_representative_entities():
assert "blocked" in [item.workstation for item in workstream_result.unreachable] assert "blocked" in [item.workstation for item in workstream_result.unreachable]
task_result = FlowEngine().evaluate( task_result = FlowEngine().evaluate(
{"status": "blocked", "needs_human": False}, {"status": "wait", "needs_human": False},
flows["task"], flows["task"],
) )
assert "in_progress" in task_result.reachable assert "progress" in task_result.reachable
contribution_result = FlowEngine().evaluate( contribution_result = FlowEngine().evaluate(
{"status": "acknowledged", "previous_workstation": "acknowledged"}, {"status": "acknowledged", "previous_workstation": "acknowledged"},

View File

@@ -173,7 +173,7 @@ class TestTokenPassthrough:
ws = await _create_workstream(client, topic["id"]) ws = await _create_workstream(client, topic["id"])
task = await _create_task(client, ws["id"]) task = await _create_task(client, ws["id"])
r = await client.patch(f"/tasks/{task['id']}", json={"status": "in_progress"}) r = await client.patch(f"/tasks/{task['id']}", json={"status": "progress"})
assert r.status_code == 200 assert r.status_code == 200
events = (await client.get("/token-events/", params={"task_id": task["id"]})).json() events = (await client.get("/token-events/", params={"task_id": task["id"]})).json()

View File

@@ -4,7 +4,7 @@ type: workplan
title: "Task State Canon Adaptation" title: "Task State Canon Adaptation"
domain: custodian domain: custodian
repo: state-hub repo: state-hub
status: proposed status: active
owner: codex owner: codex
topic_slug: custodian topic_slug: custodian
created: "2026-05-25" created: "2026-05-25"
@@ -78,9 +78,10 @@ changing code:
Attached repos are the active repos registered in State Hub via `GET /repos/`. Attached repos are the active repos registered in State Hub via `GET /repos/`.
They should not be edited directly from the State Hub repo session. Instead: They should not be edited directly from the State Hub repo session. Instead:
1. Publish a State Hub interface-change record with `repo_slug=info-tech-canon`, 1. Publish a State Hub interface-change record with `repo_slug=state-hub`,
`interface_type=schema`, `change_type=breaking`, and the active registered `interface_type=schema`, `change_type=breaking`, and the active registered
repo slugs in `affected_repo_slugs`. repo slugs in `affected_repo_slugs`. The change cites `info-tech-canon` as
the canon source and State Hub as the schema/API publisher.
2. Let the interface-change publisher send inbox messages to affected repo 2. Let the interface-change publisher send inbox messages to affected repo
agents. agents.
3. For repos that need local file or code changes, create ecosystem tasks with 3. For repos that need local file or code changes, create ecosystem tasks with
@@ -97,7 +98,7 @@ This uses the existing repo boundary rule and avoids cross-repo writes.
```task ```task
id: STATE-WP-0052-T01 id: STATE-WP-0052-T01
status: todo status: done
priority: high priority: high
state_hub_task_id: "09c4d7ef-d193-48ed-b127-611e26b70ba0" state_hub_task_id: "09c4d7ef-d193-48ed-b127-611e26b70ba0"
``` ```
@@ -120,7 +121,7 @@ Done when the design note can be used as the implementation source of truth.
```task ```task
id: STATE-WP-0052-T02 id: STATE-WP-0052-T02
status: todo status: done
priority: high priority: high
state_hub_task_id: "c79ce637-7140-4a16-8d20-b89edee4b98f" state_hub_task_id: "c79ce637-7140-4a16-8d20-b89edee4b98f"
``` ```
@@ -146,7 +147,7 @@ required changes, and risk notes.
```task ```task
id: STATE-WP-0052-T03 id: STATE-WP-0052-T03
status: todo status: done
priority: high priority: high
state_hub_task_id: "206511ae-76a8-4c47-a1b2-114b115f8c1c" state_hub_task_id: "206511ae-76a8-4c47-a1b2-114b115f8c1c"
``` ```
@@ -170,7 +171,7 @@ task-status literal sets except in tests that intentionally assert them.
```task ```task
id: STATE-WP-0052-T04 id: STATE-WP-0052-T04
status: todo status: done
priority: high priority: high
state_hub_task_id: "bf4c6891-64a5-49e6-9bf6-d70577d494e7" state_hub_task_id: "bf4c6891-64a5-49e6-9bf6-d70577d494e7"
``` ```
@@ -196,7 +197,7 @@ task values.
```task ```task
id: STATE-WP-0052-T05 id: STATE-WP-0052-T05
status: todo status: done
priority: high priority: high
state_hub_task_id: "c9e6a649-8e1c-4d3c-b51a-1814687a99be" state_hub_task_id: "c9e6a649-8e1c-4d3c-b51a-1814687a99be"
``` ```
@@ -219,7 +220,7 @@ still sync through a deliberate migration window.
```task ```task
id: STATE-WP-0052-T06 id: STATE-WP-0052-T06
status: todo status: done
priority: high priority: high
state_hub_task_id: "0aaea886-9686-4fa7-94e5-e5a6649489e8" state_hub_task_id: "0aaea886-9686-4fa7-94e5-e5a6649489e8"
``` ```
@@ -242,7 +243,7 @@ Done when dashboard, MCP, and docs no longer present the old values as canonical
```task ```task
id: STATE-WP-0052-T07 id: STATE-WP-0052-T07
status: todo status: done
priority: high priority: high
state_hub_task_id: "c884eea6-1b69-4636-86f2-475d5ec4ea9b" state_hub_task_id: "c884eea6-1b69-4636-86f2-475d5ec4ea9b"
``` ```
@@ -266,7 +267,7 @@ reintroduction of old canonical literals.
```task ```task
id: STATE-WP-0052-T08 id: STATE-WP-0052-T08
status: todo status: done
priority: high priority: high
state_hub_task_id: "b659724d-bda1-4dec-9af1-4d5e08f4db94" state_hub_task_id: "b659724d-bda1-4dec-9af1-4d5e08f4db94"
``` ```
@@ -292,7 +293,7 @@ Done when the brief is ready to embed in interface-change messages and
```task ```task
id: STATE-WP-0052-T09 id: STATE-WP-0052-T09
status: todo status: done
priority: high priority: high
state_hub_task_id: "6479748b-c922-41e3-ad12-15015b2f56a9" state_hub_task_id: "6479748b-c922-41e3-ad12-15015b2f56a9"
``` ```
@@ -306,7 +307,7 @@ Requirements:
State Hub clients, repos with workplans/agent instructions, repos with task State Hub clients, repos with workplans/agent instructions, repos with task
status code, and no-direct-impact repos; status code, and no-direct-impact repos;
- create and publish one `schema` / `breaking` interface-change record from - create and publish one `schema` / `breaking` interface-change record from
`info-tech-canon`; State Hub, citing `info-tech-canon` as the canon source;
- include all active registered repos that should receive the canon notice in - include all active registered repos that should receive the canon notice in
`affected_repo_slugs`; `affected_repo_slugs`;
- create `[repo:<slug>]` ecosystem tasks for repos that need local adaptation; - create `[repo:<slug>]` ecosystem tasks for repos that need local adaptation;
@@ -320,7 +321,7 @@ task, or a recorded no-impact classification.
```task ```task
id: STATE-WP-0052-T10 id: STATE-WP-0052-T10
status: todo status: wait
priority: medium priority: medium
state_hub_task_id: "1cde226a-6287-4db4-9d2f-7fa9ed0b6c4d" state_hub_task_id: "1cde226a-6287-4db4-9d2f-7fa9ed0b6c4d"
``` ```
@@ -341,6 +342,11 @@ Requirements:
Done when State Hub is canon-conformant, attached repos have been notified, and Done when State Hub is canon-conformant, attached repos have been notified, and
the remaining compatibility window is explicit. the remaining compatibility window is explicit.
Current wait condition: attached repos have been notified through interface
change `649102a2-4373-4621-9848-cc257e67c262`; closing the compatibility window
depends on repo-agent responses and a later decision on when aliases become
warnings or errors.
## Acceptance Criteria ## Acceptance Criteria
- State Hub persists canonical task states as `wait`, `todo`, `progress`, - State Hub persists canonical task states as `wait`, `todo`, `progress`,