generated from coulomb/repo-seed
Complete workplan state model cleanup
This commit is contained in:
@@ -141,7 +141,7 @@ decisions (FK: topic_id, workstream_id — at least one required)
|
|||||||
| Enum | Values |
|
| Enum | Values |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| `topic_status` | `active` · `paused` · `archived` |
|
| `topic_status` | `active` · `paused` · `archived` |
|
||||||
| `workstream_status` | `active` · `blocked` · `completed` · `archived` |
|
| `workstream_status` | `proposed` · `ready` · `active` · `blocked` · `backlog` · `finished` · `archived` |
|
||||||
| `task_status` | `todo` · `in_progress` · `blocked` · `done` · `cancelled` |
|
| `task_status` | `todo` · `in_progress` · `blocked` · `done` · `cancelled` |
|
||||||
| `task_priority` | `low` · `medium` · `high` · `critical` |
|
| `task_priority` | `low` · `medium` · `high` · `critical` |
|
||||||
| `decision_type` | `made` · `pending` |
|
| `decision_type` | `made` · `pending` |
|
||||||
@@ -169,7 +169,7 @@ Returns a full snapshot in one call — used by both the MCP server and dashboar
|
|||||||
"generated_at": "...",
|
"generated_at": "...",
|
||||||
"totals": {
|
"totals": {
|
||||||
"topics": { "active": 6, "paused": 0, "archived": 0, "total": 6 },
|
"topics": { "active": 6, "paused": 0, "archived": 0, "total": 6 },
|
||||||
"workstreams": { "active": 1, "blocked": 0, "completed": 1, "total": 2 },
|
"workstreams": { "ready": 1, "active": 1, "blocked": 0, "finished": 1, "total": 3 },
|
||||||
"tasks": { "todo": 9, "in_progress": 0, "blocked": 0, "done": 11, "total": 20 },
|
"tasks": { "todo": 9, "in_progress": 0, "blocked": 0, "done": 11, "total": 20 },
|
||||||
"decisions": { "open": 1, "resolved": 0, "escalated": 0, "total": 1 }
|
"decisions": { "open": 1, "resolved": 0, "escalated": 0, "total": 1 }
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -51,7 +51,10 @@ def _dependencies_any_incomplete(
|
|||||||
obj: dict[str, Any],
|
obj: dict[str, Any],
|
||||||
values: list[Any],
|
values: list[Any],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
return bool(values) and any(value != assertion.value for value in values)
|
expected = assertion.value
|
||||||
|
if isinstance(expected, list):
|
||||||
|
return bool(values) and any(value not in expected for value in values)
|
||||||
|
return bool(values) and any(value != expected for value in values)
|
||||||
|
|
||||||
|
|
||||||
def assertion_result_to_dict(result: AssertionResult) -> dict[str, Any]:
|
def assertion_result_to_dict(result: AssertionResult) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from api.models.contribution import Contribution
|
|||||||
from api.models.task import Task
|
from api.models.task import Task
|
||||||
from api.models.workstream import Workstream
|
from api.models.workstream import Workstream
|
||||||
from api.models.workstream_dependency import WorkstreamDependency
|
from api.models.workstream_dependency import WorkstreamDependency
|
||||||
|
from api.workplan_status import normalize_workstream_status
|
||||||
|
|
||||||
router = APIRouter(prefix="/flows", tags=["flows"])
|
router = APIRouter(prefix="/flows", tags=["flows"])
|
||||||
|
|
||||||
@@ -104,11 +105,12 @@ async def _flow_object(
|
|||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
entity = await _entity(entity_type, entity_id, session)
|
entity = await _entity(entity_type, entity_id, session)
|
||||||
status = _value(entity.status)
|
status = _value(entity.status)
|
||||||
|
current_status = normalize_workstream_status(status) if entity_type == "workstream" else status
|
||||||
obj: dict[str, Any] = {
|
obj: dict[str, Any] = {
|
||||||
"id": str(entity.id),
|
"id": str(entity.id),
|
||||||
"status": status,
|
"status": current_status,
|
||||||
"workstation": status,
|
"workstation": current_status,
|
||||||
"previous_workstation": status,
|
"previous_workstation": current_status,
|
||||||
}
|
}
|
||||||
|
|
||||||
if entity_type == "workstream":
|
if entity_type == "workstream":
|
||||||
@@ -127,7 +129,7 @@ async def _flow_object(
|
|||||||
select(Workstream).where(Workstream.id.in_(dependency_ids))
|
select(Workstream).where(Workstream.id.in_(dependency_ids))
|
||||||
)).scalars().all())
|
)).scalars().all())
|
||||||
dependency_workstations = [
|
dependency_workstations = [
|
||||||
{"id": str(ws.id), "workstation": ws.status}
|
{"id": str(ws.id), "workstation": normalize_workstream_status(ws.status)}
|
||||||
for ws in dep_ws
|
for ws in dep_ws
|
||||||
]
|
]
|
||||||
obj.update({
|
obj.update({
|
||||||
|
|||||||
@@ -38,6 +38,11 @@ from api.schemas.task import TaskRead
|
|||||||
from api.schemas.topic import TopicRead, TopicWithWorkstreams
|
from api.schemas.topic import TopicRead, TopicWithWorkstreams
|
||||||
from api.schemas.workstream import WorkstreamRead, WorkstreamWithTaskCounts, WorkstreamWithDeps
|
from api.schemas.workstream import WorkstreamRead, WorkstreamWithTaskCounts, WorkstreamWithDeps
|
||||||
from api.schemas.workstream_dependency import WorkstreamDepStub
|
from api.schemas.workstream_dependency import WorkstreamDepStub
|
||||||
|
from api.workplan_status import (
|
||||||
|
CLOSED_WORKSTREAM_STATUSES,
|
||||||
|
OPEN_WORKSTREAM_STATUSES,
|
||||||
|
normalize_workstream_status,
|
||||||
|
)
|
||||||
from task_flow_engine import FlowEngine
|
from task_flow_engine import FlowEngine
|
||||||
|
|
||||||
router = APIRouter(prefix="/state", tags=["state"])
|
router = APIRouter(prefix="/state", tags=["state"])
|
||||||
@@ -119,7 +124,7 @@ async def get_summary(
|
|||||||
open_ws_rows = await session.execute(
|
open_ws_rows = await session.execute(
|
||||||
select(Workstream)
|
select(Workstream)
|
||||||
.options(noload("*"))
|
.options(noload("*"))
|
||||||
.where(Workstream.status.in_(["active", "blocked"]))
|
.where(Workstream.status.in_(OPEN_WORKSTREAM_STATUSES))
|
||||||
.order_by(Workstream.due_date.asc().nullslast(), Workstream.created_at)
|
.order_by(Workstream.due_date.asc().nullslast(), Workstream.created_at)
|
||||||
)
|
)
|
||||||
open_ws = list(open_ws_rows.scalars().all())
|
open_ws = list(open_ws_rows.scalars().all())
|
||||||
@@ -211,7 +216,7 @@ async def get_summary(
|
|||||||
"workstation": w.status,
|
"workstation": w.status,
|
||||||
"tasks": [{"status": status} for status in task_statuses_per_ws.get(w.id, [])],
|
"tasks": [{"status": status} for status in task_statuses_per_ws.get(w.id, [])],
|
||||||
"dependencies": [
|
"dependencies": [
|
||||||
{"workstation": ws_lookup[d.to_workstream_id].status}
|
{"workstation": normalize_workstream_status(ws_lookup[d.to_workstream_id].status)}
|
||||||
for d in dep_rows
|
for d in dep_rows
|
||||||
if d.from_workstream_id == w.id and d.to_workstream_id and d.to_workstream_id in ws_lookup
|
if d.from_workstream_id == w.id and d.to_workstream_id and d.to_workstream_id in ws_lookup
|
||||||
],
|
],
|
||||||
@@ -244,9 +249,16 @@ async def get_summary(
|
|||||||
total=sum(topic_counts.values()),
|
total=sum(topic_counts.values()),
|
||||||
),
|
),
|
||||||
workstreams=WorkstreamTotals(
|
workstreams=WorkstreamTotals(
|
||||||
|
proposed=ws_counts.get("proposed", 0),
|
||||||
|
ready=ws_counts.get("ready", 0) + ws_counts.get("todo", 0),
|
||||||
active=sum(1 for status in effective_status.values() if status == "active"),
|
active=sum(1 for status in effective_status.values() if status == "active"),
|
||||||
blocked=sum(1 for status in effective_status.values() if status == "blocked"),
|
blocked=sum(1 for status in effective_status.values() if status == "blocked"),
|
||||||
completed=ws_counts.get("completed", 0),
|
backlog=ws_counts.get("backlog", 0),
|
||||||
|
finished=(
|
||||||
|
ws_counts.get("finished", 0)
|
||||||
|
+ ws_counts.get("completed", 0)
|
||||||
|
+ ws_counts.get("accepted", 0)
|
||||||
|
),
|
||||||
archived=ws_counts.get("archived", 0),
|
archived=ws_counts.get("archived", 0),
|
||||||
total=sum(ws_counts.values()),
|
total=sum(ws_counts.values()),
|
||||||
),
|
),
|
||||||
@@ -366,7 +378,7 @@ async def _build_domain_summaries(session: AsyncSession) -> list[DomainSummary]:
|
|||||||
for domain_id, cnt in await session.execute(
|
for domain_id, cnt in await session.execute(
|
||||||
select(Topic.domain_id, func.count(Workstream.id))
|
select(Topic.domain_id, func.count(Workstream.id))
|
||||||
.join(Workstream, Workstream.topic_id == Topic.id)
|
.join(Workstream, Workstream.topic_id == Topic.id)
|
||||||
.where(Workstream.status == "active")
|
.where(Workstream.status.in_(["active", "blocked"]))
|
||||||
.group_by(Topic.domain_id)
|
.group_by(Topic.domain_id)
|
||||||
):
|
):
|
||||||
ws_per_domain[domain_id] = cnt
|
ws_per_domain[domain_id] = cnt
|
||||||
@@ -405,7 +417,7 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr
|
|||||||
open_ws_rows = await session.execute(
|
open_ws_rows = await session.execute(
|
||||||
select(Workstream)
|
select(Workstream)
|
||||||
.options(noload("*"))
|
.options(noload("*"))
|
||||||
.where(Workstream.status.in_(["active", "blocked"]))
|
.where(Workstream.status.in_(OPEN_WORKSTREAM_STATUSES))
|
||||||
.order_by(Workstream.due_date.asc().nullslast(), Workstream.created_at)
|
.order_by(Workstream.due_date.asc().nullslast(), Workstream.created_at)
|
||||||
)
|
)
|
||||||
open_ws = list(open_ws_rows.scalars().all())
|
open_ws = list(open_ws_rows.scalars().all())
|
||||||
@@ -488,7 +500,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
|||||||
|
|
||||||
Two signal sources:
|
Two signal sources:
|
||||||
1. Recently resolved decisions (last 7 days) → first open task in same workstream
|
1. Recently resolved decisions (last 7 days) → first open task in same workstream
|
||||||
2. Workstreams whose every dependency is now completed → first todo task in that workstream
|
2. Workstreams whose every dependency is now finished -> first todo task in that workstream
|
||||||
"""
|
"""
|
||||||
steps: list[NextStep] = []
|
steps: list[NextStep] = []
|
||||||
seen_task_ids: set = set()
|
seen_task_ids: set = set()
|
||||||
@@ -575,8 +587,11 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
|||||||
ready_from_ws_ids = [
|
ready_from_ws_ids = [
|
||||||
from_ws_id
|
from_ws_id
|
||||||
for from_ws_id, to_ws_ids in dep_map.items()
|
for from_ws_id, to_ws_ids in dep_map.items()
|
||||||
if ws_info.get(from_ws_id, {}).get("status") in ("active", "blocked")
|
if normalize_workstream_status(ws_info.get(from_ws_id, {}).get("status")) in OPEN_WORKSTREAM_STATUSES
|
||||||
and all(ws_info.get(to_id, {}).get("status") == "completed" for to_id in to_ws_ids)
|
and all(
|
||||||
|
normalize_workstream_status(ws_info.get(to_id, {}).get("status")) in CLOSED_WORKSTREAM_STATUSES
|
||||||
|
for to_id in to_ws_ids
|
||||||
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
todo_by_ws: dict = {}
|
todo_by_ws: dict = {}
|
||||||
@@ -613,7 +628,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
|
|||||||
task_id=task.id,
|
task_id=task.id,
|
||||||
task_title=task.title,
|
task_title=task.title,
|
||||||
message=(
|
message=(
|
||||||
f"All dependencies of '{from_ws['title']}' are completed ({blocker_slugs}) → "
|
f"All dependencies of '{from_ws['title']}' are finished ({blocker_slugs}) -> "
|
||||||
f"'{task.title}' is ready to start"
|
f"'{task.title}' is ready to start"
|
||||||
),
|
),
|
||||||
))
|
))
|
||||||
@@ -650,7 +665,7 @@ async def get_next_steps(session: AsyncSession = Depends(get_session)) -> list[N
|
|||||||
|
|
||||||
Returns suggestions based on:
|
Returns suggestions based on:
|
||||||
- Recently resolved decisions → first open task in the same workstream
|
- Recently resolved decisions → first open task in the same workstream
|
||||||
- Workstreams whose every dependency workstream is now completed → first todo task
|
- Workstreams whose every dependency workstream is now finished -> first todo task
|
||||||
"""
|
"""
|
||||||
return await _derive_next_steps(session)
|
return await _derive_next_steps(session)
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import time
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -16,9 +17,13 @@ from api.models.workstream import Workstream
|
|||||||
from api.schemas.workstream import (
|
from api.schemas.workstream import (
|
||||||
WorkstreamCreate,
|
WorkstreamCreate,
|
||||||
WorkstreamRead,
|
WorkstreamRead,
|
||||||
WorkstreamStatus,
|
|
||||||
WorkstreamUpdate,
|
WorkstreamUpdate,
|
||||||
)
|
)
|
||||||
|
from api.workplan_status import (
|
||||||
|
is_supported_workstream_status,
|
||||||
|
normalize_workstream_status,
|
||||||
|
ready_review_status,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/workstreams", tags=["workstreams"])
|
router = APIRouter(prefix="/workstreams", tags=["workstreams"])
|
||||||
|
|
||||||
@@ -53,17 +58,10 @@ def _frontmatter(path: Path) -> dict[str, Any]:
|
|||||||
if end == -1:
|
if end == -1:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
data: dict[str, Any] = {}
|
try:
|
||||||
for raw_line in text[4:end].splitlines():
|
return yaml.safe_load(text[4:end].strip()) or {}
|
||||||
line = raw_line.strip()
|
except yaml.YAMLError:
|
||||||
if not line or line.startswith("#") or ":" not in line:
|
return {}
|
||||||
continue
|
|
||||||
key, value = line.split(":", 1)
|
|
||||||
value = value.strip()
|
|
||||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}:
|
|
||||||
value = value[1:-1]
|
|
||||||
data[key.strip()] = value
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/", response_model=list[WorkstreamRead])
|
@router.get("/", response_model=list[WorkstreamRead])
|
||||||
@@ -71,7 +69,7 @@ async def list_workstreams(
|
|||||||
topic_id: uuid.UUID | None = None,
|
topic_id: uuid.UUID | None = None,
|
||||||
repo_id: uuid.UUID | None = None,
|
repo_id: uuid.UUID | None = None,
|
||||||
repo_goal_id: uuid.UUID | None = None,
|
repo_goal_id: uuid.UUID | None = None,
|
||||||
status: WorkstreamStatus | None = None,
|
status: str | None = None,
|
||||||
owner: str | None = None,
|
owner: str | None = None,
|
||||||
slug: str | None = None,
|
slug: str | None = None,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
@@ -84,7 +82,10 @@ async def list_workstreams(
|
|||||||
if repo_goal_id:
|
if repo_goal_id:
|
||||||
q = q.where(Workstream.repo_goal_id == repo_goal_id)
|
q = q.where(Workstream.repo_goal_id == repo_goal_id)
|
||||||
if status:
|
if status:
|
||||||
q = q.where(Workstream.status == status)
|
normalised_status = normalize_workstream_status(status)
|
||||||
|
if not is_supported_workstream_status(status):
|
||||||
|
raise HTTPException(status_code=422, detail=f"Unsupported workstream status '{status}'")
|
||||||
|
q = q.where(Workstream.status == normalised_status)
|
||||||
if owner:
|
if owner:
|
||||||
q = q.where(Workstream.owner == owner)
|
q = q.where(Workstream.owner == owner)
|
||||||
if slug:
|
if slug:
|
||||||
@@ -127,11 +128,24 @@ async def workplan_index(
|
|||||||
workstream_id = data.get("state_hub_workstream_id")
|
workstream_id = data.get("state_hub_workstream_id")
|
||||||
if not workstream_id:
|
if not workstream_id:
|
||||||
continue
|
continue
|
||||||
|
file_status = normalize_workstream_status(data.get("status", ""))
|
||||||
|
review = (
|
||||||
|
ready_review_status(
|
||||||
|
root,
|
||||||
|
data.get("reviewed_against_commit"),
|
||||||
|
data.get("context_paths"),
|
||||||
|
)
|
||||||
|
if file_status == "ready"
|
||||||
|
else None
|
||||||
|
)
|
||||||
index[str(workstream_id)] = {
|
index[str(workstream_id)] = {
|
||||||
"filename": path.name,
|
"filename": path.name,
|
||||||
"relative_path": str(path.relative_to(root)),
|
"relative_path": str(path.relative_to(root)),
|
||||||
"repo_slug": repo.slug,
|
"repo_slug": repo.slug,
|
||||||
"archived": archived,
|
"archived": archived,
|
||||||
|
"status": file_status or None,
|
||||||
|
"needs_review": bool(review and review.needs_review),
|
||||||
|
"health_labels": ["needs_review"] if review and review.needs_review else [],
|
||||||
}
|
}
|
||||||
_INDEX_CACHE = {"workstreams": index}
|
_INDEX_CACHE = {"workstreams": index}
|
||||||
_INDEX_CACHE_AT = time.monotonic()
|
_INDEX_CACHE_AT = time.monotonic()
|
||||||
@@ -176,7 +190,7 @@ async def update_workstream(
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(ws)
|
await session.refresh(ws)
|
||||||
|
|
||||||
if prev_status != "completed" and ws.status == "completed":
|
if normalize_workstream_status(prev_status) != "finished" and ws.status == "finished":
|
||||||
subject = "org.statehub.workstream.completed"
|
subject = "org.statehub.workstream.completed"
|
||||||
envelope = EventEnvelope.new(
|
envelope = EventEnvelope.new(
|
||||||
subject,
|
subject,
|
||||||
|
|||||||
@@ -19,9 +19,12 @@ class TopicTotals(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class WorkstreamTotals(BaseModel):
|
class WorkstreamTotals(BaseModel):
|
||||||
|
proposed: int = 0
|
||||||
|
ready: int = 0
|
||||||
active: int = 0
|
active: int = 0
|
||||||
blocked: int = 0
|
blocked: int = 0
|
||||||
completed: int = 0
|
backlog: int = 0
|
||||||
|
finished: int = 0
|
||||||
archived: int = 0
|
archived: int = 0
|
||||||
total: int = 0
|
total: int = 0
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,30 @@ import uuid
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict, field_validator
|
||||||
|
|
||||||
from api.schemas.workstream_dependency import WorkstreamDepStub
|
from api.schemas.workstream_dependency import WorkstreamDepStub
|
||||||
|
from api.workplan_status import normalize_workstream_status
|
||||||
|
|
||||||
WorkstreamStatus = Literal["todo", "active", "blocked", "completed", "archived"]
|
WorkstreamStatus = Literal[
|
||||||
|
"proposed",
|
||||||
|
"ready",
|
||||||
|
"active",
|
||||||
|
"blocked",
|
||||||
|
"backlog",
|
||||||
|
"finished",
|
||||||
|
"archived",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
class WorkstreamCreate(BaseModel):
|
class WorkstreamStatusMixin(BaseModel):
|
||||||
|
@field_validator("status", mode="before", check_fields=False)
|
||||||
|
@classmethod
|
||||||
|
def _normalise_status(cls, value):
|
||||||
|
return normalize_workstream_status(value)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkstreamCreate(WorkstreamStatusMixin):
|
||||||
topic_id: uuid.UUID
|
topic_id: uuid.UUID
|
||||||
slug: str
|
slug: str
|
||||||
title: str
|
title: str
|
||||||
@@ -23,7 +39,7 @@ class WorkstreamCreate(BaseModel):
|
|||||||
repo_goal_id: uuid.UUID | None = None
|
repo_goal_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
class WorkstreamUpdate(BaseModel):
|
class WorkstreamUpdate(WorkstreamStatusMixin):
|
||||||
title: str | None = None
|
title: str | None = None
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
status: WorkstreamStatus | None = None
|
status: WorkstreamStatus | None = None
|
||||||
@@ -35,7 +51,7 @@ class WorkstreamUpdate(BaseModel):
|
|||||||
repo_goal_id: uuid.UUID | None = None
|
repo_goal_id: uuid.UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
class WorkstreamRead(BaseModel):
|
class WorkstreamRead(WorkstreamStatusMixin):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
topic_id: uuid.UUID
|
topic_id: uuid.UUID
|
||||||
|
|||||||
169
api/workplan_status.py
Normal file
169
api/workplan_status.py
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import fnmatch
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
CANONICAL_WORKSTREAM_STATUSES: tuple[str, ...] = (
|
||||||
|
"proposed",
|
||||||
|
"ready",
|
||||||
|
"active",
|
||||||
|
"blocked",
|
||||||
|
"backlog",
|
||||||
|
"finished",
|
||||||
|
"archived",
|
||||||
|
)
|
||||||
|
|
||||||
|
LEGACY_WORKSTREAM_STATUS_ALIASES: dict[str, str] = {
|
||||||
|
"todo": "ready",
|
||||||
|
"done": "finished",
|
||||||
|
"completed": "finished",
|
||||||
|
"accepted": "finished",
|
||||||
|
}
|
||||||
|
|
||||||
|
SUPPORTED_WORKSTREAM_STATUSES: tuple[str, ...] = (
|
||||||
|
*CANONICAL_WORKSTREAM_STATUSES,
|
||||||
|
*LEGACY_WORKSTREAM_STATUS_ALIASES.keys(),
|
||||||
|
)
|
||||||
|
|
||||||
|
OPEN_WORKSTREAM_STATUSES: tuple[str, ...] = ("ready", "active", "blocked")
|
||||||
|
CURRENT_WORKSTREAM_STATUSES: tuple[str, ...] = ("active", "blocked")
|
||||||
|
CLOSED_WORKSTREAM_STATUSES: tuple[str, ...] = ("finished", "archived")
|
||||||
|
PLANNING_WORKSTREAM_STATUSES: tuple[str, ...] = ("proposed", "ready", "backlog")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ReadyReviewStatus:
|
||||||
|
needs_review: bool
|
||||||
|
reason: str = ""
|
||||||
|
changed_paths: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_workstream_status(status: Any, *, has_started: bool | None = None) -> str:
|
||||||
|
"""Return the canonical lifecycle status for a stored or legacy value."""
|
||||||
|
value = _status_value(status)
|
||||||
|
if value == "todo" and has_started:
|
||||||
|
return "active"
|
||||||
|
return LEGACY_WORKSTREAM_STATUS_ALIASES.get(value, value)
|
||||||
|
|
||||||
|
|
||||||
|
def is_canonical_workstream_status(status: Any) -> bool:
|
||||||
|
return _status_value(status) in CANONICAL_WORKSTREAM_STATUSES
|
||||||
|
|
||||||
|
|
||||||
|
def is_supported_workstream_status(status: Any) -> bool:
|
||||||
|
return _status_value(status) in SUPPORTED_WORKSTREAM_STATUSES
|
||||||
|
|
||||||
|
|
||||||
|
def workstream_has_started(task_statuses: list[Any] | tuple[Any, ...]) -> bool:
|
||||||
|
return any(_status_value(status) not in {"", "todo"} for status in task_statuses)
|
||||||
|
|
||||||
|
|
||||||
|
def ready_review_status(
|
||||||
|
repo_dir: str | Path,
|
||||||
|
reviewed_against_commit: Any,
|
||||||
|
context_paths: Any = None,
|
||||||
|
) -> ReadyReviewStatus:
|
||||||
|
"""Return whether a ready workplan needs review against current repo state.
|
||||||
|
|
||||||
|
When context paths are supplied, only changes under those paths matter.
|
||||||
|
Missing or invalid git metadata is treated conservatively as needs review.
|
||||||
|
"""
|
||||||
|
reviewed = str(reviewed_against_commit or "").strip().strip("\"'")
|
||||||
|
if not reviewed:
|
||||||
|
return ReadyReviewStatus(False)
|
||||||
|
|
||||||
|
repo = Path(repo_dir)
|
||||||
|
head = _git_output(repo, ["rev-parse", "HEAD"])
|
||||||
|
if not head:
|
||||||
|
return ReadyReviewStatus(True, "could not determine repository HEAD")
|
||||||
|
if reviewed == head:
|
||||||
|
return ReadyReviewStatus(False)
|
||||||
|
|
||||||
|
if not _git_commit_exists(repo, reviewed):
|
||||||
|
return ReadyReviewStatus(True, f"review commit {reviewed[:12]} is not available")
|
||||||
|
|
||||||
|
patterns = _as_list(context_paths)
|
||||||
|
if not patterns:
|
||||||
|
return ReadyReviewStatus(
|
||||||
|
True,
|
||||||
|
f"reviewed against {reviewed[:12]}, current HEAD is {head[:12]}",
|
||||||
|
)
|
||||||
|
|
||||||
|
changed = _changed_paths_since(repo, reviewed)
|
||||||
|
if changed is None:
|
||||||
|
return ReadyReviewStatus(True, "could not compare reviewed commit with HEAD")
|
||||||
|
|
||||||
|
matching = tuple(path for path in changed if _matches_any_context(path, patterns))
|
||||||
|
if not matching:
|
||||||
|
return ReadyReviewStatus(False)
|
||||||
|
|
||||||
|
return ReadyReviewStatus(
|
||||||
|
True,
|
||||||
|
f"{len(matching)} context path(s) changed since {reviewed[:12]}",
|
||||||
|
matching,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _status_value(status: Any) -> str:
|
||||||
|
if hasattr(status, "value") and not isinstance(status, (str, bytes, bytearray)):
|
||||||
|
status = status.value
|
||||||
|
return str(status or "").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _as_list(value: Any) -> list[str]:
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, (list, tuple, set)):
|
||||||
|
return [str(item).strip().replace("\\", "/") for item in value if str(item).strip()]
|
||||||
|
if isinstance(value, str):
|
||||||
|
return [item.strip().replace("\\", "/") for item in value.split(",") if item.strip()]
|
||||||
|
return [str(value).strip().replace("\\", "/")]
|
||||||
|
|
||||||
|
|
||||||
|
def _git_output(repo: Path, args: list[str]) -> str | None:
|
||||||
|
try:
|
||||||
|
return subprocess.check_output(
|
||||||
|
["git", "-C", str(repo), *args],
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
text=True,
|
||||||
|
).strip()
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _git_commit_exists(repo: Path, commit: str) -> bool:
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
["git", "-C", str(repo), "cat-file", "-e", f"{commit}^{{commit}}"],
|
||||||
|
check=True,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _changed_paths_since(repo: Path, commit: str) -> tuple[str, ...] | None:
|
||||||
|
output = _git_output(repo, ["diff", "--name-only", f"{commit}..HEAD", "--"])
|
||||||
|
if output is None:
|
||||||
|
return None
|
||||||
|
return tuple(path.strip().replace("\\", "/") for path in output.splitlines() if path.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _matches_any_context(path: str, patterns: list[str]) -> bool:
|
||||||
|
norm_path = path.strip().replace("\\", "/").lstrip("./")
|
||||||
|
for raw_pattern in patterns:
|
||||||
|
pattern = raw_pattern.strip().replace("\\", "/").lstrip("./")
|
||||||
|
if not pattern:
|
||||||
|
continue
|
||||||
|
if any(char in pattern for char in "*?[]"):
|
||||||
|
if fnmatch.fnmatch(norm_path, pattern):
|
||||||
|
return True
|
||||||
|
elif norm_path == pattern or norm_path.startswith(f"{pattern.rstrip('/')}/"):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
@@ -131,8 +131,12 @@ function _ensureStyles() {
|
|||||||
|
|
||||||
/* ── Style maps ──────────────────────────────────────────────────────────── */
|
/* ── Style maps ──────────────────────────────────────────────────────────── */
|
||||||
const _STATUS_STYLE = {
|
const _STATUS_STYLE = {
|
||||||
|
proposed: "background:#fef3c7;color:#92400e",
|
||||||
|
ready: "background:#e0f2fe;color:#075985",
|
||||||
active: "background:#d4edda;color:#155724",
|
active: "background:#d4edda;color:#155724",
|
||||||
blocked: "background:#f8d7da;color:#721c24",
|
blocked: "background:#f8d7da;color:#721c24",
|
||||||
|
backlog: "background:#f1f5f9;color:#64748b",
|
||||||
|
finished: "background:#cce5ff;color:#004085",
|
||||||
completed: "background:#cce5ff;color:#004085",
|
completed: "background:#cce5ff;color:#004085",
|
||||||
archived: "background:#e2e3e5;color:#383d41",
|
archived: "background:#e2e3e5;color:#383d41",
|
||||||
open: "background:#dbeafe;color:#1e40af",
|
open: "background:#dbeafe;color:#1e40af",
|
||||||
|
|||||||
45
dashboard/src/components/workplan-status.js
Normal file
45
dashboard/src/components/workplan-status.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
export const WORKSTREAM_STATUSES = [
|
||||||
|
"proposed",
|
||||||
|
"ready",
|
||||||
|
"active",
|
||||||
|
"blocked",
|
||||||
|
"backlog",
|
||||||
|
"finished",
|
||||||
|
"archived",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const OPEN_WORKSTREAM_STATUSES = ["ready", "active", "blocked"];
|
||||||
|
export const CLOSED_WORKSTREAM_STATUSES = ["finished", "archived"];
|
||||||
|
|
||||||
|
export const LEGACY_STATUS_ALIASES = {
|
||||||
|
todo: "ready",
|
||||||
|
done: "finished",
|
||||||
|
completed: "finished",
|
||||||
|
accepted: "finished",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeWorkstreamStatus(status) {
|
||||||
|
const value = String(status ?? "").trim().toLowerCase();
|
||||||
|
return LEGACY_STATUS_ALIASES[value] ?? value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isClosedWorkstream(status) {
|
||||||
|
return CLOSED_WORKSTREAM_STATUSES.includes(normalizeWorkstreamStatus(status));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOpenWorkstream(status) {
|
||||||
|
return OPEN_WORKSTREAM_STATUSES.includes(normalizeWorkstreamStatus(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);
|
||||||
|
return ["active", "blocked"].includes(normalizeWorkstreamStatus(w.status))
|
||||||
|
&& new Date(w.updated_at) < staleAt
|
||||||
|
&& (w.done ?? 0) > 0
|
||||||
|
&& openTasks > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function needsReviewWorkstream(w) {
|
||||||
|
return Array.isArray(w.health_labels) && w.health_labels.includes("needs_review");
|
||||||
|
}
|
||||||
@@ -19,7 +19,16 @@ except urllib.error.URLError as e:
|
|||||||
"generated_at": None,
|
"generated_at": None,
|
||||||
"totals": {
|
"totals": {
|
||||||
"topics": {"active": 0, "paused": 0, "archived": 0, "total": 0},
|
"topics": {"active": 0, "paused": 0, "archived": 0, "total": 0},
|
||||||
"workstreams": {"active": 0, "blocked": 0, "completed": 0, "archived": 0, "total": 0},
|
"workstreams": {
|
||||||
|
"proposed": 0,
|
||||||
|
"ready": 0,
|
||||||
|
"active": 0,
|
||||||
|
"blocked": 0,
|
||||||
|
"backlog": 0,
|
||||||
|
"finished": 0,
|
||||||
|
"archived": 0,
|
||||||
|
"total": 0,
|
||||||
|
},
|
||||||
"tasks": {"todo": 0, "in_progress": 0, "blocked": 0, "done": 0, "cancelled": 0, "total": 0},
|
"tasks": {"todo": 0, "in_progress": 0, "blocked": 0, "done": 0, "cancelled": 0, "total": 0},
|
||||||
"decisions": {"open": 0, "resolved": 0, "escalated": 0, "superseded": 0, "total": 0},
|
"decisions": {"open": 0, "resolved": 0, "escalated": 0, "superseded": 0, "total": 0},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ title: Dependencies
|
|||||||
|
|
||||||
```js
|
```js
|
||||||
import {API, POLL_HEAVY, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
|
import {API, POLL_HEAVY, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
|
||||||
|
import {normalizeWorkstreamStatus} from "./components/workplan-status.js";
|
||||||
```
|
```
|
||||||
|
|
||||||
```js
|
```js
|
||||||
@@ -29,6 +30,7 @@ const depState = (async function*() {
|
|||||||
const repoMap = Object.fromEntries(repoList.map(r => [r.id, r]));
|
const repoMap = Object.fromEntries(repoList.map(r => [r.id, r]));
|
||||||
wsMap = Object.fromEntries(wsList.map(w => [w.id, {
|
wsMap = Object.fromEntries(wsList.map(w => [w.id, {
|
||||||
...w,
|
...w,
|
||||||
|
status: normalizeWorkstreamStatus(w.status),
|
||||||
// Prefer repo→domain (GEMS primary); fall back to topic→domain
|
// Prefer repo→domain (GEMS primary); fall back to topic→domain
|
||||||
domain: repoMap[w.repo_id]?.domain_slug ?? topicMap[w.topic_id]?.domain_slug ?? "unknown",
|
domain: repoMap[w.repo_id]?.domain_slug ?? topicMap[w.topic_id]?.domain_slug ?? "unknown",
|
||||||
}]));
|
}]));
|
||||||
@@ -87,7 +89,7 @@ injectTocTop("dep-kpi-box", _kpiBox);
|
|||||||
injectTocTop("live-indicator", _liveEl);
|
injectTocTop("live-indicator", _liveEl);
|
||||||
```
|
```
|
||||||
|
|
||||||
Directed edges between active workstreams. An edge **A → B** means A cannot
|
Directed edges between open workstreams. An edge **A → B** means A cannot
|
||||||
fully proceed until B reaches a satisfactory state.
|
fully proceed until B reaches a satisfactory state.
|
||||||
|
|
||||||
```js
|
```js
|
||||||
@@ -152,9 +154,12 @@ if (edges.length === 0) {
|
|||||||
.dep-title { font-weight: 500; max-width: 22rem; }
|
.dep-title { font-weight: 500; max-width: 22rem; }
|
||||||
.dep-arrow { text-align: center; color: var(--theme-foreground-faint, #bbb); font-size: 1rem; }
|
.dep-arrow { text-align: center; color: var(--theme-foreground-faint, #bbb); font-size: 1rem; }
|
||||||
.dep-status { display: inline-block; padding: 0.1rem 0.45rem; border-radius: 10px; font-size: 0.7rem; font-weight: 500; }
|
.dep-status { display: inline-block; padding: 0.1rem 0.45rem; border-radius: 10px; font-size: 0.7rem; font-weight: 500; }
|
||||||
|
.dep-status-proposed { background: #fef3c7; color: #92400e; }
|
||||||
|
.dep-status-ready { background: #e0f2fe; color: #075985; }
|
||||||
.dep-status-active { background: #dcfce7; color: #166534; }
|
.dep-status-active { background: #dcfce7; color: #166534; }
|
||||||
.dep-status-completed { background: #f1f5f9; color: #475569; }
|
|
||||||
.dep-status-blocked { background: #fee2e2; color: #991b1b; }
|
.dep-status-blocked { background: #fee2e2; color: #991b1b; }
|
||||||
|
.dep-status-backlog { background: #f1f5f9; color: #64748b; }
|
||||||
|
.dep-status-finished { background: #f1f5f9; color: #475569; }
|
||||||
.dep-status-archived { background: #f1f5f9; color: #9ca3af; }
|
.dep-status-archived { background: #f1f5f9; color: #9ca3af; }
|
||||||
.dim { color: gray; font-style: italic; }
|
.dim { color: gray; font-style: italic; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -244,7 +244,8 @@ The Overview page renders a horizontal stacked bar chart using `@observablehq/pl
|
|||||||
showing task counts (done / in progress / blocked / todo) per workstream.
|
showing task counts (done / in progress / blocked / todo) per workstream.
|
||||||
A `<select>` dropdown switches between:
|
A `<select>` dropdown switches between:
|
||||||
|
|
||||||
- **Status modes**: active, accepted, finished, blocked, stalled, oldies
|
- **Lifecycle modes**: proposed, ready, active, blocked, backlog, finished, archived
|
||||||
|
- **Health modes**: needs review, stalled
|
||||||
- **Time modes**: last 1h, 24h, 7d, 30d, today, this week, this month
|
- **Time modes**: last 1h, 24h, 7d, 30d, today, this week, this month
|
||||||
|
|
||||||
Domains are sorted by most recent workstream activity (most active domain at
|
Domains are sorted by most recent workstream activity (most active domain at
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ title: Dependencies — Reference
|
|||||||
|
|
||||||
# Dependencies — Reference
|
# Dependencies — Reference
|
||||||
|
|
||||||
The Dependencies page shows the directed dependency graph between active
|
The Dependencies page shows the directed dependency graph between open
|
||||||
workstreams — which workstreams are waiting on others to reach a satisfactory
|
workstreams — which workstreams are waiting on others to reach a satisfactory
|
||||||
state before they can fully proceed.
|
state before they can fully proceed.
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ state before they can fully proceed.
|
|||||||
## What is a dependency edge?
|
## What is a dependency edge?
|
||||||
|
|
||||||
A dependency edge **A → B** means workstream A cannot fully proceed until
|
A dependency edge **A → B** means workstream A cannot fully proceed until
|
||||||
workstream B is in a satisfactory state (typically `completed` or `archived`).
|
workstream B is in a satisfactory state (typically `finished` or `archived`).
|
||||||
|
|
||||||
Edges are used to model real sequencing constraints: for example, a shared
|
Edges are used to model real sequencing constraints: for example, a shared
|
||||||
library must reach a stable release before downstream domains can build on it.
|
library must reach a stable release before downstream domains can build on it.
|
||||||
@@ -36,7 +36,7 @@ Each row shows:
|
|||||||
| **→** | Direction arrow |
|
| **→** | Direction arrow |
|
||||||
| **Blocked-by domain** | Domain of the prerequisite workstream |
|
| **Blocked-by domain** | Domain of the prerequisite workstream |
|
||||||
| **Blocked-by workstream** | Title of the workstream that must complete first |
|
| **Blocked-by workstream** | Title of the workstream that must complete first |
|
||||||
| **Status** | Current status of the prerequisite (green = active, grey = completed) |
|
| **Status** | Current status of the prerequisite (green = active, grey = finished/archived) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ Four metric cards:
|
|||||||
|
|
||||||
| Card | Meaning |
|
| Card | Meaning |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| **Active Workstreams** | Count of non-completed, non-archived workstreams |
|
| **Active Workstreams** | Count of active/blocked execution workstreams |
|
||||||
| **Blocking Decisions** | Pending decisions with status `open` or `escalated` — orange border if > 0 |
|
| **Blocking Decisions** | Pending decisions with status `open` or `escalated` — orange border if > 0 |
|
||||||
| **Blocked Tasks** | Click to expand the list with blocking reasons |
|
| **Blocked Tasks** | Click to expand the list with blocking reasons |
|
||||||
| **Events Today** | Progress events created on today's date |
|
| **Events Today** | Progress events created on today's date |
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ These types are used by the State Hub's built-in write operations:
|
|||||||
| Type | When emitted |
|
| Type | When emitted |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `workstream_created` | A new workstream was registered |
|
| `workstream_created` | A new workstream was registered |
|
||||||
| `workstream_status_changed` | Workstream moved to active / blocked / completed / archived |
|
| `workstream_status_changed` | Workstream moved between canonical lifecycle states |
|
||||||
| `workstation_advanced` | Flow-aware movement via `advance_workstation()` succeeded |
|
| `workstation_advanced` | Flow-aware movement via `advance_workstation()` succeeded |
|
||||||
| `task_created` | A new task was added to a workstream |
|
| `task_created` | A new task was added to a workstream |
|
||||||
| `task_status_changed` | Task moved to todo / in_progress / blocked / done / cancelled |
|
| `task_status_changed` | Task moved to todo / in_progress / blocked / done / cancelled |
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ cd ~/ralph-workplan && ./install.sh --uninstall
|
|||||||
---
|
---
|
||||||
id: WP-0001
|
id: WP-0001
|
||||||
title: "Build a thing"
|
title: "Build a thing"
|
||||||
status: active
|
status: ready
|
||||||
---
|
---
|
||||||
|
|
||||||
Optional description.
|
Optional description.
|
||||||
@@ -120,7 +120,7 @@ priority: medium
|
|||||||
|-------|--------|-------------|
|
|-------|--------|-------------|
|
||||||
| `id` | string | Unique workplan identifier |
|
| `id` | string | Unique workplan identifier |
|
||||||
| `title` | string | Human-readable name |
|
| `title` | string | Human-readable name |
|
||||||
| `status` | `active` \| `done` \| `paused` | Workplan lifecycle state |
|
| `status` | `proposed` \| `ready` \| `active` \| `blocked` \| `backlog` \| `finished` \| `archived` | Workplan lifecycle state |
|
||||||
|
|
||||||
**Task block fields:**
|
**Task block fields:**
|
||||||
|
|
||||||
@@ -144,7 +144,7 @@ status: in_progress → status: done (when verified complete)
|
|||||||
When every task is `done`, Claude also updates the frontmatter:
|
When every task is `done`, Claude also updates the frontmatter:
|
||||||
|
|
||||||
```
|
```
|
||||||
status: active → status: done
|
status: active -> status: finished
|
||||||
```
|
```
|
||||||
|
|
||||||
The loop detects this on the next iteration and stops.
|
The loop detects this on the next iteration and stops.
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ autonomously. No human interaction is needed unless the agent has a question.
|
|||||||
|
|
||||||
The [Repos](/repos) page shows each repo's integration status. An **integrating**
|
The [Repos](/repos) page shows each repo's integration status. An **integrating**
|
||||||
badge appears on repos with an active Repo Integration workstream. The badge
|
badge appears on repos with an active Repo Integration workstream. The badge
|
||||||
clears when the workstream is marked completed.
|
clears when the workstream is marked finished.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -120,8 +120,8 @@ repo agent should:
|
|||||||
primary near-term work; register the workstream in the hub via MCP
|
primary near-term work; register the workstream in the hub via MCP
|
||||||
4. Execute T3 — ingest the SBOM so the repo appears green on the Repos page
|
4. Execute T3 — ingest the SBOM so the repo appears green on the Repos page
|
||||||
5. Execute T4 — a quick scan for obvious EPs/TDs; defer if nothing obvious
|
5. Execute T4 — a quick scan for obvious EPs/TDs; defer if nothing obvious
|
||||||
6. Mark each task `done` in the hub as completed
|
6. Mark each task `done` in the hub
|
||||||
7. Mark the Repo Integration workstream `completed`
|
7. Mark the Repo Integration workstream `finished`
|
||||||
8. Log a progress event summarising the integration
|
8. Log a progress event summarising the integration
|
||||||
|
|
||||||
The agent should resolve each task independently and in order. It does not
|
The agent should resolve each task independently and in order. It does not
|
||||||
|
|||||||
@@ -73,13 +73,13 @@ Detects concentration of blocking power. High SPR means one delay propagates wid
|
|||||||
### PEP — Parallel Execution Potential
|
### PEP — Parallel Execution Potential
|
||||||
|
|
||||||
```
|
```
|
||||||
PEP = active workstreams with all deps completed / (active + blocked)
|
PEP = ready or active workstreams with all deps finished / (ready + active + blocked)
|
||||||
```
|
```
|
||||||
|
|
||||||
Estimates how much work can proceed right now. A workstream is eligible if its
|
Estimates how much work can proceed right now. A workstream is eligible if its
|
||||||
stored workstation label is `active` and the flow/dependency checks report no
|
stored workstation label is `ready` or `active` and the flow/dependency checks report no
|
||||||
unmet dependency assertion; practically, every workstream it depends on has
|
unmet dependency assertion; practically, every workstream it depends on has
|
||||||
reached `completed` or `archived`.
|
reached `finished` or `archived`.
|
||||||
|
|
||||||
| PEP | Warning |
|
| PEP | Warning |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -147,7 +147,7 @@ The domain breakdown is shown when at least two domains have active workstreams.
|
|||||||
| Symptom | Action |
|
| Symptom | Action |
|
||||||
|---|---|
|
|---|---|
|
||||||
| High DD | Decompose tightly coupled workstreams; remove unnecessary dependencies |
|
| High DD | Decompose tightly coupled workstreams; remove unnecessary dependencies |
|
||||||
| High BR | Unblock workstreams — resolve the blocking condition, or mark dependency as completed if done |
|
| High BR | Unblock workstreams — resolve the blocking condition, or mark dependency as finished if done |
|
||||||
| High SPR | Split the bottleneck workstream into independent deliverables |
|
| High SPR | Split the bottleneck workstream into independent deliverables |
|
||||||
| Low PEP | Complete prerequisite workstreams or re-sequence work |
|
| Low PEP | Complete prerequisite workstreams or re-sequence work |
|
||||||
| High CDDR | Refactor cross-domain dependencies into shared contracts or invert the dependency |
|
| High CDDR | Refactor cross-domain dependencies into shared contracts or invert the dependency |
|
||||||
|
|||||||
@@ -143,11 +143,11 @@ High SPR indicates fragile structure where one delay propagates widely.
|
|||||||
|
|
||||||
A workstream is eligible if:
|
A workstream is eligible if:
|
||||||
|
|
||||||
* Status = active
|
* Status = ready or active
|
||||||
* All dependencies are completed
|
* All dependencies are finished or archived
|
||||||
|
|
||||||
[
|
[
|
||||||
PEP = \frac{\text{Eligible active workstreams}}{\text{Active + Blocked}}
|
PEP = \frac{\text{Eligible ready or active workstreams}}{\text{Ready + Active + Blocked}}
|
||||||
]
|
]
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -378,4 +378,3 @@ It captures both:
|
|||||||
* Operational flow conditions
|
* Operational flow conditions
|
||||||
|
|
||||||
By combining graph properties with status information, WHI enables proactive management of coordination complexity.
|
By combining graph properties with status information, WHI enables proactive management of coordination complexity.
|
||||||
|
|
||||||
|
|||||||
@@ -1,111 +1,86 @@
|
|||||||
---
|
---
|
||||||
title: Workstream Lifecycle — Reference
|
title: Workstream Lifecycle - Reference
|
||||||
---
|
---
|
||||||
|
|
||||||
# Workstream Lifecycle — Reference
|
# Workstream Lifecycle - Reference
|
||||||
|
|
||||||
A workstream is an information object that occupies a named workstation. The
|
A workstream is an information object that occupies a named lifecycle state.
|
||||||
stored `status` field keeps the current workstation label, while the
|
The stored `status` field keeps that state, while the task-flow engine derives
|
||||||
task-flow engine derives which other workstations are reachable and which exit
|
which other states are reachable and which exit assertions are blocking
|
||||||
assertions are blocking movement. The dashboard "Workstreams by Domain" chart
|
movement. Dashboard health filters such as `needs_review` and `stalled` are
|
||||||
exposes stored and derived states as selectable filters so attention can be
|
derived labels, not stored lifecycle values.
|
||||||
directed to the right workstreams at the right time.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Core workstations
|
## Stored Lifecycle States
|
||||||
|
|
||||||
These are the primary workstations used by State Hub workstreams:
|
| State | Source | Meaning |
|
||||||
|
|
||||||
| Workstation | Source | Meaning |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **active** | DB `status = active` | Work is in progress or ready to start |
|
| **proposed** | DB `status = proposed` | Plan exists, but must be reviewed against current repo state |
|
||||||
| **finished** | Derived — no open tasks | All tasks are done, but no explicit review has taken place yet |
|
| **ready** | DB `status = ready` | Plan has been reviewed and is ready to execute |
|
||||||
| **accepted** | DB `status = completed` | Custodian and human have reviewed the workstream, quality checks passed, and it is formally signed off |
|
| **active** | DB `status = active` | Work is in progress |
|
||||||
|
| **blocked** | DB `status = blocked` | Work cannot proceed until a dependency, decision, or input clears |
|
||||||
|
| **backlog** | DB `status = backlog` | Intentionally parked so it stays out of current work views |
|
||||||
|
| **finished** | DB `status = finished` | Implementation is complete |
|
||||||
|
| **archived** | DB `status = archived` | Historical record outside normal planning and execution |
|
||||||
|
|
||||||
The normal human-facing path is: **active → finished → accepted**.
|
Normal progression:
|
||||||
|
|
||||||
`accepted` is the only state that requires an explicit action. It is reached by
|
|
||||||
advancing the workstream to the `completed` workstation after deliberate
|
|
||||||
review, not by task counts alone. This makes it a reliable anchor: anything in
|
|
||||||
`finished` but not yet in `accepted` is work that still needs a quality pass.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Attention signals
|
|
||||||
|
|
||||||
These signals are orthogonal to the core workstation — a workstream can be
|
|
||||||
`active` and `stalled` at the same time. They serve as health indicators
|
|
||||||
rather than stored lifecycle stages.
|
|
||||||
|
|
||||||
| Signal | Source | Meaning |
|
|
||||||
|---|---|---|
|
|
||||||
| **blocked** | Derived — unmet exit assertion or blocked task | Work cannot currently leave its workstation; inspect `blocked_reasons` |
|
|
||||||
| **stalled** | Derived — `updated_at` > 7 days ago, has both done and open tasks | Work started but activity has stopped; needs a nudge |
|
|
||||||
| **oldies** | Derived — `created_at` > 7 days ago, zero done tasks | Workstream is old and nothing has been completed yet; may need re-evaluation |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The acceptance quality gate
|
|
||||||
|
|
||||||
When a workstream reaches **finished** (all tasks done), the custodian's role is to:
|
|
||||||
|
|
||||||
1. Review the deliverables against the workstream's stated purpose and scope
|
|
||||||
2. Check for missing tests, documentation, or follow-up issues
|
|
||||||
3. Create tasks for any gaps found — this moves the workstream back to **active**
|
|
||||||
4. Once satisfied, advance to the `completed` workstation — this marks it as **accepted**
|
|
||||||
|
|
||||||
This pattern ensures that "done" and "accepted" are distinct signals.
|
|
||||||
`finished` is a fact about task counts; `accepted` is a statement of quality.
|
|
||||||
|
|
||||||
|
```text
|
||||||
|
backlog -> proposed -> ready -> active -> finished -> archived
|
||||||
|
\ \
|
||||||
|
\ -> blocked -> active
|
||||||
|
-> backlog
|
||||||
```
|
```
|
||||||
# Inspect and accept a workstream via MCP
|
|
||||||
get_flow_state(entity_type="workstream", entity_id="<uuid>")
|
|
||||||
advance_workstation(entity_type="workstream", entity_id="<uuid>", target_workstation="completed")
|
|
||||||
|
|
||||||
# Or via REST
|
---
|
||||||
|
|
||||||
|
## Health Labels
|
||||||
|
|
||||||
|
| Label | Source | Meaning |
|
||||||
|
|---|---|---|
|
||||||
|
| **needs_review** | Ready-review metadata + git diff | A `ready` workplan may be stale because relevant files changed since review |
|
||||||
|
| **stalled** | Task counts + timestamp | Work started, but there has been no meaningful progress after the threshold |
|
||||||
|
|
||||||
|
`needs_review` and `stalled` can appear beside lifecycle states. They should
|
||||||
|
not be written into workplan frontmatter or directly into the workstream
|
||||||
|
`status` field.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ready Review Metadata
|
||||||
|
|
||||||
|
Ready workplans may include optional frontmatter:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
reviewed_at: "YYYY-MM-DD"
|
||||||
|
reviewed_by: "human-or-agent"
|
||||||
|
reviewed_against_commit: "<git-sha>"
|
||||||
|
context_paths:
|
||||||
|
- "path/or/glob"
|
||||||
|
```
|
||||||
|
|
||||||
|
If `reviewed_against_commit` differs from `HEAD`, State Hub checks
|
||||||
|
`context_paths` when present. Relevant changes produce the derived
|
||||||
|
`needs_review` label. Automatic demotion from `ready` to `proposed` is guarded
|
||||||
|
behind explicit tooling, not done silently.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Flow Operations
|
||||||
|
|
||||||
|
```text
|
||||||
|
get_flow_state(entity_type="workstream", entity_id="<uuid>")
|
||||||
|
advance_workstation(entity_type="workstream", entity_id="<uuid>", target_workstation="finished")
|
||||||
|
```
|
||||||
|
|
||||||
|
Direct status patching still exists for bootstrap and compatibility work:
|
||||||
|
|
||||||
|
```bash
|
||||||
curl -X PATCH http://127.0.0.1:8000/workstreams/<uuid>/ \
|
curl -X PATCH http://127.0.0.1:8000/workstreams/<uuid>/ \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"status": "completed"}'
|
-d '{"status": "finished"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
Workstreams are never hard-deleted. Use `finished` for completed
|
||||||
|
implementation and `archived` for historical records outside normal planning.
|
||||||
## Time-based filters
|
|
||||||
|
|
||||||
The chart also supports time-window filters that cut across all lifecycle states:
|
|
||||||
|
|
||||||
| Filter | Shows workstreams where… |
|
|
||||||
|---|---|
|
|
||||||
| **last 1 hour** | `updated_at` or `created_at` within the last 60 minutes |
|
|
||||||
| **last 24 hours** | … within the last 24 hours |
|
|
||||||
| **last 7 days** | … within the last 7 days |
|
|
||||||
| **last 30 days** | … within the last 30 days |
|
|
||||||
| **today** | … since midnight today |
|
|
||||||
| **this week** | … since Monday of the current week |
|
|
||||||
| **this month** | … since the 1st of the current month |
|
|
||||||
|
|
||||||
In time-based views, workstream labels are **bold** for accepted and blocked
|
|
||||||
workstreams to distinguish notable states at a glance.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Stored Label Vs. Dashboard State
|
|
||||||
|
|
||||||
The DB stores a single `status` field on each workstream. Treat it as the
|
|
||||||
current workstation label. The dashboard maps this alongside flow-engine
|
|
||||||
results, dependency assertions, and task-count data to produce the richer set
|
|
||||||
of filter states:
|
|
||||||
|
|
||||||
| Dashboard state | Stored label / derived source | Condition |
|
|
||||||
|---|---|---|
|
|
||||||
| active | `status = active` | — |
|
|
||||||
| accepted | `status = completed` | — |
|
|
||||||
| finished | task counts | `todo + in_progress + blocked = 0` |
|
|
||||||
| blocked | flow result / task counts | `exit_blocked = true` or `blocked ≥ 1` |
|
|
||||||
| stalled | task counts + timestamp | `done ≥ 1` and `open ≥ 1` and `updated_at > 7d ago` |
|
|
||||||
| oldies | task counts + timestamp | `done = 0` and `open ≥ 1` and `created_at > 7d ago` |
|
|
||||||
|
|
||||||
*Workstreams are never hard-deleted — use `advance_workstation(...,
|
|
||||||
"completed")` or advance/patch to `"archived"` to close them without losing
|
|
||||||
history.*
|
|
||||||
|
|||||||
@@ -20,14 +20,17 @@ as filters change.
|
|||||||
|
|
||||||
| Workstation | Meaning |
|
| Workstation | Meaning |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **active** | Work in progress or ready to start |
|
| **proposed** | Plan exists, but needs review against current repo state |
|
||||||
|
| **ready** | Reviewed and ready to execute |
|
||||||
|
| **active** | Work is in progress |
|
||||||
| **blocked** | Stored blocker label; the State Hub can also derive blocked state from unmet exit assertions |
|
| **blocked** | Stored blocker label; the State Hub can also derive blocked state from unmet exit assertions |
|
||||||
| **completed** | Formally accepted after custodian review (shown as **accepted** in the overview chart) |
|
| **backlog** | Intentionally parked for later |
|
||||||
| **archived** | Closed without completion; no longer relevant |
|
| **finished** | Implementation is complete |
|
||||||
|
| **archived** | Closed historical record |
|
||||||
|
|
||||||
See [Workstream Lifecycle](/docs/workstream-lifecycle) for the full task-flow
|
See [Workstream Lifecycle](/docs/workstream-lifecycle) for the full task-flow
|
||||||
model including derived states (finished, stalled, oldies) and assertion-based
|
model including derived health labels (`needs_review`, `stalled`) and
|
||||||
blocking.
|
assertion-based blocking.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -91,7 +94,7 @@ create_workstream(
|
|||||||
topic_id = "<uuid>",
|
topic_id = "<uuid>",
|
||||||
title = "Build user authentication",
|
title = "Build user authentication",
|
||||||
description = "JWT-based auth, refresh tokens, middleware",
|
description = "JWT-based auth, refresh tokens, middleware",
|
||||||
status = "active",
|
status = "ready",
|
||||||
owner = "human",
|
owner = "human",
|
||||||
due_date = "2026-04-01"
|
due_date = "2026-04-01"
|
||||||
)
|
)
|
||||||
@@ -102,7 +105,7 @@ Via REST:
|
|||||||
```bash
|
```bash
|
||||||
curl -X POST http://127.0.0.1:8000/workstreams/ \
|
curl -X POST http://127.0.0.1:8000/workstreams/ \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-d '{"topic_id": "<uuid>", "title": "…", "status": "active"}'
|
-d '{"topic_id": "<uuid>", "title": "…", "status": "ready"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -111,7 +114,7 @@ curl -X POST http://127.0.0.1:8000/workstreams/ \
|
|||||||
|
|
||||||
```
|
```
|
||||||
get_flow_state(entity_type="workstream", entity_id="<uuid>")
|
get_flow_state(entity_type="workstream", entity_id="<uuid>")
|
||||||
advance_workstation(entity_type="workstream", entity_id="<uuid>", target_workstation="completed")
|
advance_workstation(entity_type="workstream", entity_id="<uuid>", target_workstation="finished")
|
||||||
```
|
```
|
||||||
|
|
||||||
Movement is flow-aware: the task-flow engine evaluates the target
|
Movement is flow-aware: the task-flow engine evaluates the target
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ title: Overview
|
|||||||
|
|
||||||
```js
|
```js
|
||||||
import {API, POLL_HEAVY, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
|
import {API, POLL_HEAVY, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
|
||||||
|
import {
|
||||||
|
WORKSTREAM_STATUSES,
|
||||||
|
isClosedWorkstream,
|
||||||
|
isStalledWorkstream,
|
||||||
|
needsReviewWorkstream,
|
||||||
|
normalizeWorkstreamStatus,
|
||||||
|
} from "./components/workplan-status.js";
|
||||||
```
|
```
|
||||||
|
|
||||||
```js
|
```js
|
||||||
@@ -52,11 +59,13 @@ const pageState = (async function*() {
|
|||||||
const workplan = workplanMap[w.id] ?? {};
|
const workplan = workplanMap[w.id] ?? {};
|
||||||
return {
|
return {
|
||||||
...w,
|
...w,
|
||||||
|
status: normalizeWorkstreamStatus(w.status),
|
||||||
domain: repo?.domain_slug ?? topic?.domain_slug ?? "unknown",
|
domain: repo?.domain_slug ?? topic?.domain_slug ?? "unknown",
|
||||||
repo_label: repo?.slug ?? workplan.repo_slug ?? "unassigned",
|
repo_label: repo?.slug ?? workplan.repo_slug ?? "unassigned",
|
||||||
workplan_filename: workplan.filename ?? null,
|
workplan_filename: workplan.filename ?? null,
|
||||||
workplan_relative_path: workplan.relative_path ?? null,
|
workplan_relative_path: workplan.relative_path ?? null,
|
||||||
workplan_archived: workplan.archived ?? false,
|
workplan_archived: workplan.archived ?? false,
|
||||||
|
health_labels: workplan.health_labels ?? [],
|
||||||
href: `./workstreams/${w.id}`,
|
href: `./workstreams/${w.id}`,
|
||||||
...(counts[w.id] ?? {done: 0, in_progress: 0, blocked: 0, todo: 0, total: 0}),
|
...(counts[w.id] ?? {done: 0, in_progress: 0, blocked: 0, todo: 0, total: 0}),
|
||||||
};
|
};
|
||||||
@@ -126,13 +135,18 @@ display(html`<div class="warning" style="display:${summary.error ? '' : 'none'}"
|
|||||||
// view() is the idiomatic Observable Framework reactive input:
|
// view() is the idiomatic Observable Framework reactive input:
|
||||||
// it displays the element AND returns a reactive value that re-runs dependent blocks.
|
// it displays the element AND returns a reactive value that re-runs dependent blocks.
|
||||||
const _chartMode = view(html`<select class="ws-mode-select">
|
const _chartMode = view(html`<select class="ws-mode-select">
|
||||||
<optgroup label="By Status">
|
<optgroup label="Lifecycle">
|
||||||
<option value="active" selected>active</option>
|
<option value="ready" selected>ready</option>
|
||||||
<option value="accepted">accepted</option>
|
<option value="active">active</option>
|
||||||
<option value="finished">finished</option>
|
|
||||||
<option value="blocked">blocked</option>
|
<option value="blocked">blocked</option>
|
||||||
|
<option value="proposed">proposed</option>
|
||||||
|
<option value="backlog">backlog</option>
|
||||||
|
<option value="finished">finished</option>
|
||||||
|
<option value="archived">archived</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Health">
|
||||||
|
<option value="needs_review">needs review</option>
|
||||||
<option value="stalled">stalled</option>
|
<option value="stalled">stalled</option>
|
||||||
<option value="oldies">oldies</option>
|
|
||||||
</optgroup>
|
</optgroup>
|
||||||
<optgroup label="Recently Changed">
|
<optgroup label="Recently Changed">
|
||||||
<option value="1h">last 1 hour</option>
|
<option value="1h">last 1 hour</option>
|
||||||
@@ -150,12 +164,11 @@ const _chartMode = view(html`<select class="ws-mode-select">
|
|||||||
import * as Plot from "npm:@observablehq/plot";
|
import * as Plot from "npm:@observablehq/plot";
|
||||||
|
|
||||||
// ── Filter workstreams by selected mode ───────────────────────────────────────
|
// ── Filter workstreams by selected mode ───────────────────────────────────────
|
||||||
// "active" matches the DB status field directly.
|
// Lifecycle modes match stored canonical status values.
|
||||||
// "accepted" = DB status "completed" (explicitly reviewed and signed off).
|
// Health modes are derived labels; they are not stored lifecycle states.
|
||||||
// "finished" = no open tasks remaining (derived from task counts).
|
|
||||||
// "blocked" = has ≥1 blocked task; "stalled" / "oldies" = activity-based.
|
|
||||||
// Time modes filter by updated_at / created_at.
|
// Time modes filter by updated_at / created_at.
|
||||||
const _STATUS_MODES = new Set(["active"]);
|
const _STATUS_MODES = new Set(WORKSTREAM_STATUSES);
|
||||||
|
const _HEALTH_MODES = new Set(["needs_review", "stalled"]);
|
||||||
|
|
||||||
function _timeCutoff(mode) {
|
function _timeCutoff(mode) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -175,27 +188,11 @@ function _timeCutoff(mode) {
|
|||||||
|
|
||||||
const _chartWsFiltered = (
|
const _chartWsFiltered = (
|
||||||
_STATUS_MODES.has(_chartMode)
|
_STATUS_MODES.has(_chartMode)
|
||||||
? wsAll.filter(w => w.status === _chartMode)
|
? wsAll.filter(w => normalizeWorkstreamStatus(w.status) === _chartMode)
|
||||||
: _chartMode === "accepted"
|
: _chartMode === "needs_review"
|
||||||
? wsAll.filter(w => w.status === "completed")
|
? wsAll.filter(needsReviewWorkstream)
|
||||||
: _chartMode === "finished"
|
|
||||||
? wsAll.filter(w => (w.todo ?? 0) + (w.in_progress ?? 0) + (w.blocked ?? 0) === 0)
|
|
||||||
: _chartMode === "blocked"
|
|
||||||
? wsAll.filter(w => (w.blocked ?? 0) > 0)
|
|
||||||
: _chartMode === "stalled"
|
: _chartMode === "stalled"
|
||||||
? wsAll.filter(w => {
|
? wsAll.filter(isStalledWorkstream)
|
||||||
const staleAt = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
|
||||||
return new Date(w.updated_at) < staleAt
|
|
||||||
&& (w.done ?? 0) > 0
|
|
||||||
&& (w.todo ?? 0) + (w.in_progress ?? 0) + (w.blocked ?? 0) > 0;
|
|
||||||
})
|
|
||||||
: _chartMode === "oldies"
|
|
||||||
? wsAll.filter(w => {
|
|
||||||
const oldAt = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
|
|
||||||
return new Date(w.created_at) < oldAt
|
|
||||||
&& (w.done ?? 0) === 0
|
|
||||||
&& (w.todo ?? 0) + (w.in_progress ?? 0) + (w.blocked ?? 0) > 0;
|
|
||||||
})
|
|
||||||
: (() => {
|
: (() => {
|
||||||
const since = _timeCutoff(_chartMode);
|
const since = _timeCutoff(_chartMode);
|
||||||
return wsAll.filter(w =>
|
return wsAll.filter(w =>
|
||||||
@@ -215,9 +212,9 @@ const chartWs = [..._chartWsFiltered].sort((a, b) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Status weight: bold for notable statuses in mixed-status modes ─────────────
|
// ── Status weight: bold for notable statuses in mixed-status modes ─────────────
|
||||||
// Color is NOT used for status — avoids green-on-green when completed bars fill the row.
|
// Color is NOT used for status — avoids green-on-green when finished bars fill the row.
|
||||||
const _isTimeBased = !_STATUS_MODES.has(_chartMode);
|
const _isTimeBased = !_STATUS_MODES.has(_chartMode) && !_HEALTH_MODES.has(_chartMode);
|
||||||
function _wsWeight(s) { return (s === "accepted" || s === "blocked" || s === "stalled") ? "bold" : "normal"; }
|
function _wsWeight(s) { return (isClosedWorkstream(s) || normalizeWorkstreamStatus(s) === "blocked") ? "bold" : "normal"; }
|
||||||
|
|
||||||
// ── y-axis: domain/repo label for first workstream per repository only ────────
|
// ── y-axis: domain/repo label for first workstream per repository only ────────
|
||||||
const _yLabels = {};
|
const _yLabels = {};
|
||||||
@@ -251,10 +248,15 @@ function _wsTitle(d) {
|
|||||||
// ── Render ────────────────────────────────────────────────────────────────────
|
// ── Render ────────────────────────────────────────────────────────────────────
|
||||||
if (chartWs.length === 0) {
|
if (chartWs.length === 0) {
|
||||||
const _emptyMsg = {
|
const _emptyMsg = {
|
||||||
active: "No active workstreams.", accepted: "No accepted workstreams.",
|
proposed: "No proposed workstreams.",
|
||||||
finished: "No finished workstreams.", blocked: "No blocked workstreams.",
|
ready: "No ready workstreams.",
|
||||||
|
active: "No active workstreams.",
|
||||||
|
blocked: "No blocked workstreams.",
|
||||||
|
backlog: "No backlog workstreams.",
|
||||||
|
finished: "No finished workstreams.",
|
||||||
|
archived: "No archived workstreams.",
|
||||||
|
needs_review: "No ready workstreams need review.",
|
||||||
stalled: "No stalled workstreams — everything is moving.",
|
stalled: "No stalled workstreams — everything is moving.",
|
||||||
oldies: "No oldies — all older workstreams have at least one task done.",
|
|
||||||
"1h": "No workstreams changed in the last hour.",
|
"1h": "No workstreams changed in the last hour.",
|
||||||
"1d": "No workstreams changed in the last 24 hours.",
|
"1d": "No workstreams changed in the last 24 hours.",
|
||||||
"7d": "No workstreams changed in the last 7 days.",
|
"7d": "No workstreams changed in the last 7 days.",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ title: Workstreams
|
|||||||
|
|
||||||
```js
|
```js
|
||||||
import {API, POLL_HEAVY, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
|
import {API, POLL_HEAVY, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
|
||||||
|
import {WORKSTREAM_STATUSES, isClosedWorkstream, normalizeWorkstreamStatus} from "./components/workplan-status.js";
|
||||||
```
|
```
|
||||||
|
|
||||||
```js
|
```js
|
||||||
@@ -27,6 +28,7 @@ const wsState = (async function*() {
|
|||||||
const repoMap = Object.fromEntries(repoList.map(r => [r.id, r]));
|
const repoMap = Object.fromEntries(repoList.map(r => [r.id, r]));
|
||||||
data = wsList.map(w => ({
|
data = wsList.map(w => ({
|
||||||
...w,
|
...w,
|
||||||
|
status: normalizeWorkstreamStatus(w.status),
|
||||||
domain: repoMap[w.repo_id]?.domain_slug ?? topicMap[w.topic_id]?.domain_slug ?? "unknown",
|
domain: repoMap[w.repo_id]?.domain_slug ?? topicMap[w.topic_id]?.domain_slug ?? "unknown",
|
||||||
topic_title: topicMap[w.topic_id]?.title ?? "—",
|
topic_title: topicMap[w.topic_id]?.title ?? "—",
|
||||||
}));
|
}));
|
||||||
@@ -50,7 +52,7 @@ const _ts = wsState.ts;
|
|||||||
```js
|
```js
|
||||||
// ── Workstream Health Index (WHI) ────────────────────────────────────────────
|
// ── Workstream Health Index (WHI) ────────────────────────────────────────────
|
||||||
const _idToDomain = Object.fromEntries(data.map(w => [w.id, w.domain ?? "unknown"]));
|
const _idToDomain = Object.fromEntries(data.map(w => [w.id, w.domain ?? "unknown"]));
|
||||||
const _completedIds = new Set(data.filter(w => w.status === "completed" || w.status === "archived").map(w => w.id));
|
const _closedIds = new Set(data.filter(w => isClosedWorkstream(w.status)).map(w => w.id));
|
||||||
const _openCount = openWs.length;
|
const _openCount = openWs.length;
|
||||||
const _allEdges = openWs.flatMap(w => w.depends_on.map(d => ({from: w.id, to: d.workstream_id})));
|
const _allEdges = openWs.flatMap(w => w.depends_on.map(d => ({from: w.id, to: d.workstream_id})));
|
||||||
const _totalEdges = _allEdges.length;
|
const _totalEdges = _allEdges.length;
|
||||||
@@ -64,15 +66,15 @@ const _BR = _openCount > 0 ? openWs.filter(w => w.status === "blocked").length /
|
|||||||
// Single-Point Risk — max inbound edges on one incomplete workstream
|
// Single-Point Risk — max inbound edges on one incomplete workstream
|
||||||
const _inbound = {};
|
const _inbound = {};
|
||||||
for (const e of _allEdges) {
|
for (const e of _allEdges) {
|
||||||
if (!_completedIds.has(e.to)) _inbound[e.to] = (_inbound[e.to] ?? 0) + 1;
|
if (!_closedIds.has(e.to)) _inbound[e.to] = (_inbound[e.to] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
const _SPR = _openCount > 0
|
const _SPR = _openCount > 0
|
||||||
? (Object.keys(_inbound).length > 0 ? Math.max(...Object.values(_inbound)) : 0) / _openCount
|
? (Object.keys(_inbound).length > 0 ? Math.max(...Object.values(_inbound)) : 0) / _openCount
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// Parallel Execution Potential — active workstreams with all deps completed
|
// Parallel Execution Potential — ready/active workstreams with all deps finished
|
||||||
const _PEP = _openCount > 0
|
const _PEP = _openCount > 0
|
||||||
? openWs.filter(w => w.status === "active" && w.depends_on.every(d => _completedIds.has(d.workstream_id))).length / _openCount
|
? openWs.filter(w => ["ready", "active"].includes(normalizeWorkstreamStatus(w.status)) && w.depends_on.every(d => _closedIds.has(d.workstream_id))).length / _openCount
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
// Cross-Domain Dependency Ratio
|
// Cross-Domain Dependency Ratio
|
||||||
@@ -117,9 +119,9 @@ const _domainBreakdown = [...new Set(openWs.map(w => _idToDomain[w.id] ?? "unkno
|
|||||||
const dd = oc > 0 ? te / oc : 0;
|
const dd = oc > 0 ? te / oc : 0;
|
||||||
const br = oc > 0 ? nodes.filter(w => w.status === "blocked").length / oc : 0;
|
const br = oc > 0 ? nodes.filter(w => w.status === "blocked").length / oc : 0;
|
||||||
const pep = oc > 0 ? nodes.filter(w => {
|
const pep = oc > 0 ? nodes.filter(w => {
|
||||||
if (w.status !== "active") return false;
|
if (!["ready", "active"].includes(normalizeWorkstreamStatus(w.status))) return false;
|
||||||
const intraDeps = w.depends_on.filter(d => (_idToDomain[d.workstream_id] ?? "unknown") === domain);
|
const intraDeps = w.depends_on.filter(d => (_idToDomain[d.workstream_id] ?? "unknown") === domain);
|
||||||
return intraDeps.every(d => _completedIds.has(d.workstream_id));
|
return intraDeps.every(d => _closedIds.has(d.workstream_id));
|
||||||
}).length / oc : 0;
|
}).length / oc : 0;
|
||||||
const inb = {};
|
const inb = {};
|
||||||
for (const e of edges) inb[e.to] = (inb[e.to] ?? 0) + 1;
|
for (const e of edges) inb[e.to] = (inb[e.to] ?? 0) + 1;
|
||||||
@@ -222,7 +224,7 @@ const _domainsResp = await fetch(`${API}/domains/?status=active`).catch(() => nu
|
|||||||
const DOMAINS = _domainsResp?.ok
|
const DOMAINS = _domainsResp?.ok
|
||||||
? (await _domainsResp.json()).map(d => d.slug)
|
? (await _domainsResp.json()).map(d => d.slug)
|
||||||
: ["custodian", "railiance", "markitect", "coulomb_social", "personhood", "foerster_capabilities"];
|
: ["custodian", "railiance", "markitect", "coulomb_social", "personhood", "foerster_capabilities"];
|
||||||
const STATUSES = ["active", "blocked", "completed", "archived"];
|
const STATUSES = WORKSTREAM_STATUSES;
|
||||||
|
|
||||||
// Create filter form without displaying — shown below the chart
|
// Create filter form without displaying — shown below the chart
|
||||||
const _filtersForm = Inputs.form(
|
const _filtersForm = Inputs.form(
|
||||||
@@ -357,7 +359,10 @@ if (wsWithDeps.length === 0) {
|
|||||||
.dep-status { display: inline-block; font-size: 0.7rem; padding: 1px 6px; border-radius: 10px; margin-bottom: 0.5rem; text-transform: uppercase; }
|
.dep-status { display: inline-block; font-size: 0.7rem; padding: 1px 6px; border-radius: 10px; margin-bottom: 0.5rem; text-transform: uppercase; }
|
||||||
.dep-status-active { background: #d4edda; color: #155724; }
|
.dep-status-active { background: #d4edda; color: #155724; }
|
||||||
.dep-status-blocked { background: #f8d7da; color: #721c24; }
|
.dep-status-blocked { background: #f8d7da; color: #721c24; }
|
||||||
.dep-status-completed { background: #cce5ff; color: #004085; }
|
.dep-status-proposed { background: #fef3c7; color: #92400e; }
|
||||||
|
.dep-status-ready { background: #e0f2fe; color: #075985; }
|
||||||
|
.dep-status-finished { background: #cce5ff; color: #004085; }
|
||||||
|
.dep-status-backlog { background: #f1f5f9; color: #64748b; }
|
||||||
.dep-row { font-size: 0.85rem; margin: 0.2rem 0 0 0.5rem; color: #444; }
|
.dep-row { font-size: 0.85rem; margin: 0.2rem 0 0 0.5rem; color: #444; }
|
||||||
.dep-on { color: #1a5276; }
|
.dep-on { color: #1a5276; }
|
||||||
.dep-block { color: #6e2f00; }
|
.dep-block { color: #6e2f00; }
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ keeps the underlying scripts; only the *scheduling* moves.
|
|||||||
| # | Source | Trigger today | Script invoked | What it does |
|
| # | Source | Trigger today | Script invoked | What it does |
|
||||||
| - | ------------------- | -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
| - | ------------------- | -------------------------------------------------------- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||||
| 1 | systemd user timer | every 15 min | `scripts/consistency_check.py --remote --all` | Pull every registered repo, reconcile workplan files ↔ DB, run C-15 writeback + C-16 pull gate |
|
| 1 | systemd user timer | every 15 min | `scripts/consistency_check.py --remote --all` | Pull every registered repo, reconcile workplan files ↔ DB, run C-15 writeback + C-16 pull gate |
|
||||||
| 2 | manual / daily cron | `make cleanup-stale` (suggested `0 3 * * *`) | `scripts/cleanup_stale_tasks.py` | Cancel tasks still open in completed/archived workstreams; emits `org.statehub.task.stale` |
|
| 2 | manual / daily cron | `make cleanup-stale` (suggested `0 3 * * *`) | `scripts/cleanup_stale_tasks.py` | Cancel tasks still open in finished/archived workstreams; emits `org.statehub.task.stale` |
|
||||||
| 3 | git post-commit | every commit in a registered repo | `make fix-consistency REPO=<slug>` | Per-repo workplan ↔ DB sync immediately after a commit |
|
| 3 | git post-commit | every commit in a registered repo | `make fix-consistency REPO=<slug>` | Per-repo workplan ↔ DB sync immediately after a commit |
|
||||||
|
|
||||||
Honourable mentions (not currently scheduled, on-demand only — listed for
|
Honourable mentions (not currently scheduled, on-demand only — listed for
|
||||||
@@ -79,7 +79,7 @@ Notes:
|
|||||||
id: the-custodian.state-hub-stale-task-cleanup
|
id: the-custodian.state-hub-stale-task-cleanup
|
||||||
description: |
|
description: |
|
||||||
Daily sweep that cancels tasks still 'todo|in_progress|blocked' inside
|
Daily sweep that cancels tasks still 'todo|in_progress|blocked' inside
|
||||||
completed or archived workstreams. Each cancellation also emits
|
finished or archived workstreams. Each cancellation also emits
|
||||||
org.statehub.task.stale on NATS for downstream reaction.
|
org.statehub.task.stale on NATS for downstream reaction.
|
||||||
trigger:
|
trigger:
|
||||||
trigger_type: cron
|
trigger_type: cron
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ those publishers from colliding on the same `{noun}.{verb}` shape.
|
|||||||
| Subject | When | Required attributes |
|
| Subject | When | Required attributes |
|
||||||
| ------------------------------------ | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------------ | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `org.statehub.repo.registered` | A new repo is registered via `POST /repos/` | `repo_id`, `repo_slug`, `domain_slug`, `remote_url?`, `local_path?` |
|
| `org.statehub.repo.registered` | A new repo is registered via `POST /repos/` | `repo_id`, `repo_slug`, `domain_slug`, `remote_url?`, `local_path?` |
|
||||||
| `org.statehub.workstream.completed` | A workstream transitions to status `completed` | `workstream_id`, `slug`, `title`, `topic_id`, `repo_id?`, `repo_goal_id?` |
|
| `org.statehub.workstream.completed` | A workstream transitions to canonical status `finished` | `workstream_id`, `slug`, `title`, `topic_id`, `repo_id?`, `repo_goal_id?` |
|
||||||
| `org.statehub.decision.resolved` | A decision is resolved via `POST /decisions/{id}/resolve` | `decision_id`, `title`, `topic_id?`, `workstream_id?`, `decided_by`, `rationale_snippet` |
|
| `org.statehub.decision.resolved` | A decision is resolved via `POST /decisions/{id}/resolve` | `decision_id`, `title`, `topic_id?`, `workstream_id?`, `decided_by`, `rationale_snippet` |
|
||||||
| `org.statehub.domain.goal.activated` | A domain goal transitions to `active` | `goal_id`, `domain_id`, `domain_slug`, `title`, `superseded_goal_ids[]` |
|
| `org.statehub.domain.goal.activated` | A domain goal transitions to `active` | `goal_id`, `domain_id`, `domain_slug`, `title`, `superseded_goal_ids[]` |
|
||||||
| `org.statehub.task.stale` | `scripts/cleanup_stale_tasks.py` cancels an out-of-date task | `task_id`, `workstream_id`, `workstream_status`, `task_title`, `task_status_before` |
|
| `org.statehub.task.stale` | `scripts/cleanup_stale_tasks.py` cancels an out-of-date task | `task_id`, `workstream_id`, `workstream_status`, `task_title`, `task_status_before` |
|
||||||
|
|||||||
@@ -129,10 +129,10 @@ blocking_assertions:
|
|||||||
passed: false
|
passed: false
|
||||||
reason: "Expected all values at tasks.*.status to be in ['done', 'cancelled']; got ['done', 'todo']."
|
reason: "Expected all values at tasks.*.status to be in ['done', 'cancelled']; got ['done', 'todo']."
|
||||||
reachable:
|
reachable:
|
||||||
- todo
|
- ready
|
||||||
- active
|
- active
|
||||||
unreachable:
|
unreachable:
|
||||||
- workstation: completed
|
- workstation: finished
|
||||||
blocking:
|
blocking:
|
||||||
id: tasks.all_done
|
id: tasks.all_done
|
||||||
passed: false
|
passed: false
|
||||||
@@ -151,9 +151,9 @@ Schema:
|
|||||||
|
|
||||||
### Workstreams
|
### Workstreams
|
||||||
|
|
||||||
Workstreams can express readiness for completion by asserting that child tasks
|
Workstreams can express readiness for closure by asserting that child tasks
|
||||||
are `done` or `cancelled`. They can express dependency blocking by checking that
|
are `done` or `cancelled`. They can express dependency blocking by checking that
|
||||||
all dependency workstreams have reached `completed`.
|
all dependency workstreams have reached `finished` or `archived`.
|
||||||
|
|
||||||
### Tasks
|
### Tasks
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ type: workplan
|
|||||||
title: "Short Title"
|
title: "Short Title"
|
||||||
domain: custodian
|
domain: custodian
|
||||||
repo: state-hub
|
repo: state-hub
|
||||||
status: active
|
status: proposed
|
||||||
owner: custodian
|
owner: custodian
|
||||||
topic_slug: custodian
|
topic_slug: custodian
|
||||||
```
|
```
|
||||||
@@ -22,3 +22,14 @@ topic_slug: custodian
|
|||||||
During extraction, legacy `CUST-WP-*` plans may be bridged or migrated with
|
During extraction, legacy `CUST-WP-*` plans may be bridged or migrated with
|
||||||
their existing `state_hub_workstream_id` values. Write files first, then run
|
their existing `state_hub_workstream_id` values. Write files first, then run
|
||||||
State Hub consistency sync after this repo is registered.
|
State Hub consistency sync after this repo is registered.
|
||||||
|
|
||||||
|
Canonical workplan/workstream statuses are:
|
||||||
|
|
||||||
|
```text
|
||||||
|
proposed, ready, active, blocked, backlog, finished, archived
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `proposed` for a new plan that still needs review, `ready` after it has
|
||||||
|
been checked against the current repo state, and `finished` when implementation
|
||||||
|
is complete. `stalled` and `needs_review` are derived health labels, not stored
|
||||||
|
frontmatter statuses.
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
id: custodian.workstream.v1
|
id: custodian.workstream.v1
|
||||||
entity_type: workstream
|
entity_type: workstream
|
||||||
workstations:
|
workstations:
|
||||||
- name: todo
|
- name: proposed
|
||||||
description: Planned but not yet active.
|
description: Plan exists but needs review against the current repo state.
|
||||||
|
entry_assertions: []
|
||||||
|
exit_assertions: []
|
||||||
|
- name: ready
|
||||||
|
description: Reviewed and ready to execute.
|
||||||
entry_assertions: []
|
entry_assertions: []
|
||||||
exit_assertions: []
|
exit_assertions: []
|
||||||
- name: active
|
- name: active
|
||||||
@@ -12,23 +16,33 @@ workstations:
|
|||||||
- id: dependencies.all_complete
|
- id: dependencies.all_complete
|
||||||
target: dependencies.*.workstation
|
target: dependencies.*.workstation
|
||||||
op: all_eq
|
op: all_eq
|
||||||
value: completed
|
value:
|
||||||
description: Dependency workstreams have reached completed.
|
- finished
|
||||||
|
- archived
|
||||||
|
description: Dependency workstreams have reached a closed state.
|
||||||
- name: blocked
|
- name: blocked
|
||||||
description: Work is blocked by incomplete dependencies or missing input.
|
description: Work is blocked by incomplete dependencies or missing input.
|
||||||
entry_assertions:
|
entry_assertions:
|
||||||
- id: dependencies.any_incomplete
|
- id: dependencies.any_incomplete
|
||||||
target: dependencies.*.workstation
|
target: dependencies.*.workstation
|
||||||
op: custom
|
op: custom
|
||||||
value: completed
|
value:
|
||||||
description: At least one dependency is not completed.
|
- finished
|
||||||
|
- archived
|
||||||
|
description: At least one dependency is not finished or archived.
|
||||||
exit_assertions:
|
exit_assertions:
|
||||||
- id: dependencies.all_complete
|
- id: dependencies.all_complete
|
||||||
target: dependencies.*.workstation
|
target: dependencies.*.workstation
|
||||||
op: all_eq
|
op: all_eq
|
||||||
value: completed
|
value:
|
||||||
description: All dependency workstreams have reached completed.
|
- finished
|
||||||
- name: completed
|
- archived
|
||||||
|
description: All dependency workstreams have reached finished or archived.
|
||||||
|
- name: backlog
|
||||||
|
description: Intentionally parked for later.
|
||||||
|
entry_assertions: []
|
||||||
|
exit_assertions: []
|
||||||
|
- name: finished
|
||||||
description: Work is complete.
|
description: Work is complete.
|
||||||
entry_assertions:
|
entry_assertions:
|
||||||
- id: tasks.all_done
|
- id: tasks.all_done
|
||||||
@@ -40,6 +54,6 @@ workstations:
|
|||||||
description: All child tasks are done or cancelled.
|
description: All child tasks are done or cancelled.
|
||||||
exit_assertions: []
|
exit_assertions: []
|
||||||
- name: archived
|
- name: archived
|
||||||
description: Completed work has been moved out of the active set.
|
description: Closed work has been moved out of the active set.
|
||||||
entry_assertions: []
|
entry_assertions: []
|
||||||
exit_assertions: []
|
exit_assertions: []
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ Do not use them as a substitute for formal work definition inside the domain rep
|
|||||||
| `create_workstream(topic_id, title, ...)` | `slug?`; `owner?`; `description?`; `due_date?` | Creates workstream under a topic. Use `get_state_summary()` to find topic IDs. |
|
| `create_workstream(topic_id, title, ...)` | `slug?`; `owner?`; `description?`; `due_date?` | Creates workstream under a topic. Use `get_state_summary()` to find topic IDs. |
|
||||||
| `create_task(workstream_id, title, ...)` | `priority`: low/medium/high/critical; `assignee?`; `due_date?` | Creates task under a workstream. |
|
| `create_task(workstream_id, title, ...)` | `priority`: low/medium/high/critical; `assignee?`; `due_date?` | Creates task under a workstream. |
|
||||||
| `update_task_status(task_id, status, ...)` | `status`: todo/in_progress/blocked/done/cancelled; `blocking_reason` required when blocked | |
|
| `update_task_status(task_id, status, ...)` | `status`: todo/in_progress/blocked/done/cancelled; `blocking_reason` required when blocked | |
|
||||||
| `update_workstream_status(workstream_id, status)` | `status`: active/blocked/completed/archived | Thin shortcut — use `update_workstream` for full field control. |
|
| `update_workstream_status(workstream_id, status)` | `status`: proposed/ready/active/blocked/backlog/finished/archived | Thin shortcut — use `update_workstream` for full field control. |
|
||||||
| `update_workstream(workstream_id, ...)` | `title?`; `description?`; `owner?`; `due_date?`; `repo_goal_id?`; `status?` | Patch any subset of workstream fields. Pass empty string for `repo_goal_id` to clear the link. |
|
| `update_workstream(workstream_id, ...)` | `title?`; `description?`; `owner?`; `due_date?`; `repo_goal_id?`; `status?` | Patch any subset of workstream fields. Pass empty string for `repo_goal_id` to clear the link. |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -757,7 +757,7 @@ def update_workstream_status(workstream_id: str, status: str) -> str:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
workstream_id: UUID of the workstream
|
workstream_id: UUID of the workstream
|
||||||
status: active | blocked | completed | archived
|
status: proposed | ready | active | blocked | backlog | finished | archived
|
||||||
"""
|
"""
|
||||||
ws = _patch(f"/workstreams/{workstream_id}", {"status": status})
|
ws = _patch(f"/workstreams/{workstream_id}", {"status": status})
|
||||||
_post("/progress", {
|
_post("/progress", {
|
||||||
@@ -789,7 +789,7 @@ def update_workstream(
|
|||||||
owner: new owner (optional)
|
owner: new owner (optional)
|
||||||
due_date: ISO date string YYYY-MM-DD (optional)
|
due_date: ISO date string YYYY-MM-DD (optional)
|
||||||
repo_goal_id: UUID of the repo goal to link (optional; pass empty string to clear)
|
repo_goal_id: UUID of the repo goal to link (optional; pass empty string to clear)
|
||||||
status: active | blocked | completed | archived (optional)
|
status: proposed | ready | active | blocked | backlog | finished | archived (optional)
|
||||||
"""
|
"""
|
||||||
payload: dict = {}
|
payload: dict = {}
|
||||||
if title is not None:
|
if title is not None:
|
||||||
@@ -818,7 +818,7 @@ def get_next_steps() -> str:
|
|||||||
|
|
||||||
Returns suggestions based on:
|
Returns suggestions based on:
|
||||||
- Recently resolved decisions → first open task in the same workstream
|
- Recently resolved decisions → first open task in the same workstream
|
||||||
- Workstreams whose every dependency is now completed → first todo task
|
- Workstreams whose every dependency is now finished -> first todo task
|
||||||
|
|
||||||
Each suggestion includes domain, workstream, task, and a plain-language
|
Each suggestion includes domain, workstream, task, and a plain-language
|
||||||
message. The hub surfaces *what* and *where* — the domain owns *how*.
|
message. The hub surfaces *what* and *where* — the domain owns *how*.
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""normalize workstream lifecycle states
|
||||||
|
|
||||||
|
Revision ID: u8p9q0r1s2t3
|
||||||
|
Revises: t7o8p9q0r1s2
|
||||||
|
Create Date: 2026-05-17
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "u8p9q0r1s2t3"
|
||||||
|
down_revision = "t7o8p9q0r1s2"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE workstreams
|
||||||
|
SET status = 'finished'
|
||||||
|
WHERE status IN ('completed', 'accepted', 'done')
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE workstreams ws
|
||||||
|
SET status = 'active'
|
||||||
|
WHERE status = 'todo'
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM tasks t
|
||||||
|
WHERE t.workstream_id = ws.id
|
||||||
|
AND t.status <> 'todo'
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
UPDATE workstreams
|
||||||
|
SET status = 'ready'
|
||||||
|
WHERE status = 'todo'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("UPDATE workstreams SET status = 'completed' WHERE status = 'finished'")
|
||||||
|
op.execute("UPDATE workstreams SET status = 'active' WHERE status IN ('proposed', 'ready', 'backlog')")
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
cleanup_stale_tasks.py — cancel tasks that are still open in completed/archived workstreams.
|
cleanup_stale_tasks.py — cancel tasks that are still open in finished/archived workstreams.
|
||||||
|
|
||||||
Run manually: python3 scripts/cleanup_stale_tasks.py
|
Run manually: python3 scripts/cleanup_stale_tasks.py
|
||||||
Run via make: make cleanup-stale
|
Run via make: make cleanup-stale
|
||||||
@@ -22,6 +22,8 @@ from datetime import datetime, timezone
|
|||||||
# Make the api package importable when running as `python scripts/cleanup_stale_tasks.py`
|
# Make the api package importable when running as `python scripts/cleanup_stale_tasks.py`
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
from api.workplan_status import CLOSED_WORKSTREAM_STATUSES, normalize_workstream_status
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from api.events import EventEnvelope, publish_event, shutdown_publisher
|
from api.events import EventEnvelope, publish_event, shutdown_publisher
|
||||||
except Exception: # pragma: no cover — event publishing is optional
|
except Exception: # pragma: no cover — event publishing is optional
|
||||||
@@ -31,7 +33,7 @@ except Exception: # pragma: no cover — event publishing is optional
|
|||||||
|
|
||||||
API = "http://127.0.0.1:8000"
|
API = "http://127.0.0.1:8000"
|
||||||
STALE_STATUSES = {"todo", "in_progress", "blocked"}
|
STALE_STATUSES = {"todo", "in_progress", "blocked"}
|
||||||
CLOSED_WS_STATUS = {"completed", "archived"}
|
CLOSED_WS_STATUS = set(CLOSED_WORKSTREAM_STATUSES)
|
||||||
|
|
||||||
|
|
||||||
def get(path: str) -> list | dict:
|
def get(path: str) -> list | dict:
|
||||||
@@ -81,7 +83,11 @@ def main() -> int:
|
|||||||
print("[cleanup-stale] Start the API with: cd ~/state-hub && make api", file=sys.stderr)
|
print("[cleanup-stale] Start the API with: cd ~/state-hub && make api", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
closed_ws = {w["id"]: w for w in workstreams if w["status"] in CLOSED_WS_STATUS}
|
closed_ws = {
|
||||||
|
w["id"]: w
|
||||||
|
for w in workstreams
|
||||||
|
if normalize_workstream_status(w["status"]) in CLOSED_WS_STATUS
|
||||||
|
}
|
||||||
|
|
||||||
stale = [
|
stale = [
|
||||||
t for t in tasks
|
t for t in tasks
|
||||||
@@ -93,7 +99,7 @@ def main() -> int:
|
|||||||
print("[cleanup-stale] Nothing to cancel — all open tasks belong to active workstreams.")
|
print("[cleanup-stale] Nothing to cancel — all open tasks belong to active workstreams.")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
print(f"[cleanup-stale] Found {len(stale)} stale task(s) in completed/archived workstreams:")
|
print(f"[cleanup-stale] Found {len(stale)} stale task(s) in finished/archived workstreams:")
|
||||||
|
|
||||||
cancelled = []
|
cancelled = []
|
||||||
errors = []
|
errors = []
|
||||||
@@ -150,7 +156,7 @@ def main() -> int:
|
|||||||
|
|
||||||
summary = (
|
summary = (
|
||||||
f"Stale-task cleanup: cancelled {len(cancelled)} task(s) "
|
f"Stale-task cleanup: cancelled {len(cancelled)} task(s) "
|
||||||
f"across {len(by_ws)} completed workstream(s)"
|
f"across {len(by_ws)} finished workstream(s)"
|
||||||
)
|
)
|
||||||
detail = {
|
detail = {
|
||||||
"cancelled_count": len(cancelled),
|
"cancelled_count": len(cancelled),
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Checks:
|
|||||||
C-05 workstream-title-drift WARN Yes File title != DB title (file wins)
|
C-05 workstream-title-drift WARN Yes File title != DB title (file wins)
|
||||||
C-06 workstream-unlinked WARN Yes Workplan has no state_hub_workstream_id
|
C-06 workstream-unlinked WARN Yes Workplan has no state_hub_workstream_id
|
||||||
C-07 orphan-db-active FAIL No Active DB workstream, no backing file
|
C-07 orphan-db-active FAIL No Active DB workstream, no backing file
|
||||||
C-08 orphan-db-completed INFO No Completed/archived DB workstream, no file
|
C-08 orphan-db-closed INFO No Finished/archived DB workstream, no file
|
||||||
C-09 workstream-repo-mismatch FAIL Yes DB workstream repo_id != file location
|
C-09 workstream-repo-mismatch FAIL Yes DB workstream repo_id != file location
|
||||||
C-10 task-status-drift WARN Yes Task status differs between file and DB
|
C-10 task-status-drift WARN Yes Task status differs between file and DB
|
||||||
C-11 task-unlinked WARN Yes Task block has no state_hub_task_id
|
C-11 task-unlinked WARN Yes Task block has no state_hub_task_id
|
||||||
@@ -51,6 +51,20 @@ from datetime import datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
if str(_REPO_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_REPO_ROOT))
|
||||||
|
|
||||||
|
from api.workplan_status import ( # noqa: E402
|
||||||
|
CANONICAL_WORKSTREAM_STATUSES,
|
||||||
|
CLOSED_WORKSTREAM_STATUSES,
|
||||||
|
LEGACY_WORKSTREAM_STATUS_ALIASES,
|
||||||
|
OPEN_WORKSTREAM_STATUSES,
|
||||||
|
SUPPORTED_WORKSTREAM_STATUSES,
|
||||||
|
normalize_workstream_status as _normalize_workstream_status,
|
||||||
|
ready_review_status,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import yaml as _yaml
|
import yaml as _yaml
|
||||||
_HAS_YAML = True
|
_HAS_YAML = True
|
||||||
@@ -71,19 +85,15 @@ except ImportError:
|
|||||||
_TASK_BLOCK_RE = re.compile(r"```task\s*\n(.*?)\n```", re.DOTALL)
|
_TASK_BLOCK_RE = re.compile(r"```task\s*\n(.*?)\n```", re.DOTALL)
|
||||||
_HEADING_RE = re.compile(r"^#{1,4}\s+(.+?)$", re.MULTILINE)
|
_HEADING_RE = re.compile(r"^#{1,4}\s+(.+?)$", re.MULTILINE)
|
||||||
_ARCHIVED_WP_RE = re.compile(r"^\d{6}-(.+\.md)$")
|
_ARCHIVED_WP_RE = re.compile(r"^\d{6}-(.+\.md)$")
|
||||||
VALID_WP_STATUSES = {"active", "completed", "archived"}
|
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 = {"todo", "in_progress", "blocked", "done", "cancelled"}
|
||||||
VALID_TASK_PRIORITIES = {"low", "medium", "high", "critical"}
|
VALID_TASK_PRIORITIES = {"low", "medium", "high", "critical"}
|
||||||
VALID_DEP_RELATIONSHIPS = {"blocks", "starts_after", "informs", "soft_dependency"}
|
VALID_DEP_RELATIONSHIPS = {"blocks", "starts_after", "informs", "soft_dependency"}
|
||||||
DEFAULT_REMOTE_ALL_MAX_SECONDS = int(os.environ.get("CONSISTENCY_REMOTE_ALL_MAX_SECONDS", "300"))
|
DEFAULT_REMOTE_ALL_MAX_SECONDS = int(os.environ.get("CONSISTENCY_REMOTE_ALL_MAX_SECONDS", "300"))
|
||||||
|
|
||||||
# Workplan files use task-style vocabulary ("done"); the DB workstream API uses
|
# Legacy file/API aliases translated before comparison and PATCHing.
|
||||||
# "completed". This map translates file values to DB values before comparison
|
FILE_TO_DB_WORKSTREAM_STATUS: dict[str, str] = dict(LEGACY_WORKSTREAM_STATUS_ALIASES)
|
||||||
# and before PATCHing, so "done" vs "completed" is never flagged as C-04 drift.
|
|
||||||
FILE_TO_DB_WORKSTREAM_STATUS: dict[str, str] = {
|
|
||||||
"done": "completed",
|
|
||||||
"todo": "active", # workplan not yet started → active workstream in DB
|
|
||||||
}
|
|
||||||
|
|
||||||
# Ordinal ranking for task statuses used by the no-regress rule (T01/C-15).
|
# Ordinal ranking for task statuses used by the no-regress rule (T01/C-15).
|
||||||
# blocked and in_progress share rank 1 — both are "in flight".
|
# blocked and in_progress share rank 1 — both are "in flight".
|
||||||
@@ -96,9 +106,9 @@ STATUS_ORDER: dict[str, int] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def normalise_workstream_status(status: str) -> str:
|
def normalise_workstream_status(status: str, *, has_started: bool | None = None) -> str:
|
||||||
"""Translate a workplan file status value to its DB-canonical equivalent."""
|
"""Translate a workplan file status value to its DB-canonical equivalent."""
|
||||||
return FILE_TO_DB_WORKSTREAM_STATUS.get(status, status)
|
return _normalize_workstream_status(status, has_started=has_started)
|
||||||
|
|
||||||
|
|
||||||
def canonical_workplan_filename(path: Path) -> str:
|
def canonical_workplan_filename(path: Path) -> str:
|
||||||
@@ -593,10 +603,11 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
|
|||||||
file_title = str(meta.get("title", "")).strip()
|
file_title = str(meta.get("title", "")).strip()
|
||||||
file_domain = str(meta.get("domain", "")).strip()
|
file_domain = str(meta.get("domain", "")).strip()
|
||||||
|
|
||||||
if archived_file and normalise_workstream_status(file_status) == "active":
|
normalised_file_status = normalise_workstream_status(file_status)
|
||||||
|
if archived_file and normalised_file_status not in CLOSED_WORKSTREAM_STATUSES:
|
||||||
report.add(
|
report.add(
|
||||||
severity="FAIL", check_id="C-18",
|
severity="FAIL", check_id="C-18",
|
||||||
message="Archived workplan file has active/todo status",
|
message="Archived workplan file has an open or planning status",
|
||||||
file_path=fname,
|
file_path=fname,
|
||||||
file_value=file_status,
|
file_value=file_status,
|
||||||
fixable=False,
|
fixable=False,
|
||||||
@@ -652,10 +663,10 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
|
|||||||
# Continue to check drift even with mismatched repo
|
# Continue to check drift even with mismatched repo
|
||||||
|
|
||||||
# C-04: status drift — normalise file value before comparing so that
|
# C-04: status drift — normalise file value before comparing so that
|
||||||
# "done" (file) vs "completed" (DB) is not treated as drift.
|
# legacy file/API aliases are not treated as drift.
|
||||||
db_status = ws.get("status", "")
|
db_status = ws.get("status", "")
|
||||||
normalised_file_status = normalise_workstream_status(file_status)
|
normalised_db_status = normalise_workstream_status(db_status)
|
||||||
if file_status and db_status and normalised_file_status != db_status:
|
if file_status and db_status and normalised_file_status != normalised_db_status:
|
||||||
report.add(
|
report.add(
|
||||||
severity="WARN", check_id="C-04",
|
severity="WARN", check_id="C-04",
|
||||||
message=(
|
message=(
|
||||||
@@ -674,6 +685,28 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if normalised_file_status == "ready":
|
||||||
|
review = ready_review_status(
|
||||||
|
repo_dir,
|
||||||
|
meta.get("reviewed_against_commit"),
|
||||||
|
meta.get("context_paths"),
|
||||||
|
)
|
||||||
|
if review.needs_review:
|
||||||
|
detail = f"Ready workplan may be stale: {review.reason}"
|
||||||
|
if review.changed_paths:
|
||||||
|
preview = ", ".join(review.changed_paths[:5])
|
||||||
|
extra = "" if len(review.changed_paths) <= 5 else ", ..."
|
||||||
|
detail = f"{detail}; changed paths: {preview}{extra}"
|
||||||
|
report.add(
|
||||||
|
severity="WARN",
|
||||||
|
check_id="C-21",
|
||||||
|
message=detail,
|
||||||
|
file_path=fname,
|
||||||
|
file_value=file_status,
|
||||||
|
db_value="needs_review",
|
||||||
|
fixable=False,
|
||||||
|
)
|
||||||
|
|
||||||
# C-05: title drift
|
# C-05: title drift
|
||||||
db_title = ws.get("title", "")
|
db_title = ws.get("title", "")
|
||||||
if file_title and db_title and file_title != db_title:
|
if file_title and db_title and file_title != db_title:
|
||||||
@@ -888,7 +921,7 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
|
|||||||
# C-12: DB tasks with no file backing
|
# C-12: DB tasks with no file backing
|
||||||
if isinstance(db_tasks, list):
|
if isinstance(db_tasks, list):
|
||||||
ws_status = ws.get("status", "")
|
ws_status = ws.get("status", "")
|
||||||
ws_finished = ws_status in ("completed", "archived")
|
ws_finished = normalise_workstream_status(ws_status) in CLOSED_WORKSTREAM_STATUSES
|
||||||
for db_t in db_tasks:
|
for db_t in db_tasks:
|
||||||
if db_t["id"] not in file_task_sh_ids:
|
if db_t["id"] not in file_task_sh_ids:
|
||||||
db_t_status = db_t.get("status", "")
|
db_t_status = db_t.get("status", "")
|
||||||
@@ -912,7 +945,7 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
|
|||||||
|
|
||||||
# C-13: all DB tasks done but workstream still active — worker forgot to close
|
# C-13: all DB tasks done but workstream still active — worker forgot to close
|
||||||
db_status = ws.get("status", "")
|
db_status = ws.get("status", "")
|
||||||
if db_status == "active" and isinstance(db_tasks, list) and db_tasks:
|
if normalise_workstream_status(db_status) == "active" and isinstance(db_tasks, list) and db_tasks:
|
||||||
non_terminal = [
|
non_terminal = [
|
||||||
t for t in db_tasks
|
t for t in db_tasks
|
||||||
if t.get("status") not in ("done", "cancelled")
|
if t.get("status") not in ("done", "cancelled")
|
||||||
@@ -932,7 +965,7 @@ def check_repo(api_base: str, repo_slug: str, repo_path_override: str | None = N
|
|||||||
_fix_context={
|
_fix_context={
|
||||||
"ws_id": ws_id,
|
"ws_id": ws_id,
|
||||||
"field": "status",
|
"field": "status",
|
||||||
"value": "completed",
|
"value": "finished",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -963,26 +996,27 @@ def _check_orphan_db(
|
|||||||
for ws in all_ws:
|
for ws in all_ws:
|
||||||
ws_id = ws["id"]
|
ws_id = ws["id"]
|
||||||
ws_status = ws.get("status", "")
|
ws_status = ws.get("status", "")
|
||||||
if ws_status == "active" and ws_id in active_file_ws_ids:
|
normalised_status = normalise_workstream_status(ws_status)
|
||||||
|
if normalised_status not in CLOSED_WORKSTREAM_STATUSES and ws_id in active_file_ws_ids:
|
||||||
continue
|
continue
|
||||||
if ws_status in ("completed", "archived") and ws_id in file_ws_ids:
|
if normalised_status in CLOSED_WORKSTREAM_STATUSES and ws_id in file_ws_ids:
|
||||||
continue
|
continue
|
||||||
ws_slug = ws.get("slug", "")
|
ws_slug = ws.get("slug", "")
|
||||||
if ws_status == "active":
|
if normalised_status not in CLOSED_WORKSTREAM_STATUSES:
|
||||||
report.add(
|
report.add(
|
||||||
severity="FAIL", check_id="C-07",
|
severity="FAIL", check_id="C-07",
|
||||||
message=(
|
message=(
|
||||||
f"Active DB workstream '{ws_slug}' (id={ws_id[:8]}…) "
|
f"Non-closed DB workstream '{ws_slug}' (id={ws_id[:8]}…) "
|
||||||
f"has no backing workplan file — ADR-001 violation"
|
f"has no backing workplan file — ADR-001 violation"
|
||||||
),
|
),
|
||||||
db_id=ws_id,
|
db_id=ws_id,
|
||||||
fixable=False,
|
fixable=False,
|
||||||
)
|
)
|
||||||
elif ws_status in ("completed", "archived"):
|
elif normalised_status in CLOSED_WORKSTREAM_STATUSES:
|
||||||
report.add(
|
report.add(
|
||||||
severity="INFO", check_id="C-08",
|
severity="INFO", check_id="C-08",
|
||||||
message=(
|
message=(
|
||||||
f"Completed/archived DB workstream '{ws_slug}' "
|
f"Closed DB workstream '{ws_slug}' "
|
||||||
f"(id={ws_id[:8]}…, status={ws_status}) has no backing workplan file"
|
f"(id={ws_id[:8]}…, status={ws_status}) has no backing workplan file"
|
||||||
),
|
),
|
||||||
db_id=ws_id,
|
db_id=ws_id,
|
||||||
@@ -1019,9 +1053,11 @@ def _check_ghost_duplicates(
|
|||||||
topic_ids.add(ws["topic_id"])
|
topic_ids.add(ws["topic_id"])
|
||||||
|
|
||||||
for topic_id in topic_ids:
|
for topic_id in topic_ids:
|
||||||
topic_ws = _api_get(api_base, "/workstreams", {"topic_id": topic_id, "status": "active"})
|
topic_ws: list[dict] = []
|
||||||
if not isinstance(topic_ws, list):
|
for status in OPEN_WORKSTREAM_STATUSES:
|
||||||
continue
|
status_rows = _api_get(api_base, "/workstreams", {"topic_id": topic_id, "status": status})
|
||||||
|
if isinstance(status_rows, list):
|
||||||
|
topic_ws.extend(status_rows)
|
||||||
for ws in topic_ws:
|
for ws in topic_ws:
|
||||||
ws_id = ws["id"]
|
ws_id = ws["id"]
|
||||||
if ws_id in file_ws_ids:
|
if ws_id in file_ws_ids:
|
||||||
@@ -1166,9 +1202,13 @@ def _write_custodian_brief(api_base: str, repo_slug: str, repo_path: str) -> boo
|
|||||||
domain_slug: str = ""
|
domain_slug: str = ""
|
||||||
|
|
||||||
# Resolve domain slug: prefer active workstreams, fall back to any workstream
|
# Resolve domain slug: prefer active workstreams, fall back to any workstream
|
||||||
# so that a fully-completed repo doesn't degrade to "(unknown)".
|
# so that a fully-finished repo doesn't degrade to "(unknown)".
|
||||||
workstreams = _api_get(api_base, "/workstreams", {"repo_id": repo_id, "status": "active"}) or []
|
workstreams: list[dict] = []
|
||||||
_ws_for_domain = workstreams if (isinstance(workstreams, list) and workstreams) else []
|
for status in OPEN_WORKSTREAM_STATUSES:
|
||||||
|
rows = _api_get(api_base, "/workstreams", {"repo_id": repo_id, "status": status}) or []
|
||||||
|
if isinstance(rows, list):
|
||||||
|
workstreams.extend(rows)
|
||||||
|
_ws_for_domain = workstreams if workstreams else []
|
||||||
if not _ws_for_domain:
|
if not _ws_for_domain:
|
||||||
all_ws = _api_get(api_base, "/workstreams", {"repo_id": repo_id}) or []
|
all_ws = _api_get(api_base, "/workstreams", {"repo_id": repo_id}) or []
|
||||||
_ws_for_domain = all_ws if isinstance(all_ws, list) else []
|
_ws_for_domain = all_ws if isinstance(all_ws, list) else []
|
||||||
@@ -1379,7 +1419,8 @@ def fix_repo(
|
|||||||
wp_id = str(meta.get("id", "")).strip()
|
wp_id = str(meta.get("id", "")).strip()
|
||||||
title = str(meta.get("title", "")).strip()
|
title = str(meta.get("title", "")).strip()
|
||||||
status = str(meta.get("status", "active")).strip()
|
status = str(meta.get("status", "active")).strip()
|
||||||
if status not in ("active", "completed", "archived"):
|
status = normalise_workstream_status(status)
|
||||||
|
if status not in VALID_WP_STATUSES:
|
||||||
status = "active"
|
status = "active"
|
||||||
|
|
||||||
# Find topic_id for this domain
|
# Find topic_id for this domain
|
||||||
@@ -1500,7 +1541,7 @@ def fix_repo(
|
|||||||
t_id = str(task.get("id", "")).strip()
|
t_id = str(task.get("id", "")).strip()
|
||||||
# Skip creating tasks for finished workstreams — the workstream is
|
# Skip creating tasks for finished workstreams — the workstream is
|
||||||
# done/archived so unlinked tasks are stale file artefacts, not gaps.
|
# done/archived so unlinked tasks are stale file artefacts, not gaps.
|
||||||
if ws_status in ("completed", "archived"):
|
if normalise_workstream_status(ws_status) in CLOSED_WORKSTREAM_STATUSES:
|
||||||
report.fixes_applied.append(
|
report.fixes_applied.append(
|
||||||
f"C-11 skipped: task '{t_id}' in {ws_status} workstream — not created"
|
f"C-11 skipped: task '{t_id}' in {ws_status} workstream — not created"
|
||||||
)
|
)
|
||||||
@@ -1596,7 +1637,7 @@ def fix_repo(
|
|||||||
|
|
||||||
|
|
||||||
# Check IDs that are known-background noise in multi-machine setups:
|
# Check IDs that are known-background noise in multi-machine setups:
|
||||||
# C-08 = completed/archived DB workstream with no file (pre-ADR-001 legacy)
|
# C-08 = finished/archived DB workstream with no file (pre-ADR-001 legacy)
|
||||||
# These alone do not warrant a pull+fix cycle.
|
# These alone do not warrant a pull+fix cycle.
|
||||||
_BACKGROUND_CHECKS: frozenset[str] = frozenset({"C-08"})
|
_BACKGROUND_CHECKS: frozenset[str] = frozenset({"C-08"})
|
||||||
|
|
||||||
@@ -1707,7 +1748,7 @@ def archive_closed_workplans(
|
|||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Move closed root workplans into workplans/archived/ with YYMMDD prefix.
|
"""Move closed root workplans into workplans/archived/ with YYMMDD prefix.
|
||||||
|
|
||||||
Only root-level files whose frontmatter status normalises to completed or
|
Only root-level files whose frontmatter status normalises to finished or
|
||||||
archived are moved. Files with any open task blocks are left in place.
|
archived are moved. Files with any open task blocks are left in place.
|
||||||
"""
|
"""
|
||||||
repo_dir = Path(repo_path)
|
repo_dir = Path(repo_path)
|
||||||
@@ -1732,7 +1773,7 @@ def archive_closed_workplans(
|
|||||||
if wanted not in {str(meta.get("id", "")), wp_file.stem, wp_file.name}:
|
if wanted not in {str(meta.get("id", "")), wp_file.stem, wp_file.name}:
|
||||||
continue
|
continue
|
||||||
status = normalise_workstream_status(str(meta.get("status", "")).strip())
|
status = normalise_workstream_status(str(meta.get("status", "")).strip())
|
||||||
if status not in ("completed", "archived"):
|
if status not in CLOSED_WORKSTREAM_STATUSES:
|
||||||
continue
|
continue
|
||||||
tasks = get_tasks_from_workplan(meta, body)
|
tasks = get_tasks_from_workplan(meta, body)
|
||||||
open_tasks = [
|
open_tasks = [
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
|
|||||||
**Start:**
|
**Start:**
|
||||||
1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe)
|
1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe)
|
||||||
2. Check inbox: `GET /messages/?to_agent={REPO_SLUG}&unread_only=true`; mark read
|
2. Check inbox: `GET /messages/?to_agent={REPO_SLUG}&unread_only=true`; mark read
|
||||||
3. Scan workplans: `ls workplans/` — note `status: active` files and open tasks
|
3. Scan workplans: `ls workplans/` — note `status: ready`, `active`, or `blocked` files and open tasks
|
||||||
4. Check blocked tasks: `GET /tasks/?needs_human=true`
|
4. Check blocked tasks: `GET /tasks/?needs_human=true`
|
||||||
|
|
||||||
**During work:**
|
**During work:**
|
||||||
@@ -108,7 +108,7 @@ read/cache/index layer that rebuilds from files.
|
|||||||
|
|
||||||
**File location:** `workplans/{WP_PREFIX}-NNNN-<slug>.md`
|
**File location:** `workplans/{WP_PREFIX}-NNNN-<slug>.md`
|
||||||
|
|
||||||
**Archived location:** completed workplans may move to
|
**Archived location:** finished workplans may move to
|
||||||
`workplans/archived/YYMMDD-{WP_PREFIX}-NNNN-<slug>.md`. The `YYMMDD` prefix is
|
`workplans/archived/YYMMDD-{WP_PREFIX}-NNNN-<slug>.md`. The `YYMMDD` prefix is
|
||||||
the completion/archive date; the frontmatter `id` does not change.
|
the completion/archive date; the frontmatter `id` does not change.
|
||||||
|
|
||||||
@@ -126,7 +126,7 @@ type: workplan
|
|||||||
title: "..."
|
title: "..."
|
||||||
domain: {DOMAIN}
|
domain: {DOMAIN}
|
||||||
repo: {REPO_SLUG}
|
repo: {REPO_SLUG}
|
||||||
status: active | done
|
status: proposed | ready | active | blocked | backlog | finished | archived
|
||||||
owner: codex
|
owner: codex
|
||||||
topic_slug: ...
|
topic_slug: ...
|
||||||
created: "YYYY-MM-DD"
|
created: "YYYY-MM-DD"
|
||||||
@@ -135,6 +135,10 @@ state_hub_workstream_id: "<uuid>" # written by fix-consistency — do not edit
|
|||||||
---
|
---
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Use `proposed` for a new draft, `ready` after review against current repo
|
||||||
|
state, and `finished` after implementation. `stalled` and `needs_review` are
|
||||||
|
derived health labels, not frontmatter statuses.
|
||||||
|
|
||||||
**Task block format** (one per `##` section):
|
**Task block format** (one per `##` section):
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -25,7 +25,8 @@ requests before proceeding.
|
|||||||
```bash
|
```bash
|
||||||
ls workplans/
|
ls workplans/
|
||||||
```
|
```
|
||||||
For each file with `status: active`, note pending `todo`/`in_progress` tasks.
|
For each file with `status: ready`, `active`, or `blocked`, note pending
|
||||||
|
`todo`/`in_progress` tasks.
|
||||||
|
|
||||||
**Step 4 — Present brief**
|
**Step 4 — Present brief**
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ ID prefix: `{WP_PREFIX}`
|
|||||||
|
|
||||||
Work items originate as files in this repo **before** being registered in the hub.
|
Work items originate as files in this repo **before** being registered in the hub.
|
||||||
|
|
||||||
|
Canonical workplan/workstream frontmatter statuses are:
|
||||||
|
`proposed`, `ready`, `active`, `blocked`, `backlog`, `finished`, `archived`.
|
||||||
|
Use `proposed` for a newly drafted plan, `ready` after review against current
|
||||||
|
repo state, and `finished` when implementation is complete. `stalled` and
|
||||||
|
`needs_review` are derived health labels, not stored statuses.
|
||||||
|
|
||||||
Closed workplans may be moved to `workplans/archived/` with a completion-date
|
Closed workplans may be moved to `workplans/archived/` with a completion-date
|
||||||
prefix: `YYMMDD-{REPO_SLUG}-WP-NNNN-<slug>.md`. The frontmatter id remains
|
prefix: `YYMMDD-{REPO_SLUG}-WP-NNNN-<slug>.md`. The frontmatter id remains
|
||||||
unchanged; the prefix is only for quick visual reference.
|
unchanged; the prefix is only for quick visual reference.
|
||||||
|
|||||||
@@ -40,6 +40,16 @@ from dataclasses import dataclass, field
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
if str(_REPO_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(_REPO_ROOT))
|
||||||
|
|
||||||
|
from api.workplan_status import ( # noqa: E402
|
||||||
|
CANONICAL_WORKSTREAM_STATUSES,
|
||||||
|
SUPPORTED_WORKSTREAM_STATUSES,
|
||||||
|
normalize_workstream_status,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import yaml as _yaml
|
import yaml as _yaml
|
||||||
_HAS_YAML = True
|
_HAS_YAML = True
|
||||||
@@ -58,7 +68,8 @@ except ImportError:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
REQUIRED_FRONTMATTER = {"id", "type", "title", "domain", "status", "owner", "created"}
|
REQUIRED_FRONTMATTER = {"id", "type", "title", "domain", "status", "owner", "created"}
|
||||||
VALID_WP_STATUSES = {"active", "completed", "archived"}
|
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 = {"todo", "in_progress", "blocked", "done", "cancelled"}
|
||||||
VALID_TASK_PRIORITIES = {"low", "medium", "high", "critical"}
|
VALID_TASK_PRIORITIES = {"low", "medium", "high", "critical"}
|
||||||
|
|
||||||
@@ -198,11 +209,14 @@ def _check_workplan_file(wp_file: Path, report: Report) -> dict | None:
|
|||||||
|
|
||||||
# status
|
# status
|
||||||
status = str(meta.get("status", ""))
|
status = str(meta.get("status", ""))
|
||||||
if status not in VALID_WP_STATUSES:
|
if status not in SUPPORTED_WP_STATUSES:
|
||||||
report.add(Level.FAIL, "frontmatter-status",
|
report.add(Level.FAIL, "frontmatter-status",
|
||||||
f"status must be one of {sorted(VALID_WP_STATUSES)}, got {status!r}", fname)
|
f"status must be one of {sorted(VALID_WP_STATUSES)} "
|
||||||
|
f"(legacy aliases accepted: {sorted(SUPPORTED_WP_STATUSES - VALID_WP_STATUSES)}), "
|
||||||
|
f"got {status!r}", fname)
|
||||||
else:
|
else:
|
||||||
report.add(Level.PASS, "frontmatter-status", f"status={status}", fname)
|
report.add(Level.PASS, "frontmatter-status",
|
||||||
|
f"status={normalize_workstream_status(status)}", fname)
|
||||||
|
|
||||||
# id format
|
# id format
|
||||||
wp_id = str(meta.get("id", ""))
|
wp_id = str(meta.get("id", ""))
|
||||||
@@ -363,7 +377,7 @@ def check_api(api_base: str, metas: list[dict], domain_slug: str | None,
|
|||||||
continue
|
continue
|
||||||
for ws in workstreams:
|
for ws in workstreams:
|
||||||
ws_status = ws.get("status", "")
|
ws_status = ws.get("status", "")
|
||||||
if ws_status in ("completed", "archived"):
|
if normalize_workstream_status(ws_status) in {"finished", "archived"}:
|
||||||
continue
|
continue
|
||||||
ws_id = ws["id"]
|
ws_id = ws["id"]
|
||||||
ws_slug = ws.get("slug", "")
|
ws_slug = ws.get("slug", "")
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ from consistency_check import (
|
|||||||
render_text,
|
render_text,
|
||||||
report_to_dict,
|
report_to_dict,
|
||||||
)
|
)
|
||||||
|
from api.workplan_status import ready_review_status
|
||||||
# _detect_behind_remote and _git_pull are re-exported from consistency_check
|
# _detect_behind_remote and _git_pull are re-exported from consistency_check
|
||||||
# for backward compat; their canonical implementations live in repo_sync.py.
|
# for backward compat; their canonical implementations live in repo_sync.py.
|
||||||
|
|
||||||
@@ -403,17 +404,22 @@ class TestReportToDict:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestNormaliseWorkstreamStatus:
|
class TestNormaliseWorkstreamStatus:
|
||||||
"""FILE_TO_DB_WORKSTREAM_STATUS maps workplan file vocabulary to DB vocabulary.
|
"""Legacy workplan/API vocabulary maps to the canonical lifecycle model."""
|
||||||
|
|
||||||
Workplan files use task-style "done"; the DB workstream API uses "completed".
|
def test_done_maps_to_finished(self):
|
||||||
The C-04 check and fix code must normalise before comparing or PATCHing.
|
assert normalise_workstream_status("done") == "finished"
|
||||||
"""
|
|
||||||
|
|
||||||
def test_done_maps_to_completed(self):
|
def test_completed_maps_to_finished(self):
|
||||||
assert normalise_workstream_status("done") == "completed"
|
assert normalise_workstream_status("completed") == "finished"
|
||||||
|
|
||||||
def test_completed_is_identity(self):
|
def test_accepted_maps_to_finished(self):
|
||||||
assert normalise_workstream_status("completed") == "completed"
|
assert normalise_workstream_status("accepted") == "finished"
|
||||||
|
|
||||||
|
def test_todo_maps_to_ready_by_default(self):
|
||||||
|
assert normalise_workstream_status("todo") == "ready"
|
||||||
|
|
||||||
|
def test_todo_maps_to_active_when_started(self):
|
||||||
|
assert normalise_workstream_status("todo", has_started=True) == "active"
|
||||||
|
|
||||||
def test_active_is_identity(self):
|
def test_active_is_identity(self):
|
||||||
assert normalise_workstream_status("active") == "active"
|
assert normalise_workstream_status("active") == "active"
|
||||||
@@ -428,12 +434,12 @@ class TestNormaliseWorkstreamStatus:
|
|||||||
# Don't crash on unexpected values — return them unchanged
|
# Don't crash on unexpected values — return them unchanged
|
||||||
assert normalise_workstream_status("foobar") == "foobar"
|
assert normalise_workstream_status("foobar") == "foobar"
|
||||||
|
|
||||||
def test_map_constant_covers_done(self):
|
def test_map_constant_covers_legacy_aliases(self):
|
||||||
assert "done" in FILE_TO_DB_WORKSTREAM_STATUS
|
assert FILE_TO_DB_WORKSTREAM_STATUS["done"] == "finished"
|
||||||
assert FILE_TO_DB_WORKSTREAM_STATUS["done"] == "completed"
|
assert FILE_TO_DB_WORKSTREAM_STATUS["completed"] == "finished"
|
||||||
|
|
||||||
def test_c04_no_spurious_drift_when_done_vs_completed(self):
|
def test_c04_no_spurious_drift_when_done_vs_finished(self):
|
||||||
"""done (file) vs completed (DB) must NOT be reported as C-04 drift."""
|
"""done (file) vs finished (DB) must NOT be reported as C-04 drift."""
|
||||||
assert normalise_workstream_status("done") == normalise_workstream_status("completed")
|
assert normalise_workstream_status("done") == normalise_workstream_status("completed")
|
||||||
|
|
||||||
def test_c04_real_drift_still_detected(self):
|
def test_c04_real_drift_still_detected(self):
|
||||||
@@ -441,6 +447,55 @@ class TestNormaliseWorkstreamStatus:
|
|||||||
assert normalise_workstream_status("done") != normalise_workstream_status("active")
|
assert normalise_workstream_status("done") != normalise_workstream_status("active")
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadyReviewStatus:
|
||||||
|
def _repo_with_commit(self, tmp_path):
|
||||||
|
import subprocess
|
||||||
|
repo = tmp_path / "repo"
|
||||||
|
repo.mkdir()
|
||||||
|
subprocess.run(["git", "-C", str(repo), "init"], check=True, capture_output=True)
|
||||||
|
subprocess.run(["git", "-C", str(repo), "config", "user.email", "test@example.invalid"], check=True)
|
||||||
|
subprocess.run(["git", "-C", str(repo), "config", "user.name", "Test"], check=True)
|
||||||
|
tracked = repo / "src" / "app.py"
|
||||||
|
tracked.parent.mkdir()
|
||||||
|
tracked.write_text("print('one')\n", encoding="utf-8")
|
||||||
|
subprocess.run(["git", "-C", str(repo), "add", "."], check=True, capture_output=True)
|
||||||
|
subprocess.run(["git", "-C", str(repo), "commit", "-m", "init"], check=True, capture_output=True)
|
||||||
|
base = subprocess.check_output(["git", "-C", str(repo), "rev-parse", "HEAD"], text=True).strip()
|
||||||
|
return repo, tracked, base
|
||||||
|
|
||||||
|
def test_same_commit_is_current(self, tmp_path):
|
||||||
|
repo, _tracked, base = self._repo_with_commit(tmp_path)
|
||||||
|
|
||||||
|
result = ready_review_status(repo, base)
|
||||||
|
|
||||||
|
assert result.needs_review is False
|
||||||
|
|
||||||
|
def test_changed_context_path_needs_review(self, tmp_path):
|
||||||
|
import subprocess
|
||||||
|
repo, tracked, base = self._repo_with_commit(tmp_path)
|
||||||
|
tracked.write_text("print('two')\n", encoding="utf-8")
|
||||||
|
subprocess.run(["git", "-C", str(repo), "add", "."], check=True, capture_output=True)
|
||||||
|
subprocess.run(["git", "-C", str(repo), "commit", "-m", "change app"], check=True, capture_output=True)
|
||||||
|
|
||||||
|
result = ready_review_status(repo, base, ["src"])
|
||||||
|
|
||||||
|
assert result.needs_review is True
|
||||||
|
assert result.changed_paths == ("src/app.py",)
|
||||||
|
|
||||||
|
def test_unrelated_context_path_does_not_need_review(self, tmp_path):
|
||||||
|
import subprocess
|
||||||
|
repo, _tracked, base = self._repo_with_commit(tmp_path)
|
||||||
|
docs = repo / "docs" / "note.md"
|
||||||
|
docs.parent.mkdir()
|
||||||
|
docs.write_text("note\n", encoding="utf-8")
|
||||||
|
subprocess.run(["git", "-C", str(repo), "add", "."], check=True, capture_output=True)
|
||||||
|
subprocess.run(["git", "-C", str(repo), "commit", "-m", "docs"], check=True, capture_output=True)
|
||||||
|
|
||||||
|
result = ready_review_status(repo, base, ["src"])
|
||||||
|
|
||||||
|
assert result.needs_review is False
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# STATUS_ORDER / no-regress rule (T01 / C-15)
|
# STATUS_ORDER / no-regress rule (T01 / C-15)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -109,9 +109,18 @@ class TestWorkstreams:
|
|||||||
topic = await _create_topic(client)
|
topic = await _create_topic(client)
|
||||||
ws = await _create_workstream(client, topic["id"])
|
ws = await _create_workstream(client, topic["id"])
|
||||||
|
|
||||||
|
r = await client.patch(f"/workstreams/{ws['id']}", json={"status": "finished"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["status"] == "finished"
|
||||||
|
|
||||||
|
async def test_legacy_completed_status_is_normalized(self, client):
|
||||||
|
await _create_domain(client)
|
||||||
|
topic = await _create_topic(client)
|
||||||
|
ws = await _create_workstream(client, topic["id"])
|
||||||
|
|
||||||
r = await client.patch(f"/workstreams/{ws['id']}", json={"status": "completed"})
|
r = await client.patch(f"/workstreams/{ws['id']}", json={"status": "completed"})
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert r.json()["status"] == "completed"
|
assert r.json()["status"] == "finished"
|
||||||
|
|
||||||
async def test_filter_by_owner(self, client):
|
async def test_filter_by_owner(self, client):
|
||||||
await _create_domain(client)
|
await _create_domain(client)
|
||||||
@@ -321,11 +330,11 @@ class TestFlowEndpoints:
|
|||||||
|
|
||||||
r = await client.get(f"/flows/workstream/{ws['id']}")
|
r = await client.get(f"/flows/workstream/{ws['id']}")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert "completed" in r.json()["reachable"]
|
assert "finished" in r.json()["reachable"]
|
||||||
|
|
||||||
r = await client.post(f"/flows/workstream/{ws['id']}/advance/completed")
|
r = await client.post(f"/flows/workstream/{ws['id']}/advance/finished")
|
||||||
assert r.status_code == 200
|
assert r.status_code == 200
|
||||||
assert r.json()["current_workstation"] == "completed"
|
assert r.json()["current_workstation"] == "finished"
|
||||||
|
|
||||||
r = await client.get(f"/workstreams/{ws['id']}")
|
r = await client.get(f"/workstreams/{ws['id']}")
|
||||||
assert r.json()["status"] == "completed"
|
assert r.json()["status"] == "finished"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ def test_all_assertions_satisfied_reports_reachable_workstations():
|
|||||||
workstations=[
|
workstations=[
|
||||||
WorkstationDef(name="active"),
|
WorkstationDef(name="active"),
|
||||||
WorkstationDef(
|
WorkstationDef(
|
||||||
name="completed",
|
name="finished",
|
||||||
entry_assertions=[
|
entry_assertions=[
|
||||||
AssertionDef(
|
AssertionDef(
|
||||||
id="tasks.all_done",
|
id="tasks.all_done",
|
||||||
@@ -31,7 +31,7 @@ def test_all_assertions_satisfied_reports_reachable_workstations():
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert result.exit_blocked is False
|
assert result.exit_blocked is False
|
||||||
assert result.reachable == ["active", "completed"]
|
assert result.reachable == ["active", "finished"]
|
||||||
assert result.unreachable == []
|
assert result.unreachable == []
|
||||||
|
|
||||||
|
|
||||||
@@ -135,18 +135,18 @@ def test_yaml_flow_definitions_load_and_evaluate_representative_entities():
|
|||||||
workstream_result = FlowEngine(
|
workstream_result = FlowEngine(
|
||||||
custom_ops={
|
custom_ops={
|
||||||
"dependencies.any_incomplete": lambda assertion, obj, values: any(
|
"dependencies.any_incomplete": lambda assertion, obj, values: any(
|
||||||
value != assertion.value for value in values
|
value not in assertion.value for value in values
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
).evaluate(
|
).evaluate(
|
||||||
{
|
{
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"tasks": [{"status": "done"}],
|
"tasks": [{"status": "done"}],
|
||||||
"dependencies": [{"workstation": "completed"}],
|
"dependencies": [{"workstation": "finished"}],
|
||||||
},
|
},
|
||||||
flows["workstream"],
|
flows["workstream"],
|
||||||
)
|
)
|
||||||
assert "completed" in workstream_result.reachable
|
assert "finished" in workstream_result.reachable
|
||||||
assert "blocked" in [item.workstation for item in workstream_result.unreachable]
|
assert "blocked" in [item.workstation for item in workstream_result.unreachable]
|
||||||
|
|
||||||
task_result = FlowEngine().evaluate(
|
task_result = FlowEngine().evaluate(
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ type: workplan
|
|||||||
title: "Workplan State Model Cleanup"
|
title: "Workplan State Model Cleanup"
|
||||||
domain: custodian
|
domain: custodian
|
||||||
repo: state-hub
|
repo: state-hub
|
||||||
status: active
|
status: finished
|
||||||
owner: custodian
|
owner: custodian
|
||||||
topic_slug: custodian
|
topic_slug: custodian
|
||||||
planning_priority: high
|
planning_priority: high
|
||||||
@@ -89,7 +89,7 @@ reviewed as the design source for this workplan.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: CUST-WP-0042-T02
|
id: CUST-WP-0042-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "9be18f7e-1db1-464b-8c65-bf64ae3462e8"
|
state_hub_task_id: "9be18f7e-1db1-464b-8c65-bf64ae3462e8"
|
||||||
```
|
```
|
||||||
@@ -105,7 +105,7 @@ string sets in multiple files.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: CUST-WP-0042-T03
|
id: CUST-WP-0042-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "1d9964ce-7b30-49d3-a4e3-6d5b3ef8d684"
|
state_hub_task_id: "1d9964ce-7b30-49d3-a4e3-6d5b3ef8d684"
|
||||||
```
|
```
|
||||||
@@ -124,7 +124,7 @@ normalized without data loss.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: CUST-WP-0042-T04
|
id: CUST-WP-0042-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "c80df776-5d45-419d-a92c-59e3b77d9798"
|
state_hub_task_id: "c80df776-5d45-419d-a92c-59e3b77d9798"
|
||||||
```
|
```
|
||||||
@@ -140,7 +140,7 @@ values only when they intentionally edit files.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: CUST-WP-0042-T05
|
id: CUST-WP-0042-T05
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "97ebc285-3998-4f17-bf43-c4f803cf1e7b"
|
state_hub_task_id: "97ebc285-3998-4f17-bf43-c4f803cf1e7b"
|
||||||
```
|
```
|
||||||
@@ -163,7 +163,7 @@ an explicit flag.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: CUST-WP-0042-T06
|
id: CUST-WP-0042-T06
|
||||||
status: todo
|
status: done
|
||||||
priority: high
|
priority: high
|
||||||
state_hub_task_id: "210fcff3-e3e9-4f8a-9687-c5de83ace465"
|
state_hub_task_id: "210fcff3-e3e9-4f8a-9687-c5de83ace465"
|
||||||
```
|
```
|
||||||
@@ -178,7 +178,7 @@ Done when the overview no longer uses `accepted`, `oldies`, or derived
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: CUST-WP-0042-T07
|
id: CUST-WP-0042-T07
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "8ecceebb-0471-4541-96f9-c9f98df12f84"
|
state_hub_task_id: "8ecceebb-0471-4541-96f9-c9f98df12f84"
|
||||||
```
|
```
|
||||||
@@ -193,7 +193,7 @@ API and consistency engine.
|
|||||||
|
|
||||||
```task
|
```task
|
||||||
id: CUST-WP-0042-T08
|
id: CUST-WP-0042-T08
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
state_hub_task_id: "7dd1f27d-4a4a-4c78-8b91-6103688559a9"
|
state_hub_task_id: "7dd1f27d-4a4a-4c78-8b91-6103688559a9"
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user