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
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
-H "Content-Type: application/json" \
-d '{"status": "in_progress"}'
# values: todo | in_progress | done | blocked
-d '{"status": "progress"}'
# values: wait | todo | progress | done | cancel
```
### Flag a task for human review
@@ -83,7 +83,7 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe)
2. Check inbox: `GET /messages/?to_agent=state-hub&unread_only=true`; mark read
3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks
4. Check blocked tasks: `GET /tasks/?needs_human=true`
4. Check human-needed tasks: `GET /tasks/?needs_human=true`
**During work:**
- Update task statuses in workplan files as tasks progress
@@ -146,7 +146,7 @@ derived health labels, not frontmatter statuses.
` ` `task
id: STATE-WP-NNNN-T01
status: todo | in_progress | done | blocked
status: wait | todo | progress | done | cancel
priority: high | medium | low
state_hub_task_id: "<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.
```
Status progression: `todo` → `in_progress` → `done` (or `blocked`)
Status progression: `todo` → `progress` → `done`; use `wait` for waiting/blocked work and `cancel` for stopped work.
To create a new workplan:
1. Write the file following the format above

View File

@@ -142,7 +142,7 @@ decisions (FK: topic_id, workstream_id — at least one required)
|------|--------|
| `topic_status` | `active` · `paused` · `archived` |
| `workstream_status` | `proposed` · `ready` · `active` · `blocked` · `backlog` · `finished` · `archived` |
| `task_status` | `todo` · `in_progress` · `blocked` · `done` · `cancelled` |
| `task_status` | `wait` · `todo` · `progress` · `done` · `cancel` |
| `task_priority` | `low` · `medium` · `high` · `critical` |
| `decision_type` | `made` · `pending` |
| `decision_status` | `open` · `resolved` · `escalated` · `superseded` |
@@ -150,7 +150,7 @@ decisions (FK: topic_id, workstream_id — at least one required)
### Governance constraints encoded in schema
- No hard DELETE endpoints — only soft: `archived`, `cancelled`, `superseded`
- No hard DELETE endpoints — only soft: `archived`, `cancel`, `superseded`
- `progress_events` has no `updated_at` and no DELETE endpoint (append-only per constitution §5)
- `decisions` with financial/legal keywords + `pending` type → auto-set `escalation_note` (§4)
@@ -170,12 +170,12 @@ Returns a full snapshot in one call — used by both the MCP server and dashboar
"totals": {
"topics": { "active": 6, "paused": 0, "archived": 0, "total": 6 },
"workstreams": { "ready": 1, "active": 1, "blocked": 0, "finished": 1, "total": 3 },
"tasks": { "todo": 9, "in_progress": 0, "blocked": 0, "done": 11, "total": 20 },
"tasks": { "wait": 0, "todo": 9, "progress": 0, "done": 11, "cancel": 0, "total": 20 },
"decisions": { "open": 1, "resolved": 0, "escalated": 0, "total": 1 }
},
"topics": [...], // topics with nested workstream stubs
"blocking_decisions": [...], // pending decisions only
"blocked_tasks": [...],
"waiting_tasks": [...],
"recent_progress": [...], // last 20 events
"open_workstreams": [...]
}
@@ -187,7 +187,7 @@ Returns a full snapshot in one call — used by both the MCP server and dashboar
|--------|-----------|
| `/topics` | CRUD (soft-delete: `archived`) |
| `/workstreams` | CRUD (soft-delete: `archived`) |
| `/tasks` | CRUD (soft-delete: `cancelled`); `PATCH` updates status |
| `/tasks` | CRUD (soft-delete: `cancel`); `PATCH` updates status |
| `/decisions` | CRUD (soft-delete: `superseded`); auto-escalation |
| `/progress` | `GET` list + `POST` append — no DELETE |
| `/state/summary` | Full snapshot |

View File

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

View File

@@ -252,7 +252,7 @@ async def patch_request_status(
# Auto-unblock the blocking task
if req.blocking_task_id:
task = await session.get(Task, req.blocking_task_id)
if task and task.status == "blocked":
if task and task.status == "wait":
task.status = "todo"
task.blocking_reason = None

View File

@@ -100,7 +100,7 @@ async def workplan_stack(
]
blocked_tasks = [
task_id for task_id in task_deps.get(ws.id, [])
if task_status.get(task_id) not in {"done", "cancelled"}
if task_status.get(task_id) not in {"done", "cancel"}
]
eligible = lifecycle_status != "blocked" and not blocked_ws and not blocked_tasks
if not include_blocked and not eligible:

View File

@@ -17,6 +17,7 @@ from api.services.lifecycle import (
transition_task_status,
transition_workstream_status,
)
from api.task_status import TERMINAL_TASK_STATUSES
from api.services.reconciliation import (
ReconciliationClass,
StateChangeClassification,
@@ -52,7 +53,7 @@ def _conflict(reason: str, follow_up: str) -> StateChangeClassification:
async def _workstream_tasks_terminal(session: AsyncSession, workstream_id: uuid.UUID) -> bool:
result = await session.execute(select(Task.status).where(Task.workstream_id == workstream_id))
statuses = [status_value(row[0]) for row in result.all()]
return bool(statuses) and all(status in {"done", "cancelled"} for status in statuses)
return bool(statuses) and all(status in TERMINAL_TASK_STATUSES for status in statuses)
def _deferred_message(

View File

@@ -590,7 +590,7 @@ async def get_repo_dispatch(
for ws in workstreams:
task_result = await session.execute(
select(Task)
.where(Task.workstream_id == ws.id, Task.status.in_(["todo", "in_progress"]))
.where(Task.workstream_id == ws.id, Task.status.in_(["todo", "progress"]))
.order_by(Task.created_at)
)
tasks = list(task_result.scalars().all())

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -123,7 +123,7 @@ async def collect_domain_activity(
]
attention_tasks = [
task for task in tasks
if task.needs_human and _enum_value(task.status) not in {TaskStatus.done.value, TaskStatus.cancelled.value}
if task.needs_human and _enum_value(task.status) not in {TaskStatus.done.value, TaskStatus.cancel.value}
]
data = {

View File

@@ -5,6 +5,7 @@ from enum import Enum
from typing import Any
from api.services.lifecycle import status_value
from api.task_status import CANONICAL_TASK_STATUSES
from api.workplan_status import CLOSED_WORKSTREAM_STATUSES, normalize_workstream_status
@@ -22,7 +23,7 @@ class StateChangeClassification:
WRITE_THROUGH_WORKSTREAM_STATUSES = {"proposed", "ready", "active", "backlog"}
TASK_STATUSES = {"todo", "in_progress", "blocked", "done", "cancelled"}
TASK_STATUSES = set(CANONICAL_TASK_STATUSES)
def classify_workstream_status_change(
@@ -129,11 +130,11 @@ def classify_task_status_change(
"status is unchanged",
"no file update required",
)
if target == "blocked" and not (blocking_reason or "").strip():
if target == "wait" and not (blocking_reason or "").strip():
return StateChangeClassification(
ReconciliationClass.HUMAN_CONFIRMATION,
"blocked tasks require a blocking reason",
"capture the blocker before writing status",
"waiting tasks should explain the wait condition",
"capture the wait reason before writing status",
)
if target in TASK_STATUSES:
return StateChangeClassification(

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

View File

@@ -153,7 +153,7 @@ export const FIELD_HELP = {
},
status: {
label: "Status",
description: "Current lifecycle state: todo, in_progress, blocked, done, or cancelled.",
description: "Current lifecycle state. Tasks use wait, todo, progress, done, or cancel.",
doc: "/docs/workstream-lifecycle",
},
topic_id: {
@@ -182,7 +182,7 @@ export const FIELD_HELP = {
},
needs_human: {
label: "Needs Human",
description: "True if the task is blocked waiting for human input or approval.",
description: "True if the task is waiting for human input or approval.",
doc: "/interventions",
},
intervention_note: {

View File

@@ -3,7 +3,7 @@ import {WORKSTREAM_STATUSES} from "./workplan-status.js";
const STYLE_ID = "status-control-styles";
export const TASK_STATUSES = ["todo", "in_progress", "blocked", "done", "cancelled"];
export const TASK_STATUSES = ["wait", "todo", "progress", "done", "cancel"];
export {WORKSTREAM_STATUSES};
function ensureStyles() {
@@ -138,9 +138,9 @@ export function statusControl({
if (nextStatus === currentStatus) return;
let blockingReason = null;
if (type === "task" && nextStatus === "blocked") {
if (type === "task" && nextStatus === "wait") {
const existingReason = entity?.blocking_reason ?? "";
const reason = existingReason || window.prompt("Blocking reason required for blocked tasks:");
const reason = existingReason || window.prompt("Reason this task is waiting:");
if (!reason) {
select.value = currentStatus;
setMessage("unchanged");

View File

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

View File

@@ -29,11 +29,12 @@ except urllib.error.URLError as e:
"archived": 0,
"total": 0,
},
"tasks": {"todo": 0, "in_progress": 0, "blocked": 0, "done": 0, "cancelled": 0, "total": 0},
"tasks": {"wait": 0, "todo": 0, "progress": 0, "done": 0, "cancel": 0, "total": 0},
"decisions": {"open": 0, "resolved": 0, "escalated": 0, "superseded": 0, "total": 0},
},
"topics": [],
"blocking_decisions": [],
"waiting_tasks": [],
"blocked_tasks": [],
"recent_progress": [],
"open_workstreams": [],

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`.
When the request reaches `completed`, the system automatically patches that
task from `blocked` → `todo` and clears its `blocking_reason`. This means
blocked work resumes without manual intervention.
task from `wait` → `todo` and clears its `blocking_reason`. This means
waiting work resumes without manual intervention.
---

View File

@@ -241,7 +241,7 @@ TOC sidebar as a persistent KPI card.
### Multi-mode workstream chart
The Overview page renders a horizontal stacked bar chart using `@observablehq/plot`
showing task counts (done / in progress / blocked / todo) per workstream.
showing task counts (done / progress / wait / todo) per workstream.
A `<select>` dropdown switches between:
- **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:
**Internal todos** (Step 2 of orientation) — workplan files in `workplans/`
whose stored workstation/status label is `active`, with tasks in `todo` or
`in_progress`.
whose stored workstation/status label is `active`, with tasks in `wait`,
`todo`, or `progress`.
**Ecosystem todos targeting this repo** (Step 1 of orientation) —
`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?
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
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 |
|---|---|
@@ -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.
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.

View File

@@ -20,8 +20,8 @@ by repository. Each bar is broken into four task-status segments:
| Colour | Segment |
|--------|---------|
| green | done |
| blue | in progress |
| orange-red | blocked |
| purple | progress |
| orange | wait |
| light grey | todo |
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 |
| `workstation_advanced` | Flow-aware movement via `advance_workstation()` succeeded |
| `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_resolved` | A pending decision was resolved |

View File

@@ -127,7 +127,7 @@ priority: medium
| Field | Values | Description |
|-------|--------|-------------|
| `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 |
---
@@ -137,8 +137,8 @@ priority: medium
As Claude completes tasks it edits the workplan file directly:
```
status: todo → status: in_progress (when starting)
status: in_progress → status: done (when verified complete)
status: todo → status: progress (when starting)
status: progress → status: done (when verified complete)
```
When every task is `done`, Claude also updates the frontmatter:

View File

@@ -5,7 +5,7 @@ title: Tasks — Reference
# Tasks — Reference
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.
---
@@ -17,11 +17,11 @@ compatibility.
| Workstation | Meaning |
|--------|---------|
| **wait** | Waiting on another actor, event, decision, input, or condition |
| **todo** | Not yet started |
| **in_progress** | Actively being worked on |
| **blocked** | Cannot proceed — has a blocking reason |
| **progress** | Actively being worked on |
| **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 |
|--------|--------|
| orange | wait |
| grey-blue | todo |
| blue | in_progress |
| red | blocked |
| purple | progress |
| 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:
- Priority badge and status
- Domain and workstream context
- Task title
- Blocking reason (amber background)
- Wait reason (amber background)
---
## KPI sidebar card
Shows four counts for the unfiltered dataset: open (todo + in_progress +
blocked), blocked, in progress, done, and a done-% of total.
Shows counts for the unfiltered dataset: open (`wait` + `todo` + `progress`),
waiting, progress, done, and a done-% of total.
---
## Sorting
Tasks are sorted by status (blocked first, then in_progress, todo, done,
cancelled) then by priority (critical → high → medium → low) within each
Tasks are sorted by status (wait first, then progress, todo, done, cancel) then
by priority (critical → high → medium → low) within each
status group.
---

View File

@@ -18,7 +18,7 @@ boundary rule and routing workflows.
### 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.
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 = {};
for (const t of taskList) {
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++;
if (t.status === "done") counts[wid].done++;
else if (t.status === "in_progress") counts[wid].in_progress++;
else if (t.status === "blocked") counts[wid].blocked++;
else if (t.status === "todo") counts[wid].todo++;
if (t.status === "done") counts[wid].done++;
else if (t.status === "progress") counts[wid].progress++;
else if (t.status === "wait") counts[wid].wait++;
else if (t.status === "todo") counts[wid].todo++;
}
wsAll = wsList.map(w => {
const repo = repoMap[w.repo_id];
@@ -78,7 +78,7 @@ const pageState = (async function*() {
workplan_archived: workplan.archived ?? false,
health_labels: workplan.health_labels ?? [],
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) {
@@ -233,13 +233,13 @@ for (const w of chartWs) {
_seen.add(group);
}
const statusOrder = ["done", "in progress", "blocked", "todo"];
const statusColors = ["#4caf50", "steelblue", "#ff7043", "#e0e0e0"];
const statusOrder = ["done", "progress", "wait", "todo"];
const statusColors = ["#4caf50", "#8b5cf6", "#f59e0b", "#e0e0e0"];
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: "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: "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: "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: "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},
]).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
```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 todayCount = (summary.recent_progress ?? []).filter(e =>
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>
<small>${decisions.escalated ?? 0} escalated</small>
</a>
<div class="card card-link ${blockedTasks.length > 0 ? 'warn' : ''}" data-toggle="blocked-panel">
<h3>Blocked Tasks</h3>
<p class="big-num">${blockedTasks.length}</p>
<div class="card card-link ${waitingTasks.length > 0 ? 'warn' : ''}" data-toggle="waiting-panel">
<h3>Waiting Tasks</h3>
<p class="big-num">${waitingTasks.length}</p>
<small>of ${tasks.total ?? 0} total · click to expand</small>
</div>
<a class="card card-link" href="#recent-activity">
@@ -400,10 +400,10 @@ const statusEl = html`<div>
</a>
</div>
<div id="blocked-panel" style="display:none;margin-bottom:1rem">
${blockedTasks.length === 0
? html`<p class="dim" style="padding:0.5rem 0">No tasks currently blocked.</p>`
: html`<div class="bt-list">${blockedTasks.map(t => {
<div id="waiting-panel" style="display:none;margin-bottom:1rem">
${waitingTasks.length === 0
? html`<p class="dim" style="padding:0.5rem 0">No tasks currently waiting.</p>`
: html`<div class="bt-list">${waitingTasks.map(t => {
const wsName = wsById[t.workstream_id]?.title ?? t.workstream_id?.slice(0,8) ?? "—";
return html`<div class="bt-row">
<div class="bt-meta">${wsName}</div>
@@ -415,11 +415,11 @@ const statusEl = html`<div>
</div>
</div>`;
statusEl.querySelector('[data-toggle="blocked-panel"]').addEventListener('click', () => {
const panel = statusEl.querySelector('#blocked-panel');
statusEl.querySelector('[data-toggle="waiting-panel"]').addEventListener('click', () => {
const panel = statusEl.querySelector('#waiting-panel');
const isOpen = panel.style.display !== 'none';
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`;
});

View File

@@ -51,7 +51,7 @@ const _ts = interventionState.ts;
```
```js
const OPEN_STATUSES = new Set(["todo", "in_progress", "blocked"]);
const OPEN_STATUSES = new Set(["wait", "todo", "progress"]);
// open = currently flagged for human action
// closed = previously flagged (intervention_note records the resolution comment)
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";
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) {
return [...arr].sort((a, b) => {
@@ -217,11 +217,11 @@ if (closed.length === 0) {
.task-priority-medium { background: #dbeafe; color: #1e40af; }
.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; }
.status-chip-blocked { background: #fee2e2; color: #991b1b; }
.status-chip-in_progress { background: #dbeafe; color: #1e40af; }
.status-chip-wait { background: #fef3c7; color: #92400e; }
.status-chip-progress { background: #ede9fe; color: #5b21b6; }
.status-chip-todo { background: #f1f5f9; color: #475569; }
.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-ws-name { font-style: italic; }
.dim { color: gray; font-style: italic; }

View File

@@ -50,7 +50,7 @@ const _ts = taskState.ts;
```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 _domainsResp = await fetch(`${API}/domains/?status=active`).catch(() => null);
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";
// ── KPI sidebar card ─────────────────────────────────────────────────────────
const _open = data.filter(t => ["todo", "in_progress", "blocked"].includes(t.status));
const _blocked = data.filter(t => t.status === "blocked");
const _inProg = data.filter(t => t.status === "in_progress");
const _open = data.filter(t => ["wait", "todo", "progress"].includes(t.status));
const _waiting = data.filter(t => t.status === "wait");
const _inProg = data.filter(t => t.status === "progress");
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 _kpiBox = html`<div class="kpi-infobox">
@@ -111,13 +111,13 @@ const _kpiBox = html`<div class="kpi-infobox">
</div>
</div>
<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-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 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-value">${_inProg.length}</div>
</div>
@@ -154,11 +154,11 @@ injectTocTop("live-indicator", _liveEl);
import * as Plot from "npm:@observablehq/plot";
const STATUS_COLOR = {
wait: "#f59e0b",
todo: "#94a3b8",
in_progress: "#3b82f6",
blocked: "#ef4444",
progress: "#8b5cf6",
done: "#22c55e",
cancelled: "#cbd5e1",
cancel: "#cbd5e1",
};
const byStatus = STATUSES
@@ -178,16 +178,16 @@ display(byStatus.length === 0
);
```
## Blocked Tasks
## Waiting Tasks
```js
const _blockedInFilter = filtered.filter(t => t.status === "blocked");
const _waitingInFilter = filtered.filter(t => t.status === "wait");
if (_blockedInFilter.length === 0) {
display(html`<p class="dim">No blocked tasks in current filter. ✓</p>`);
if (_waitingInFilter.length === 0) {
display(html`<p class="dim">No waiting tasks in current filter. ✓</p>`);
} else {
display(html`<div class="task-blocked-list">${_blockedInFilter.map(t => html`
<div class="task-blocked-item entity-row" onclick=${() => openEntityModal(t, "task")}>
display(html`<div class="task-waiting-list">${_waitingInFilter.map(t => html`
<div class="task-waiting-item entity-row" onclick=${() => openEntityModal(t, "task")}>
<div class="task-item-header">
<span class="task-badge task-priority-${t.priority}">${t.priority}</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>` : ""}
</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>`);
}
@@ -209,7 +209,7 @@ display(_filtersForm);
display(html`<p><strong>${filtered.length}</strong> tasks shown.</p>`);
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 sd = (STATUS_ORDER[a.status] ?? 9) - (STATUS_ORDER[b.status] ?? 9);
@@ -246,9 +246,9 @@ display(buildEntityTable(
/* ── Filters ──────────────────────────────────────────────────────────────── */
/* ── Blocked task cards ───────────────────────────────────────────────────── */
.task-blocked-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; }
/* ── Waiting task cards ───────────────────────────────────────────────────── */
.task-waiting-list { display: flex; flex-direction: column; gap: 0.5rem; }
.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-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; }
@@ -260,7 +260,7 @@ display(buildEntityTable(
.task-due { color: #dc2626; font-weight: 600; }
.task-assignee { color: var(--theme-foreground-muted, #888); }
.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 ──────────────────────────────────────────────────────────────── */
.dim { color: gray; font-style: italic; }

View File

@@ -60,7 +60,7 @@ const _ts = todoState.ts;
```js
// ── 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
const internal = tasks.filter(t =>
@@ -141,7 +141,7 @@ without a cross-repo routing prefix.
```js
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) {
return [...arr].sort((a, b) => {
@@ -249,8 +249,8 @@ if (improvements.length === 0) {
/* ── Task list ────────────────────────────────────────────────────────────── */
.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.status-blocked { border-left-color: #ef4444; }
.task-item.status-in_progress { border-left-color: #3b82f6; }
.task-item.status-wait { border-left-color: #f59e0b; }
.task-item.status-progress { border-left-color: #8b5cf6; }
.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-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-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; }
.status-chip-blocked { background: #fee2e2; color: #991b1b; }
.status-chip-in_progress { background: #dbeafe; color: #1e40af; }
.status-chip-wait { background: #fef3c7; color: #92400e; }
.status-chip-progress { background: #ede9fe; color: #5b21b6; }
.status-chip-todo { background: #f1f5f9; color: #475569; }
.task-context { color: var(--theme-foreground-muted, #666); }
.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>`);
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 statusCompare = (statusOrder[a.status] ?? 9) - (statusOrder[b.status] ?? 9);
if (statusCompare !== 0) return statusCompare;
@@ -131,8 +131,8 @@ if (raw.error) {
white-space: nowrap;
}
.task-status-done { background: #e8f5e9; color: #1b5e20; }
.task-status-in_progress { background: #e3f2fd; color: #0d47a1; }
.task-status-blocked { background: #fff3e0; color: #bf360c; }
.task-status-progress { background: #ede9fe; color: #5b21b6; }
.task-status-wait { background: #fff3e0; color: #bf360c; }
.task-status-todo { background: #f1f5f9; color: #334155; }
.task-status-cancelled { background: #f3f4f6; color: #6b7280; }
.task-status-cancel { background: #f3f4f6; color: #6b7280; }
</style>

View File

@@ -72,7 +72,7 @@ function okResponse(overrides = {}) {
target_id: "00000000-0000-0000-0000-000000000001",
actor: "dashboard",
current_status: "todo",
target_status: "in_progress",
target_status: "progress",
file_backed: true,
archived_file: false,
task_linked: true,
@@ -102,23 +102,23 @@ test("status control posts dashboard changes through reconciliation", async () =
const root = statusControl({
entity,
type: "task",
statuses: ["todo", "in_progress"],
statuses: ["todo", "progress"],
onSaved: (updated, result) => {
saved = {updated, result};
},
});
const [select, message] = root.children;
select.value = "in_progress";
select.value = "progress";
await select.listeners.change();
assert.equal(requests.length, 1);
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_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.apply, true);
assert.equal(entity.status, "in_progress");
assert.equal(entity.status, "progress");
assert.equal(message.textContent, "synced");
assert.equal(saved.result.write_through_result, "applied");
});
@@ -131,7 +131,7 @@ test("status control keeps local state on reconciliation conflicts", async () =>
ok: true,
json: async () => okResponse({
current_status: "done",
target_status: "in_progress",
target_status: "progress",
reconciliation_class: "deferred",
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",
@@ -147,14 +147,14 @@ test("status control keeps local state on reconciliation conflicts", async () =>
const root = statusControl({
entity,
type: "task",
statuses: ["todo", "in_progress"],
statuses: ["todo", "progress"],
onSaved: () => {
saved = true;
},
});
const [select, message] = root.children;
select.value = "in_progress";
select.value = "progress";
await select.listeners.change();
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.decision.resolved` | `api/routers/decisions.py:resolve_decision_action` |
| `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`)
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
id: the-custodian.state-hub-stale-task-cleanup
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
org.statehub.task.stale on NATS for downstream reaction.
trigger:

View File

@@ -46,8 +46,8 @@ exit_assertions:
- id: tasks.all_done
target: tasks.*.status
op: all_eq
value: [done, cancelled]
description: All child tasks are done or cancelled.
value: [done, cancel]
description: All child tasks are done or canceled.
```
Schema:
@@ -127,7 +127,7 @@ exit_blocked: true
blocking_assertions:
- id: tasks.all_done
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:
- ready
- active
@@ -136,7 +136,7 @@ unreachable:
blocking:
id: tasks.all_done
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:
@@ -152,13 +152,13 @@ Schema:
### Workstreams
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`.
### Tasks
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
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
the repo changes; parked backlog work should not clutter the current work view.
Task status should remain separate. Tasks can keep using
`todo`, `in_progress`, `blocked`, `done`, and `cancelled`.
Task status should remain separate. Tasks use the InfoTechCanon-aligned
`wait`, `todo`, `progress`, `done`, and `cancel` lifecycle.
## Proposed Canonical Workplan States

View File

@@ -1,11 +1,15 @@
id: custodian.task.v1
entity_type: task
workstations:
- name: wait
description: Task is waiting on another actor, event, decision, input, or condition.
entry_assertions: []
exit_assertions: []
- name: todo
description: Task is known but not currently underway.
entry_assertions: []
exit_assertions: []
- name: in_progress
- name: progress
description: Task is being actively worked.
entry_assertions:
- id: task.needs_human_false
@@ -19,25 +23,11 @@ workstations:
op: all_eq
value: false
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
description: Task is complete.
entry_assertions: []
exit_assertions: []
- name: cancelled
- name: cancel
description: Task is intentionally not being completed.
entry_assertions: []
exit_assertions: []

View File

@@ -50,8 +50,8 @@ workstations:
op: all_eq
value:
- done
- cancelled
description: All child tasks are done or cancelled.
- cancel
description: All child tasks are done or canceled.
exit_assertions: []
- name: archived
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 |
|------|----------|-------------|
| `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. |
| `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_blocked_tasks(workstream_id?)` | optional filter | Surface all impediments, optionally scoped to one workstream. |
| `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 | Legacy name: surfaces `wait` tasks, optionally scoped to one workstream. |
| `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_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_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(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://workstreams/{topic_slug}` | Workstreams for a topic (by slug) |
| `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")
def resource_blocked_tasks() -> str:
"""All tasks with status=blocked."""
return json.dumps(_get("/tasks", {"status": "blocked"}), indent=2)
"""All tasks with status=wait. Legacy resource name kept for compatibility."""
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.
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
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:
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
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()
def list_blocked_tasks(workstream_id: str | None = None) -> str:
"""List all tasks with status=blocked, optionally filtered by workstream_id."""
return json.dumps(_get("/tasks", {"status": "blocked", "workstream_id": workstream_id}), indent=2)
"""List all waiting tasks, optionally filtered by workstream_id."""
return json.dumps(_get("/tasks", {"status": "wait", "workstream_id": workstream_id}), indent=2)
@mcp.tool()
@@ -512,7 +512,7 @@ def update_task_status(
agent: Optional[str] = None,
session_id: Optional[str] = None,
) -> 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:
Tier 1 (best): pass tokens_in + tokens_out — exact counts from the session
@@ -526,8 +526,8 @@ def update_task_status(
Args:
task_id: UUID of the task
status: todo | in_progress | blocked | done | cancelled
blocking_reason: required when status=blocked
status: wait | todo | progress | done | cancel
blocking_reason: optional wait-condition detail
tokens_in: exact input 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)

View File

@@ -29,7 +29,7 @@ def upgrade() -> None:
"active", "blocked", "completed", "archived", name="workstreamstatus", create_type=True
)
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(
"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
Exit codes:
0 — ran successfully (zero or more tasks cancelled)
0 — ran successfully (zero or more tasks canceled)
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__))))
from api.workplan_status import CLOSED_WORKSTREAM_STATUSES, normalize_workstream_status
from api.task_status import OPEN_TASK_STATUSES, normalize_task_status
try:
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]
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)
@@ -91,7 +92,7 @@ def main() -> int:
stale = [
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
]
@@ -115,10 +116,10 @@ def main() -> int:
try:
patch(
f"/tasks/{t['id']}/",
{"status": "cancelled", "blocking_reason": reason},
{"status": "cancel", "blocking_reason": reason},
)
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:
subject = "org.statehub.task.stale"
nats_events.append((
@@ -155,11 +156,11 @@ def main() -> int:
by_ws.setdefault(closed_ws[t["workstream_id"]]["title"], []).append(t["title"])
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)"
)
detail = {
"cancelled_count": len(cancelled),
"canceled_count": len(cancelled),
"by_workstream": {ws: titles for ws, titles in by_ws.items()},
"error_count": len(errors),
}
@@ -173,7 +174,7 @@ def main() -> int:
print(f"[cleanup-stale] Completed with {len(errors)} error(s).")
return 1
print(f"[cleanup-stale] Done. {len(cancelled)} task(s) cancelled.")
print(f"[cleanup-stale] Done. {len(cancelled)} task(s) canceled.")
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-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-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:
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,
)
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:
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)$")
VALID_WP_STATUSES = set(CANONICAL_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_DEP_RELATIONSHIPS = {"blocks", "starts_after", "informs", "soft_dependency"}
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)
# 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] = {
"todo": 0,
"in_progress": 1,
"blocked": 1,
"done": 2,
"cancelled": 2,
}
STATUS_ORDER: dict[str, int] = dict(TASK_STATUS_ORDER)
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)
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:
"""Return the workplan filename without an archive completion-date prefix."""
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.",
detection=(
"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.",
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('"')
if t_sh_id in ("~", "null", "None", "none"):
t_sh_id = ""
t_status = str(task.get("status", "")).strip()
t_status = normalise_task_status(task.get("status", "todo"))
if 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
# 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:
db_rank = STATUS_ORDER.get(db_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:
if db_t["id"] not in file_task_sh_ids:
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
fixable = ws_finished and open_task
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:
non_terminal = [
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:
report.add(
severity="WARN", check_id="C-13",
message=(
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()"
),
file_path=fname,
@@ -1358,8 +1365,8 @@ def _git_commit_writeback(
# ---------------------------------------------------------------------------
_BRIEF_HEADER = "<!-- custodian-brief: generated by fix-consistency — do not edit manually -->"
_TASK_STATUS_ICON = {"done": "", "cancelled": "", "in_progress": "", "blocked": "!", "todo": "·"}
_OPEN_STATUSES = {"todo", "in_progress", "blocked"}
_TASK_STATUS_ICON = {"done": "", "cancel": "", "progress": "", "wait": "!", "todo": "·"}
_OPEN_STATUSES = set(OPEN_TASK_STATUSES)
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):
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)
pct = f"{done}/{total}" if total else "no tasks"
open_tasks = [t for t in tasks if t.get("status") in _OPEN_STATUSES]
# Show blocked first, then in_progress, then todo (cap at 5)
priority_order = {"blocked": 0, "in_progress": 1, "todo": 2}
open_tasks.sort(key=lambda t: priority_order.get(t.get("status", "todo"), 9))
open_tasks = [
t for t in tasks
if normalise_task_status(t.get("status", "todo")) in _OPEN_STATUSES
]
# 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 += [
"",
@@ -1448,14 +1465,14 @@ def _write_custodian_brief(api_base: str, repo_slug: str, repo_path: str) -> boo
lines.append("")
lines.append("**Open tasks:**")
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"])
tid = t["id"]
status = t.get("status", "")
blocker = t.get("blocking_reason", "")
task_line = f"- {icon} {title} `{tid[:8]}`"
if status == "blocked" and blocker:
task_line += f"\n *(blocked: {blocker})*"
if status == "wait" and blocker:
task_line += f"\n *(wait: {blocker})*"
lines.append(task_line)
if len(open_tasks) > 7:
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()
if not t_id:
continue
t_status = str(task.get("status", "todo")).strip()
if t_status not in VALID_TASK_STATUSES:
t_status = "todo"
t_status = normalise_task_status(task.get("status", "todo"))
t_priority = str(task.get("priority", "medium")).strip()
if t_priority not in VALID_TASK_PRIORITIES:
t_priority = "medium"
@@ -1767,9 +1782,7 @@ def fix_repo(
f"C-11 skipped: task '{t_id}' in {ws_status} workstream — not created"
)
else:
t_status = str(task.get("status", "todo")).strip()
if t_status not in VALID_TASK_STATUSES:
t_status = "todo"
t_status = normalise_task_status(task.get("status", "todo"))
t_priority = str(task.get("priority", "medium")).strip()
if t_priority not in VALID_TASK_PRIORITIES:
t_priority = "medium"
@@ -1795,10 +1808,10 @@ def fix_repo(
elif issue.check_id == "C-12":
task_id = ctx["task_id"]
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:
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":
@@ -2013,7 +2026,7 @@ def archive_closed_workplans(
tasks = get_tasks_from_workplan(meta, body)
open_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:
continue

View File

@@ -63,8 +63,8 @@ Omit `workstream_id` / `task_id` when not applicable.
```bash
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
-H "Content-Type: application/json" \
-d '{"status": "in_progress"}'
# values: todo | in_progress | done | blocked
-d '{"status": "progress"}'
# values: wait | todo | progress | done | cancel
```
### Flag a task for human review
@@ -83,7 +83,7 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
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
3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks
4. Check blocked tasks: `GET /tasks/?needs_human=true`
4. Check human-needed tasks: `GET /tasks/?needs_human=true`
**During work:**
- Update task statuses in workplan files as tasks progress
@@ -146,7 +146,7 @@ derived health labels, not frontmatter statuses.
` ` `task
id: {WP_PREFIX}-NNNN-T01
status: todo | in_progress | done | blocked
status: wait | todo | progress | done | cancel
priority: high | medium | low
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.
```
Status progression: `todo` → `in_progress` → `done` (or `blocked`)
Status progression: `todo` → `progress` → `done`; use `wait` for waiting/blocked work and `cancel` for stopped work.
To create a new workplan:
1. Write the file following the format above

View File

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

View File

@@ -49,6 +49,7 @@ from api.workplan_status import ( # noqa: E402
SUPPORTED_WORKSTREAM_STATUSES,
normalize_workstream_status,
)
from api.task_status import CANONICAL_TASK_STATUSES, normalize_task_status # noqa: E402
try:
import yaml as _yaml
@@ -70,7 +71,7 @@ except ImportError:
REQUIRED_FRONTMATTER = {"id", "type", "title", "domain", "status", "owner", "created"}
VALID_WP_STATUSES = set(CANONICAL_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"}
_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", ""))
if not t_status:
report.add(Level.FAIL, "task-status", "Missing 'status' field", tref)
elif t_status not in VALID_TASK_STATUSES:
report.add(Level.FAIL, "task-status-value",
f"status {t_status!r} not in {sorted(VALID_TASK_STATUSES)}", tref)
else:
try:
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", ""))
if not t_prio:
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()
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={
"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()
if status != "todo":
patch = {"status": status}
if status == "blocked":
if status == "wait":
patch["blocking_reason"] = "Waiting for capability request"
r2 = await client.patch(f"/tasks/{task['id']}", json=patch)
assert r2.status_code == 200, r2.text
@@ -229,7 +229,7 @@ class TestCapabilityRequestLifecycle:
await _setup_two_domains(client)
topic = await _create_topic(client, "custodian")
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"])

View File

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

View File

@@ -16,7 +16,7 @@ from api.services.lifecycle import (
def test_task_start_activates_planning_parent(parent_status):
assert should_activate_parent_for_task_start(
previous_task_status="todo",
new_task_status="in_progress",
new_task_status="progress",
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):
assert not should_activate_parent_for_task_start(
previous_task_status="todo",
new_task_status="in_progress",
new_task_status="progress",
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(
previous_task_status="in_progress",
new_task_status="in_progress",
previous_task_status="progress",
new_task_status="progress",
parent_workstream_status="ready",
)
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():
assert has_active_task_status(["todo", "done", "cancelled"]) is False
assert has_active_task_status(["todo", "blocked"]) is True
assert has_active_task_status(["in_progress"]) is True
assert has_active_task_status(["todo", "done", "cancel"]) is False
assert has_active_task_status(["todo", "wait"]) is True
assert has_active_task_status(["progress"]) is True
def test_active_task_state_activates_planning_parent_for_renormalization():
assert should_activate_parent_for_active_tasks(
parent_workstream_status="proposed",
task_statuses=["todo", "in_progress"],
task_statuses=["todo", "progress"],
)
def test_active_task_state_does_not_unblock_blocked_parent():
assert not should_activate_parent_for_active_tasks(
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:
value = "In_Progress"
assert status_value(Status()) == "in_progress"
assert status_value(Status()) == "progress"
def test_transition_workstream_status_normalizes_aliases():
@@ -92,10 +92,10 @@ def test_transition_task_status_activates_parent_once():
task = Task()
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 result.parent_activated is True
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():
result = classify_task_status_change(
current_status="todo",
target_status="in_progress",
target_status="progress",
file_backed=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():
result = classify_task_status_change(
current_status="todo",
target_status="in_progress",
target_status="progress",
file_backed=False,
task_linked=True,
)
@@ -92,7 +92,7 @@ def test_task_without_file_is_deferred():
def test_unlinked_task_is_deferred_until_link_repaired():
result = classify_task_status_change(
current_status="todo",
target_status="in_progress",
target_status="progress",
file_backed=True,
task_linked=False,
)
@@ -101,22 +101,22 @@ def test_unlinked_task_is_deferred_until_link_repaired():
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(
current_status="todo",
target_status="blocked",
target_status="wait",
file_backed=True,
task_linked=True,
)
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(
current_status="todo",
target_status="blocked",
target_status="wait",
file_backed=True,
task_linked=True,
blocking_reason="Waiting on dependency",

View File

@@ -207,7 +207,7 @@ class TestTasks:
r = await client.delete(f"/tasks/{task['id']}")
assert r.status_code == 200
assert r.json()["status"] == "cancelled"
assert r.json()["status"] == "cancel"
async def test_filter_by_priority(self, client):
await _create_domain(client)
@@ -238,7 +238,7 @@ class TestTasks:
)
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
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")
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
r = await client.get(f"/workstreams/{ws['id']}")
@@ -326,13 +326,13 @@ class TestStateSummary:
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["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']}",
json={"status": "blocked", "blocking_reason": "waiting on dep"})
json={"status": "wait", "blocking_reason": "waiting on dep"})
r = await client.get("/state/summary")
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):
await _create_domain(client)
@@ -449,7 +449,7 @@ class TestReconciliationEndpoints:
assert body["reconciliation_class"] == "human_confirmation"
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)
topic = await _create_topic(client)
ws = await _create_workstream(client, topic["id"])
@@ -458,7 +458,7 @@ class TestReconciliationEndpoints:
r = await client.post("/reconciliation/state-change", json={
"target_type": "task",
"target_id": task["id"],
"target_status": "blocked",
"target_status": "wait",
"file_backed": True,
"task_linked": True,
})
@@ -467,7 +467,7 @@ class TestReconciliationEndpoints:
body = r.json()
assert body["current_status"] == "todo"
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):
r = await client.post("/reconciliation/state-change", json={
@@ -582,7 +582,7 @@ class TestReconciliationEndpoints:
r = await client.post("/reconciliation/state-change", json={
"target_type": "task",
"target_id": task["id"],
"target_status": "in_progress",
"target_status": "progress",
"actor": "dashboard",
"apply": True,
})
@@ -592,10 +592,10 @@ class TestReconciliationEndpoints:
assert body["reconciliation_class"] == "write_through"
assert body["write_through_result"] == "applied"
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']}")
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):
await _create_domain(client)
@@ -630,7 +630,7 @@ class TestReconciliationEndpoints:
r = await client.post("/reconciliation/state-change", json={
"target_type": "task",
"target_id": task["id"],
"target_status": "in_progress",
"target_status": "progress",
"actor": "dashboard",
"expected_current_status": "todo",
"apply": True,
@@ -641,13 +641,13 @@ class TestReconciliationEndpoints:
assert body["write_through_result"] == "applied"
text = wp.read_text(encoding="utf-8")
assert "status: active" in text
assert "status: in_progress" in text
assert "status: progress" in text
r = await client.get(f"/workstreams/{ws['id']}")
assert r.json()["status"] == "active"
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):
await _create_domain(client)
@@ -682,7 +682,7 @@ class TestReconciliationEndpoints:
r = await client.post("/reconciliation/state-change", json={
"target_type": "task",
"target_id": task["id"],
"target_status": "blocked",
"target_status": "wait",
"apply": True,
})
@@ -698,7 +698,7 @@ class TestReconciliationEndpoints:
r = await client.get("/messages/?to_agent=state-hub&unread_only=true")
messages = r.json()
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):
await _create_domain(client)
@@ -792,7 +792,7 @@ class TestReconciliationEndpoints:
r = await client.post("/reconciliation/state-change", json={
"target_type": "task",
"target_id": task["id"],
"target_status": "in_progress",
"target_status": "progress",
"expected_current_status": "todo",
"apply": True,
})

View File

@@ -18,7 +18,7 @@ def test_all_assertions_satisfied_reports_reachable_workstations():
id="tasks.all_done",
target="tasks.*.status",
op="all_eq",
value=["done", "cancelled"],
value=["done", "cancel"],
)
],
),
@@ -26,7 +26,7 @@ def test_all_assertions_satisfied_reports_reachable_workstations():
)
result = FlowEngine().evaluate(
{"status": "active", "tasks": [{"status": "done"}, {"status": "cancelled"}]},
{"status": "active", "tasks": [{"status": "done"}, {"status": "cancel"}]},
flow,
)
@@ -47,7 +47,7 @@ def test_failing_exit_assertion_identifies_blocking_assertion():
id="tasks.all_done",
target="tasks.*.status",
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",
target="tasks.*.status",
op="all_eq",
value=["done", "cancelled"],
value=["done", "cancel"],
)
],
),
@@ -144,7 +144,7 @@ def test_empty_assertions_make_all_workstations_reachable():
entity_type="task",
workstations=[
WorkstationDef(name="todo"),
WorkstationDef(name="in_progress"),
WorkstationDef(name="progress"),
WorkstationDef(name="done"),
],
)
@@ -152,7 +152,7 @@ def test_empty_assertions_make_all_workstations_reachable():
result = FlowEngine().evaluate({"status": "todo"}, flow)
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():
@@ -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]
task_result = FlowEngine().evaluate(
{"status": "blocked", "needs_human": False},
{"status": "wait", "needs_human": False},
flows["task"],
)
assert "in_progress" in task_result.reachable
assert "progress" in task_result.reachable
contribution_result = FlowEngine().evaluate(
{"status": "acknowledged", "previous_workstation": "acknowledged"},

View File

@@ -173,7 +173,7 @@ class TestTokenPassthrough:
ws = await _create_workstream(client, topic["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
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"
domain: custodian
repo: state-hub
status: proposed
status: active
owner: codex
topic_slug: custodian
created: "2026-05-25"
@@ -78,9 +78,10 @@ changing code:
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:
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
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
agents.
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
id: STATE-WP-0052-T01
status: todo
status: done
priority: high
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
id: STATE-WP-0052-T02
status: todo
status: done
priority: high
state_hub_task_id: "c79ce637-7140-4a16-8d20-b89edee4b98f"
```
@@ -146,7 +147,7 @@ required changes, and risk notes.
```task
id: STATE-WP-0052-T03
status: todo
status: done
priority: high
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
id: STATE-WP-0052-T04
status: todo
status: done
priority: high
state_hub_task_id: "bf4c6891-64a5-49e6-9bf6-d70577d494e7"
```
@@ -196,7 +197,7 @@ task values.
```task
id: STATE-WP-0052-T05
status: todo
status: done
priority: high
state_hub_task_id: "c9e6a649-8e1c-4d3c-b51a-1814687a99be"
```
@@ -219,7 +220,7 @@ still sync through a deliberate migration window.
```task
id: STATE-WP-0052-T06
status: todo
status: done
priority: high
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
id: STATE-WP-0052-T07
status: todo
status: done
priority: high
state_hub_task_id: "c884eea6-1b69-4636-86f2-475d5ec4ea9b"
```
@@ -266,7 +267,7 @@ reintroduction of old canonical literals.
```task
id: STATE-WP-0052-T08
status: todo
status: done
priority: high
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
id: STATE-WP-0052-T09
status: todo
status: done
priority: high
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
status code, and no-direct-impact repos;
- 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
`affected_repo_slugs`;
- create `[repo:<slug>]` ecosystem tasks for repos that need local adaptation;
@@ -320,7 +321,7 @@ task, or a recorded no-impact classification.
```task
id: STATE-WP-0052-T10
status: todo
status: wait
priority: medium
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
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
- State Hub persists canonical task states as `wait`, `todo`, `progress`,