Complete workplan state model cleanup

This commit is contained in:
2026-05-18 01:31:36 +02:00
parent 98b2cb6484
commit d6522a9a40
42 changed files with 789 additions and 310 deletions

View File

@@ -141,7 +141,7 @@ decisions (FK: topic_id, workstream_id — at least one required)
| Enum | Values |
|------|--------|
| `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_priority` | `low` · `medium` · `high` · `critical` |
| `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": "...",
"totals": {
"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 },
"decisions": { "open": 1, "resolved": 0, "escalated": 0, "total": 1 }
},

View File

@@ -51,7 +51,10 @@ def _dependencies_any_incomplete(
obj: dict[str, Any],
values: list[Any],
) -> 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]:

View File

@@ -19,6 +19,7 @@ from api.models.contribution import Contribution
from api.models.task import Task
from api.models.workstream import Workstream
from api.models.workstream_dependency import WorkstreamDependency
from api.workplan_status import normalize_workstream_status
router = APIRouter(prefix="/flows", tags=["flows"])
@@ -104,11 +105,12 @@ async def _flow_object(
) -> dict[str, Any]:
entity = await _entity(entity_type, entity_id, session)
status = _value(entity.status)
current_status = normalize_workstream_status(status) if entity_type == "workstream" else status
obj: dict[str, Any] = {
"id": str(entity.id),
"status": status,
"workstation": status,
"previous_workstation": status,
"status": current_status,
"workstation": current_status,
"previous_workstation": current_status,
}
if entity_type == "workstream":
@@ -127,7 +129,7 @@ async def _flow_object(
select(Workstream).where(Workstream.id.in_(dependency_ids))
)).scalars().all())
dependency_workstations = [
{"id": str(ws.id), "workstation": ws.status}
{"id": str(ws.id), "workstation": normalize_workstream_status(ws.status)}
for ws in dep_ws
]
obj.update({

View File

@@ -38,6 +38,11 @@ from api.schemas.task import TaskRead
from api.schemas.topic import TopicRead, TopicWithWorkstreams
from api.schemas.workstream import WorkstreamRead, WorkstreamWithTaskCounts, WorkstreamWithDeps
from api.schemas.workstream_dependency import WorkstreamDepStub
from api.workplan_status import (
CLOSED_WORKSTREAM_STATUSES,
OPEN_WORKSTREAM_STATUSES,
normalize_workstream_status,
)
from task_flow_engine import FlowEngine
router = APIRouter(prefix="/state", tags=["state"])
@@ -119,7 +124,7 @@ async def get_summary(
open_ws_rows = await session.execute(
select(Workstream)
.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)
)
open_ws = list(open_ws_rows.scalars().all())
@@ -211,7 +216,7 @@ async def get_summary(
"workstation": w.status,
"tasks": [{"status": status} for status in task_statuses_per_ws.get(w.id, [])],
"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
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()),
),
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"),
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),
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(
select(Topic.domain_id, func.count(Workstream.id))
.join(Workstream, Workstream.topic_id == Topic.id)
.where(Workstream.status == "active")
.where(Workstream.status.in_(["active", "blocked"]))
.group_by(Topic.domain_id)
):
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(
select(Workstream)
.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)
)
open_ws = list(open_ws_rows.scalars().all())
@@ -488,7 +500,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
Two signal sources:
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] = []
seen_task_ids: set = set()
@@ -575,8 +587,11 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
ready_from_ws_ids = [
from_ws_id
for from_ws_id, to_ws_ids in dep_map.items()
if ws_info.get(from_ws_id, {}).get("status") in ("active", "blocked")
and all(ws_info.get(to_id, {}).get("status") == "completed" for to_id in to_ws_ids)
if normalize_workstream_status(ws_info.get(from_ws_id, {}).get("status")) in OPEN_WORKSTREAM_STATUSES
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 = {}
@@ -613,7 +628,7 @@ async def _derive_next_steps(session: AsyncSession) -> list[NextStep]:
task_id=task.id,
task_title=task.title,
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"
),
))
@@ -650,7 +665,7 @@ async def get_next_steps(session: AsyncSession = Depends(get_session)) -> list[N
Returns suggestions based on:
- 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)

View File

@@ -5,6 +5,7 @@ import time
from pathlib import Path
from typing import Any
import yaml
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -16,9 +17,13 @@ from api.models.workstream import Workstream
from api.schemas.workstream import (
WorkstreamCreate,
WorkstreamRead,
WorkstreamStatus,
WorkstreamUpdate,
)
from api.workplan_status import (
is_supported_workstream_status,
normalize_workstream_status,
ready_review_status,
)
router = APIRouter(prefix="/workstreams", tags=["workstreams"])
@@ -53,17 +58,10 @@ def _frontmatter(path: Path) -> dict[str, Any]:
if end == -1:
return {}
data: dict[str, Any] = {}
for raw_line in text[4:end].splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or ":" not in line:
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
try:
return yaml.safe_load(text[4:end].strip()) or {}
except yaml.YAMLError:
return {}
@router.get("/", response_model=list[WorkstreamRead])
@@ -71,7 +69,7 @@ async def list_workstreams(
topic_id: uuid.UUID | None = None,
repo_id: uuid.UUID | None = None,
repo_goal_id: uuid.UUID | None = None,
status: WorkstreamStatus | None = None,
status: str | None = None,
owner: str | None = None,
slug: str | None = None,
session: AsyncSession = Depends(get_session),
@@ -84,7 +82,10 @@ async def list_workstreams(
if repo_goal_id:
q = q.where(Workstream.repo_goal_id == repo_goal_id)
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:
q = q.where(Workstream.owner == owner)
if slug:
@@ -127,11 +128,24 @@ async def workplan_index(
workstream_id = data.get("state_hub_workstream_id")
if not workstream_id:
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)] = {
"filename": path.name,
"relative_path": str(path.relative_to(root)),
"repo_slug": repo.slug,
"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_AT = time.monotonic()
@@ -176,7 +190,7 @@ async def update_workstream(
await session.commit()
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"
envelope = EventEnvelope.new(
subject,

View File

@@ -19,9 +19,12 @@ class TopicTotals(BaseModel):
class WorkstreamTotals(BaseModel):
proposed: int = 0
ready: int = 0
active: int = 0
blocked: int = 0
completed: int = 0
backlog: int = 0
finished: int = 0
archived: int = 0
total: int = 0

View File

@@ -2,14 +2,30 @@ import uuid
from datetime import date, datetime
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.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
slug: str
title: str
@@ -23,7 +39,7 @@ class WorkstreamCreate(BaseModel):
repo_goal_id: uuid.UUID | None = None
class WorkstreamUpdate(BaseModel):
class WorkstreamUpdate(WorkstreamStatusMixin):
title: str | None = None
description: str | None = None
status: WorkstreamStatus | None = None
@@ -35,7 +51,7 @@ class WorkstreamUpdate(BaseModel):
repo_goal_id: uuid.UUID | None = None
class WorkstreamRead(BaseModel):
class WorkstreamRead(WorkstreamStatusMixin):
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
topic_id: uuid.UUID

169
api/workplan_status.py Normal file
View 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

View File

@@ -131,8 +131,12 @@ function _ensureStyles() {
/* ── Style maps ──────────────────────────────────────────────────────────── */
const _STATUS_STYLE = {
proposed: "background:#fef3c7;color:#92400e",
ready: "background:#e0f2fe;color:#075985",
active: "background:#d4edda;color:#155724",
blocked: "background:#f8d7da;color:#721c24",
backlog: "background:#f1f5f9;color:#64748b",
finished: "background:#cce5ff;color:#004085",
completed: "background:#cce5ff;color:#004085",
archived: "background:#e2e3e5;color:#383d41",
open: "background:#dbeafe;color:#1e40af",

View 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");
}

View File

@@ -19,7 +19,16 @@ except urllib.error.URLError as e:
"generated_at": None,
"totals": {
"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},
"decisions": {"open": 0, "resolved": 0, "escalated": 0, "superseded": 0, "total": 0},
},

View File

@@ -4,6 +4,7 @@ title: Dependencies
```js
import {API, POLL_HEAVY, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
import {normalizeWorkstreamStatus} from "./components/workplan-status.js";
```
```js
@@ -29,6 +30,7 @@ const depState = (async function*() {
const repoMap = Object.fromEntries(repoList.map(r => [r.id, r]));
wsMap = Object.fromEntries(wsList.map(w => [w.id, {
...w,
status: normalizeWorkstreamStatus(w.status),
// Prefer repo→domain (GEMS primary); fall back to topic→domain
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);
```
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.
```js
@@ -152,9 +154,12 @@ if (edges.length === 0) {
.dep-title { font-weight: 500; max-width: 22rem; }
.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-proposed { background: #fef3c7; color: #92400e; }
.dep-status-ready { background: #e0f2fe; color: #075985; }
.dep-status-active { background: #dcfce7; color: #166534; }
.dep-status-completed { background: #f1f5f9; color: #475569; }
.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; }
.dim { color: gray; font-style: italic; }
</style>

View File

@@ -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.
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
Domains are sorted by most recent workstream activity (most active domain at

View File

@@ -4,7 +4,7 @@ title: 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
state before they can fully proceed.
@@ -13,7 +13,7 @@ state before they can fully proceed.
## What is a dependency edge?
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
library must reach a stable release before downstream domains can build on it.
@@ -36,7 +36,7 @@ Each row shows:
| **→** | Direction arrow |
| **Blocked-by domain** | Domain of the prerequisite workstream |
| **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) |
---

View File

@@ -48,7 +48,7 @@ Four metric cards:
| 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 |
| **Blocked Tasks** | Click to expand the list with blocking reasons |
| **Events Today** | Progress events created on today's date |

View File

@@ -39,7 +39,7 @@ These types are used by the State Hub's built-in write operations:
| Type | When emitted |
|---|---|
| `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 |
| `task_created` | A new task was added to a workstream |
| `task_status_changed` | Task moved to todo / in_progress / blocked / done / cancelled |

View File

@@ -92,7 +92,7 @@ cd ~/ralph-workplan && ./install.sh --uninstall
---
id: WP-0001
title: "Build a thing"
status: active
status: ready
---
Optional description.
@@ -120,7 +120,7 @@ priority: medium
|-------|--------|-------------|
| `id` | string | Unique workplan identifier |
| `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:**
@@ -144,7 +144,7 @@ status: in_progress → status: done (when verified complete)
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.

View File

@@ -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**
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
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
6. Mark each task `done` in the hub as completed
7. Mark the Repo Integration workstream `completed`
6. Mark each task `done` in the hub
7. Mark the Repo Integration workstream `finished`
8. Log a progress event summarising the integration
The agent should resolve each task independently and in order. It does not

View File

@@ -73,13 +73,13 @@ Detects concentration of blocking power. High SPR means one delay propagates wid
### 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
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
reached `completed` or `archived`.
reached `finished` or `archived`.
| PEP | Warning |
|---|---|
@@ -147,7 +147,7 @@ The domain breakdown is shown when at least two domains have active workstreams.
| Symptom | Action |
|---|---|
| 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 |
| Low PEP | Complete prerequisite workstreams or re-sequence work |
| High CDDR | Refactor cross-domain dependencies into shared contracts or invert the dependency |

View File

@@ -143,11 +143,11 @@ High SPR indicates fragile structure where one delay propagates widely.
A workstream is eligible if:
* Status = active
* All dependencies are completed
* Status = ready or active
* 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
By combining graph properties with status information, WHI enables proactive management of coordination complexity.

View File

@@ -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
stored `status` field keeps the current workstation label, while the
task-flow engine derives which other workstations are reachable and which exit
assertions are blocking movement. The dashboard "Workstreams by Domain" chart
exposes stored and derived states as selectable filters so attention can be
directed to the right workstreams at the right time.
A workstream is an information object that occupies a named lifecycle state.
The stored `status` field keeps that state, while the task-flow engine derives
which other states are reachable and which exit assertions are blocking
movement. Dashboard health filters such as `needs_review` and `stalled` are
derived labels, not stored lifecycle values.
---
## Core workstations
## Stored Lifecycle States
These are the primary workstations used by State Hub workstreams:
| Workstation | Source | Meaning |
| State | Source | Meaning |
|---|---|---|
| **active** | DB `status = active` | Work is in progress or ready to start |
| **finished** | Derived — no open tasks | All tasks are done, but no explicit review has taken place yet |
| **accepted** | DB `status = completed` | Custodian and human have reviewed the workstream, quality checks passed, and it is formally signed off |
| **proposed** | DB `status = proposed` | Plan exists, but must be reviewed against current repo state |
| **ready** | DB `status = ready` | Plan has been reviewed and is ready to execute |
| **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**.
`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.
Normal progression:
```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>/ \
-H "Content-Type: application/json" \
-d '{"status": "completed"}'
-d '{"status": "finished"}'
```
---
## 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.*
Workstreams are never hard-deleted. Use `finished` for completed
implementation and `archived` for historical records outside normal planning.

View File

@@ -20,14 +20,17 @@ as filters change.
| 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 |
| **completed** | Formally accepted after custodian review (shown as **accepted** in the overview chart) |
| **archived** | Closed without completion; no longer relevant |
| **backlog** | Intentionally parked for later |
| **finished** | Implementation is complete |
| **archived** | Closed historical record |
See [Workstream Lifecycle](/docs/workstream-lifecycle) for the full task-flow
model including derived states (finished, stalled, oldies) and assertion-based
blocking.
model including derived health labels (`needs_review`, `stalled`) and
assertion-based blocking.
---
@@ -91,7 +94,7 @@ create_workstream(
topic_id = "<uuid>",
title = "Build user authentication",
description = "JWT-based auth, refresh tokens, middleware",
status = "active",
status = "ready",
owner = "human",
due_date = "2026-04-01"
)
@@ -102,7 +105,7 @@ Via REST:
```bash
curl -X POST http://127.0.0.1:8000/workstreams/ \
-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>")
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

View File

@@ -4,6 +4,13 @@ title: Overview
```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
@@ -52,11 +59,13 @@ const pageState = (async function*() {
const workplan = workplanMap[w.id] ?? {};
return {
...w,
status: normalizeWorkstreamStatus(w.status),
domain: repo?.domain_slug ?? topic?.domain_slug ?? "unknown",
repo_label: repo?.slug ?? workplan.repo_slug ?? "unassigned",
workplan_filename: workplan.filename ?? null,
workplan_relative_path: workplan.relative_path ?? null,
workplan_archived: workplan.archived ?? false,
health_labels: workplan.health_labels ?? [],
href: `./workstreams/${w.id}`,
...(counts[w.id] ?? {done: 0, in_progress: 0, blocked: 0, todo: 0, total: 0}),
};
@@ -126,13 +135,18 @@ display(html`<div class="warning" style="display:${summary.error ? '' : 'none'}"
// view() is the idiomatic Observable Framework reactive input:
// it displays the element AND returns a reactive value that re-runs dependent blocks.
const _chartMode = view(html`<select class="ws-mode-select">
<optgroup label="By Status">
<option value="active" selected>active</option>
<option value="accepted">accepted</option>
<option value="finished">finished</option>
<optgroup label="Lifecycle">
<option value="ready" selected>ready</option>
<option value="active">active</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="oldies">oldies</option>
</optgroup>
<optgroup label="Recently Changed">
<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";
// ── Filter workstreams by selected mode ───────────────────────────────────────
// "active" matches the DB status field directly.
// "accepted" = DB status "completed" (explicitly reviewed and signed off).
// "finished" = no open tasks remaining (derived from task counts).
// "blocked" = has ≥1 blocked task; "stalled" / "oldies" = activity-based.
// Lifecycle modes match stored canonical status values.
// Health modes are derived labels; they are not stored lifecycle states.
// 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) {
const now = new Date();
@@ -175,27 +188,11 @@ function _timeCutoff(mode) {
const _chartWsFiltered = (
_STATUS_MODES.has(_chartMode)
? wsAll.filter(w => w.status === _chartMode)
: _chartMode === "accepted"
? wsAll.filter(w => w.status === "completed")
: _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)
? wsAll.filter(w => normalizeWorkstreamStatus(w.status) === _chartMode)
: _chartMode === "needs_review"
? wsAll.filter(needsReviewWorkstream)
: _chartMode === "stalled"
? wsAll.filter(w => {
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;
})
? wsAll.filter(isStalledWorkstream)
: (() => {
const since = _timeCutoff(_chartMode);
return wsAll.filter(w =>
@@ -215,9 +212,9 @@ const chartWs = [..._chartWsFiltered].sort((a, b) => {
});
// ── 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.
const _isTimeBased = !_STATUS_MODES.has(_chartMode);
function _wsWeight(s) { return (s === "accepted" || s === "blocked" || s === "stalled") ? "bold" : "normal"; }
// Color is NOT used for status — avoids green-on-green when finished bars fill the row.
const _isTimeBased = !_STATUS_MODES.has(_chartMode) && !_HEALTH_MODES.has(_chartMode);
function _wsWeight(s) { return (isClosedWorkstream(s) || normalizeWorkstreamStatus(s) === "blocked") ? "bold" : "normal"; }
// ── y-axis: domain/repo label for first workstream per repository only ────────
const _yLabels = {};
@@ -251,10 +248,15 @@ function _wsTitle(d) {
// ── Render ────────────────────────────────────────────────────────────────────
if (chartWs.length === 0) {
const _emptyMsg = {
active: "No active workstreams.", accepted: "No accepted workstreams.",
finished: "No finished workstreams.", blocked: "No blocked workstreams.",
proposed: "No proposed 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.",
oldies: "No oldies — all older workstreams have at least one task done.",
"1h": "No workstreams changed in the last hour.",
"1d": "No workstreams changed in the last 24 hours.",
"7d": "No workstreams changed in the last 7 days.",

View File

@@ -4,6 +4,7 @@ title: Workstreams
```js
import {API, POLL_HEAVY, apiFetch, pollDelay, waitForVisible} from "./components/config.js";
import {WORKSTREAM_STATUSES, isClosedWorkstream, normalizeWorkstreamStatus} from "./components/workplan-status.js";
```
```js
@@ -27,6 +28,7 @@ const wsState = (async function*() {
const repoMap = Object.fromEntries(repoList.map(r => [r.id, r]));
data = wsList.map(w => ({
...w,
status: normalizeWorkstreamStatus(w.status),
domain: repoMap[w.repo_id]?.domain_slug ?? topicMap[w.topic_id]?.domain_slug ?? "unknown",
topic_title: topicMap[w.topic_id]?.title ?? "—",
}));
@@ -50,7 +52,7 @@ const _ts = wsState.ts;
```js
// ── Workstream Health Index (WHI) ────────────────────────────────────────────
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 _allEdges = openWs.flatMap(w => w.depends_on.map(d => ({from: w.id, to: d.workstream_id})));
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
const _inbound = {};
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
? (Object.keys(_inbound).length > 0 ? Math.max(...Object.values(_inbound)) : 0) / _openCount
: 0;
// Parallel Execution Potential — active workstreams with all deps completed
// Parallel Execution Potential — ready/active workstreams with all deps finished
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;
// 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 br = oc > 0 ? nodes.filter(w => w.status === "blocked").length / oc : 0;
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);
return intraDeps.every(d => _completedIds.has(d.workstream_id));
return intraDeps.every(d => _closedIds.has(d.workstream_id));
}).length / oc : 0;
const inb = {};
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
? (await _domainsResp.json()).map(d => d.slug)
: ["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
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-active { background: #d4edda; color: #155724; }
.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-on { color: #1a5276; }
.dep-block { color: #6e2f00; }

View File

@@ -16,7 +16,7 @@ keeps the underlying scripts; only the *scheduling* moves.
| # | 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 |
| 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 |
Honourable mentions (not currently scheduled, on-demand only — listed for
@@ -79,7 +79,7 @@ Notes:
id: the-custodian.state-hub-stale-task-cleanup
description: |
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.
trigger:
trigger_type: cron

View File

@@ -42,7 +42,7 @@ those publishers from colliding on the same `{noun}.{verb}` shape.
| 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.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.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` |

View File

@@ -129,10 +129,10 @@ blocking_assertions:
passed: false
reason: "Expected all values at tasks.*.status to be in ['done', 'cancelled']; got ['done', 'todo']."
reachable:
- todo
- ready
- active
unreachable:
- workstation: completed
- workstation: finished
blocking:
id: tasks.all_done
passed: false
@@ -151,9 +151,9 @@ Schema:
### 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
all dependency workstreams have reached `completed`.
all dependency workstreams have reached `finished` or `archived`.
### Tasks

View File

@@ -14,7 +14,7 @@ type: workplan
title: "Short Title"
domain: custodian
repo: state-hub
status: active
status: proposed
owner: custodian
topic_slug: custodian
```
@@ -22,3 +22,14 @@ topic_slug: custodian
During extraction, legacy `CUST-WP-*` plans may be bridged or migrated with
their existing `state_hub_workstream_id` values. Write files first, then run
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.

View File

@@ -1,8 +1,12 @@
id: custodian.workstream.v1
entity_type: workstream
workstations:
- name: todo
description: Planned but not yet active.
- name: proposed
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: []
exit_assertions: []
- name: active
@@ -12,23 +16,33 @@ workstations:
- id: dependencies.all_complete
target: dependencies.*.workstation
op: all_eq
value: completed
description: Dependency workstreams have reached completed.
value:
- finished
- archived
description: Dependency workstreams have reached a closed state.
- name: blocked
description: Work is blocked by incomplete dependencies or missing input.
entry_assertions:
- id: dependencies.any_incomplete
target: dependencies.*.workstation
op: custom
value: completed
description: At least one dependency is not completed.
value:
- finished
- archived
description: At least one dependency is not finished or archived.
exit_assertions:
- id: dependencies.all_complete
target: dependencies.*.workstation
op: all_eq
value: completed
description: All dependency workstreams have reached completed.
- name: completed
value:
- finished
- 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.
entry_assertions:
- id: tasks.all_done
@@ -40,6 +54,6 @@ workstations:
description: All child tasks are done or cancelled.
exit_assertions: []
- 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: []
exit_assertions: []

View File

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

View File

@@ -757,7 +757,7 @@ def update_workstream_status(workstream_id: str, status: str) -> str:
Args:
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})
_post("/progress", {
@@ -789,7 +789,7 @@ def update_workstream(
owner: new owner (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)
status: active | blocked | completed | archived (optional)
status: proposed | ready | active | blocked | backlog | finished | archived (optional)
"""
payload: dict = {}
if title is not None:
@@ -818,7 +818,7 @@ def get_next_steps() -> str:
Returns suggestions based on:
- 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
message. The hub surfaces *what* and *where* — the domain owns *how*.

View File

@@ -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')")

View File

@@ -1,6 +1,6 @@
#!/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 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`
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:
from api.events import EventEnvelope, publish_event, shutdown_publisher
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"
STALE_STATUSES = {"todo", "in_progress", "blocked"}
CLOSED_WS_STATUS = {"completed", "archived"}
CLOSED_WS_STATUS = set(CLOSED_WORKSTREAM_STATUSES)
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)
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 = [
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.")
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 = []
errors = []
@@ -150,7 +156,7 @@ def main() -> int:
summary = (
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 = {
"cancelled_count": len(cancelled),

View File

@@ -12,7 +12,7 @@ Checks:
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-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-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
@@ -51,6 +51,20 @@ from datetime import datetime
from pathlib import Path
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:
import yaml as _yaml
_HAS_YAML = True
@@ -71,19 +85,15 @@ except ImportError:
_TASK_BLOCK_RE = re.compile(r"```task\s*\n(.*?)\n```", re.DOTALL)
_HEADING_RE = re.compile(r"^#{1,4}\s+(.+?)$", re.MULTILINE)
_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_PRIORITIES = {"low", "medium", "high", "critical"}
VALID_DEP_RELATIONSHIPS = {"blocks", "starts_after", "informs", "soft_dependency"}
DEFAULT_REMOTE_ALL_MAX_SECONDS = int(os.environ.get("CONSISTENCY_REMOTE_ALL_MAX_SECONDS", "300"))
# Workplan files use task-style vocabulary ("done"); the DB workstream API uses
# "completed". This map translates file values to DB values before comparison
# 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
}
# Legacy file/API aliases translated before comparison and PATCHing.
FILE_TO_DB_WORKSTREAM_STATUS: dict[str, str] = dict(LEGACY_WORKSTREAM_STATUS_ALIASES)
# Ordinal ranking for task statuses used by the no-regress rule (T01/C-15).
# blocked and in_progress share rank 1 — both are "in flight".
@@ -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."""
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:
@@ -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_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(
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_value=file_status,
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
# 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", "")
normalised_file_status = normalise_workstream_status(file_status)
if file_status and db_status and normalised_file_status != db_status:
normalised_db_status = normalise_workstream_status(db_status)
if file_status and db_status and normalised_file_status != normalised_db_status:
report.add(
severity="WARN", check_id="C-04",
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
db_title = ws.get("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
if isinstance(db_tasks, list):
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:
if db_t["id"] not in file_task_sh_ids:
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
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 = [
t for t in db_tasks
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={
"ws_id": ws_id,
"field": "status",
"value": "completed",
"value": "finished",
},
)
@@ -963,26 +996,27 @@ def _check_orphan_db(
for ws in all_ws:
ws_id = ws["id"]
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
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
ws_slug = ws.get("slug", "")
if ws_status == "active":
if normalised_status not in CLOSED_WORKSTREAM_STATUSES:
report.add(
severity="FAIL", check_id="C-07",
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"
),
db_id=ws_id,
fixable=False,
)
elif ws_status in ("completed", "archived"):
elif normalised_status in CLOSED_WORKSTREAM_STATUSES:
report.add(
severity="INFO", check_id="C-08",
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"
),
db_id=ws_id,
@@ -1019,9 +1053,11 @@ def _check_ghost_duplicates(
topic_ids.add(ws["topic_id"])
for topic_id in topic_ids:
topic_ws = _api_get(api_base, "/workstreams", {"topic_id": topic_id, "status": "active"})
if not isinstance(topic_ws, list):
continue
topic_ws: list[dict] = []
for status in OPEN_WORKSTREAM_STATUSES:
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:
ws_id = ws["id"]
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 = ""
# Resolve domain slug: prefer active workstreams, fall back to any workstream
# so that a fully-completed repo doesn't degrade to "(unknown)".
workstreams = _api_get(api_base, "/workstreams", {"repo_id": repo_id, "status": "active"}) or []
_ws_for_domain = workstreams if (isinstance(workstreams, list) and workstreams) else []
# so that a fully-finished repo doesn't degrade to "(unknown)".
workstreams: list[dict] = []
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:
all_ws = _api_get(api_base, "/workstreams", {"repo_id": repo_id}) or []
_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()
title = str(meta.get("title", "")).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"
# Find topic_id for this domain
@@ -1500,7 +1541,7 @@ def fix_repo(
t_id = str(task.get("id", "")).strip()
# Skip creating tasks for finished workstreams — the workstream is
# 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(
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:
# 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.
_BACKGROUND_CHECKS: frozenset[str] = frozenset({"C-08"})
@@ -1707,7 +1748,7 @@ def archive_closed_workplans(
) -> list[str]:
"""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.
"""
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}:
continue
status = normalise_workstream_status(str(meta.get("status", "")).strip())
if status not in ("completed", "archived"):
if status not in CLOSED_WORKSTREAM_STATUSES:
continue
tasks = get_tasks_from_workplan(meta, body)
open_tasks = [

View File

@@ -82,7 +82,7 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/<task_id>" \
**Start:**
1. `cat .custodian-brief.md` — domain goal and open workstreams (offline-safe)
2. Check inbox: `GET /messages/?to_agent={REPO_SLUG}&unread_only=true`; mark read
3. Scan workplans: `ls workplans/` — note `status: 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`
**During work:**
@@ -108,7 +108,7 @@ read/cache/index layer that rebuilds from files.
**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
the completion/archive date; the frontmatter `id` does not change.
@@ -126,7 +126,7 @@ type: workplan
title: "..."
domain: {DOMAIN}
repo: {REPO_SLUG}
status: active | done
status: proposed | ready | active | blocked | backlog | finished | archived
owner: codex
topic_slug: ...
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):
```

View File

@@ -25,7 +25,8 @@ requests before proceeding.
```bash
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**

View File

@@ -5,6 +5,12 @@ ID prefix: `{WP_PREFIX}`
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
prefix: `YYMMDD-{REPO_SLUG}-WP-NNNN-<slug>.md`. The frontmatter id remains
unchanged; the prefix is only for quick visual reference.

View File

@@ -40,6 +40,16 @@ from dataclasses import dataclass, field
from pathlib import Path
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:
import yaml as _yaml
_HAS_YAML = True
@@ -58,7 +68,8 @@ except ImportError:
# ---------------------------------------------------------------------------
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_PRIORITIES = {"low", "medium", "high", "critical"}
@@ -198,11 +209,14 @@ def _check_workplan_file(wp_file: Path, report: Report) -> dict | None:
# 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",
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:
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
wp_id = str(meta.get("id", ""))
@@ -363,7 +377,7 @@ def check_api(api_base: str, metas: list[dict], domain_slug: str | None,
continue
for ws in workstreams:
ws_status = ws.get("status", "")
if ws_status in ("completed", "archived"):
if normalize_workstream_status(ws_status) in {"finished", "archived"}:
continue
ws_id = ws["id"]
ws_slug = ws.get("slug", "")

View File

@@ -40,6 +40,7 @@ from consistency_check import (
render_text,
report_to_dict,
)
from api.workplan_status import ready_review_status
# _detect_behind_remote and _git_pull are re-exported from consistency_check
# for backward compat; their canonical implementations live in repo_sync.py.
@@ -403,17 +404,22 @@ class TestReportToDict:
# ---------------------------------------------------------------------------
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".
The C-04 check and fix code must normalise before comparing or PATCHing.
"""
def test_done_maps_to_finished(self):
assert normalise_workstream_status("done") == "finished"
def test_done_maps_to_completed(self):
assert normalise_workstream_status("done") == "completed"
def test_completed_maps_to_finished(self):
assert normalise_workstream_status("completed") == "finished"
def test_completed_is_identity(self):
assert normalise_workstream_status("completed") == "completed"
def test_accepted_maps_to_finished(self):
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):
assert normalise_workstream_status("active") == "active"
@@ -428,12 +434,12 @@ class TestNormaliseWorkstreamStatus:
# Don't crash on unexpected values — return them unchanged
assert normalise_workstream_status("foobar") == "foobar"
def test_map_constant_covers_done(self):
assert "done" in FILE_TO_DB_WORKSTREAM_STATUS
assert FILE_TO_DB_WORKSTREAM_STATUS["done"] == "completed"
def test_map_constant_covers_legacy_aliases(self):
assert FILE_TO_DB_WORKSTREAM_STATUS["done"] == "finished"
assert FILE_TO_DB_WORKSTREAM_STATUS["completed"] == "finished"
def test_c04_no_spurious_drift_when_done_vs_completed(self):
"""done (file) vs completed (DB) must NOT be reported as C-04 drift."""
def test_c04_no_spurious_drift_when_done_vs_finished(self):
"""done (file) vs finished (DB) must NOT be reported as C-04 drift."""
assert normalise_workstream_status("done") == normalise_workstream_status("completed")
def test_c04_real_drift_still_detected(self):
@@ -441,6 +447,55 @@ class TestNormaliseWorkstreamStatus:
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)
# ---------------------------------------------------------------------------

View File

@@ -109,9 +109,18 @@ class TestWorkstreams:
topic = await _create_topic(client)
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"})
assert r.status_code == 200
assert r.json()["status"] == "completed"
assert r.json()["status"] == "finished"
async def test_filter_by_owner(self, client):
await _create_domain(client)
@@ -321,11 +330,11 @@ class TestFlowEndpoints:
r = await client.get(f"/flows/workstream/{ws['id']}")
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.json()["current_workstation"] == "completed"
assert r.json()["current_workstation"] == "finished"
r = await client.get(f"/workstreams/{ws['id']}")
assert r.json()["status"] == "completed"
assert r.json()["status"] == "finished"

View File

@@ -12,7 +12,7 @@ def test_all_assertions_satisfied_reports_reachable_workstations():
workstations=[
WorkstationDef(name="active"),
WorkstationDef(
name="completed",
name="finished",
entry_assertions=[
AssertionDef(
id="tasks.all_done",
@@ -31,7 +31,7 @@ def test_all_assertions_satisfied_reports_reachable_workstations():
)
assert result.exit_blocked is False
assert result.reachable == ["active", "completed"]
assert result.reachable == ["active", "finished"]
assert result.unreachable == []
@@ -135,18 +135,18 @@ def test_yaml_flow_definitions_load_and_evaluate_representative_entities():
workstream_result = FlowEngine(
custom_ops={
"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(
{
"status": "active",
"tasks": [{"status": "done"}],
"dependencies": [{"workstation": "completed"}],
"dependencies": [{"workstation": "finished"}],
},
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]
task_result = FlowEngine().evaluate(

View File

@@ -4,7 +4,7 @@ type: workplan
title: "Workplan State Model Cleanup"
domain: custodian
repo: state-hub
status: active
status: finished
owner: custodian
topic_slug: custodian
planning_priority: high
@@ -89,7 +89,7 @@ reviewed as the design source for this workplan.
```task
id: CUST-WP-0042-T02
status: todo
status: done
priority: high
state_hub_task_id: "9be18f7e-1db1-464b-8c65-bf64ae3462e8"
```
@@ -105,7 +105,7 @@ string sets in multiple files.
```task
id: CUST-WP-0042-T03
status: todo
status: done
priority: high
state_hub_task_id: "1d9964ce-7b30-49d3-a4e3-6d5b3ef8d684"
```
@@ -124,7 +124,7 @@ normalized without data loss.
```task
id: CUST-WP-0042-T04
status: todo
status: done
priority: high
state_hub_task_id: "c80df776-5d45-419d-a92c-59e3b77d9798"
```
@@ -140,7 +140,7 @@ values only when they intentionally edit files.
```task
id: CUST-WP-0042-T05
status: todo
status: done
priority: medium
state_hub_task_id: "97ebc285-3998-4f17-bf43-c4f803cf1e7b"
```
@@ -163,7 +163,7 @@ an explicit flag.
```task
id: CUST-WP-0042-T06
status: todo
status: done
priority: high
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
id: CUST-WP-0042-T07
status: todo
status: done
priority: medium
state_hub_task_id: "8ecceebb-0471-4541-96f9-c9f98df12f84"
```
@@ -193,7 +193,7 @@ API and consistency engine.
```task
id: CUST-WP-0042-T08
status: todo
status: done
priority: medium
state_hub_task_id: "7dd1f27d-4a4a-4c78-8b91-6103688559a9"
```