generated from coulomb/repo-seed
feat(tasks): adopt canonical task statuses
This commit is contained in:
10
AGENTS.md
10
AGENTS.md
@@ -63,8 +63,8 @@ Omit `workstream_id` / `task_id` when not applicable.
|
||||
```bash
|
||||
curl -s -X PATCH "http://127.0.0.1:8000/tasks/<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
|
||||
|
||||
10
README.md
10
README.md
@@ -142,7 +142,7 @@ decisions (FK: topic_id, workstream_id — at least one required)
|
||||
|------|--------|
|
||||
| `topic_status` | `active` · `paused` · `archived` |
|
||||
| `workstream_status` | `proposed` · `ready` · `active` · `blocked` · `backlog` · `finished` · `archived` |
|
||||
| `task_status` | `todo` · `in_progress` · `blocked` · `done` · `cancelled` |
|
||||
| `task_status` | `wait` · `todo` · `progress` · `done` · `cancel` |
|
||||
| `task_priority` | `low` · `medium` · `high` · `critical` |
|
||||
| `decision_type` | `made` · `pending` |
|
||||
| `decision_status` | `open` · `resolved` · `escalated` · `superseded` |
|
||||
@@ -150,7 +150,7 @@ decisions (FK: topic_id, workstream_id — at least one required)
|
||||
|
||||
### Governance constraints encoded in schema
|
||||
|
||||
- No hard DELETE endpoints — only soft: `archived`, `cancelled`, `superseded`
|
||||
- No hard DELETE endpoints — only soft: `archived`, `cancel`, `superseded`
|
||||
- `progress_events` has no `updated_at` and no DELETE endpoint (append-only per constitution §5)
|
||||
- `decisions` with financial/legal keywords + `pending` type → auto-set `escalation_note` (§4)
|
||||
|
||||
@@ -170,12 +170,12 @@ Returns a full snapshot in one call — used by both the MCP server and dashboar
|
||||
"totals": {
|
||||
"topics": { "active": 6, "paused": 0, "archived": 0, "total": 6 },
|
||||
"workstreams": { "ready": 1, "active": 1, "blocked": 0, "finished": 1, "total": 3 },
|
||||
"tasks": { "todo": 9, "in_progress": 0, "blocked": 0, "done": 11, "total": 20 },
|
||||
"tasks": { "wait": 0, "todo": 9, "progress": 0, "done": 11, "cancel": 0, "total": 20 },
|
||||
"decisions": { "open": 1, "resolved": 0, "escalated": 0, "total": 1 }
|
||||
},
|
||||
"topics": [...], // topics with nested workstream stubs
|
||||
"blocking_decisions": [...], // pending decisions only
|
||||
"blocked_tasks": [...],
|
||||
"waiting_tasks": [...],
|
||||
"recent_progress": [...], // last 20 events
|
||||
"open_workstreams": [...]
|
||||
}
|
||||
@@ -187,7 +187,7 @@ Returns a full snapshot in one call — used by both the MCP server and dashboar
|
||||
|--------|-----------|
|
||||
| `/topics` | CRUD (soft-delete: `archived`) |
|
||||
| `/workstreams` | CRUD (soft-delete: `archived`) |
|
||||
| `/tasks` | CRUD (soft-delete: `cancelled`); `PATCH` updates status |
|
||||
| `/tasks` | CRUD (soft-delete: `cancel`); `PATCH` updates status |
|
||||
| `/decisions` | CRUD (soft-delete: `superseded`); auto-escalation |
|
||||
| `/progress` | `GET` list + `POST` append — no DELETE |
|
||||
| `/state/summary` | Full snapshot |
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
84
api/task_status.py
Normal 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
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`;
|
||||
});
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
93
docs/task-state-attached-repo-impact.md
Normal file
93
docs/task-state-attached-repo-impact.md
Normal 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.
|
||||
90
docs/task-state-canon-migration.md
Normal file
90
docs/task-state-canon-migration.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: []
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
57
migrations/versions/a4v5w6x7y8z9_task_status_canon.py
Normal file
57
migrations/versions/a4v5w6x7y8z9_task_status_canon.py
Normal 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")
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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**
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user