From d6522a9a4092f2bf15d3d12fbd60ff001cbd9fae Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 18 May 2026 01:31:36 +0200 Subject: [PATCH] Complete workplan state model cleanup --- README.md | 4 +- api/flow_defs.py | 5 +- api/routers/flows.py | 10 +- api/routers/state.py | 35 ++-- api/routers/workstreams.py | 44 +++-- api/schemas/state.py | 5 +- api/schemas/workstream.py | 26 ++- api/workplan_status.py | 169 ++++++++++++++++++ dashboard/src/components/entity-modal.js | 4 + dashboard/src/components/workplan-status.js | 45 +++++ dashboard/src/data/summary.json.py | 11 +- dashboard/src/dependencies.md | 9 +- dashboard/src/docs/dashboard.md | 3 +- dashboard/src/docs/dependencies.md | 6 +- dashboard/src/docs/overview.md | 2 +- dashboard/src/docs/progress-log.md | 2 +- dashboard/src/docs/ralph-workplan.md | 6 +- dashboard/src/docs/repo-integration.md | 6 +- dashboard/src/docs/workstream-health-index.md | 8 +- dashboard/src/docs/workstream-kpi.md | 7 +- dashboard/src/docs/workstream-lifecycle.md | 163 +++++++---------- dashboard/src/docs/workstreams.md | 19 +- dashboard/src/index.md | 74 ++++---- dashboard/src/workstreams.md | 21 ++- docs/cron-migration.md | 4 +- docs/nats-event-subjects.md | 2 +- docs/task-flow-engine-spec.md | 8 +- docs/workplan-convention.md | 13 +- flows/workstream.yaml | 34 ++-- mcp_server/TOOLS.md | 2 +- mcp_server/server.py | 6 +- ...8p9q0r1s2t3_workstream_canonical_states.py | 48 +++++ scripts/cleanup_stale_tasks.py | 16 +- scripts/consistency_check.py | 113 ++++++++---- scripts/project_rules/agents-codex.template | 10 +- .../project_rules/session-protocol.template | 3 +- .../workplan-convention.template | 6 + scripts/validate_repo_adr.py | 24 ++- tests/test_consistency_check.py | 81 +++++++-- tests/test_routers_core.py | 19 +- tests/test_task_flow_engine.py | 10 +- ...ST-WP-0042-workplan-state-model-cleanup.md | 16 +- 42 files changed, 789 insertions(+), 310 deletions(-) create mode 100644 api/workplan_status.py create mode 100644 dashboard/src/components/workplan-status.js create mode 100644 migrations/versions/u8p9q0r1s2t3_workstream_canonical_states.py diff --git a/README.md b/README.md index 4672023..d108376 100644 --- a/README.md +++ b/README.md @@ -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 } }, diff --git a/api/flow_defs.py b/api/flow_defs.py index 9c7c5a2..09a78bb 100644 --- a/api/flow_defs.py +++ b/api/flow_defs.py @@ -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]: diff --git a/api/routers/flows.py b/api/routers/flows.py index c3919d3..365a6f9 100644 --- a/api/routers/flows.py +++ b/api/routers/flows.py @@ -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({ diff --git a/api/routers/state.py b/api/routers/state.py index 62aabe8..cf8b1be 100644 --- a/api/routers/state.py +++ b/api/routers/state.py @@ -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) diff --git a/api/routers/workstreams.py b/api/routers/workstreams.py index 64868e9..12c2735 100644 --- a/api/routers/workstreams.py +++ b/api/routers/workstreams.py @@ -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, diff --git a/api/schemas/state.py b/api/schemas/state.py index 5f21777..0d5c632 100644 --- a/api/schemas/state.py +++ b/api/schemas/state.py @@ -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 diff --git a/api/schemas/workstream.py b/api/schemas/workstream.py index 26e1698..29fb614 100644 --- a/api/schemas/workstream.py +++ b/api/schemas/workstream.py @@ -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 diff --git a/api/workplan_status.py b/api/workplan_status.py new file mode 100644 index 0000000..4180225 --- /dev/null +++ b/api/workplan_status.py @@ -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 diff --git a/dashboard/src/components/entity-modal.js b/dashboard/src/components/entity-modal.js index c3fe3af..46610b5 100644 --- a/dashboard/src/components/entity-modal.js +++ b/dashboard/src/components/entity-modal.js @@ -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", diff --git a/dashboard/src/components/workplan-status.js b/dashboard/src/components/workplan-status.js new file mode 100644 index 0000000..a6777d0 --- /dev/null +++ b/dashboard/src/components/workplan-status.js @@ -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"); +} diff --git a/dashboard/src/data/summary.json.py b/dashboard/src/data/summary.json.py index 4d6ab11..2f15c4a 100644 --- a/dashboard/src/data/summary.json.py +++ b/dashboard/src/data/summary.json.py @@ -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}, }, diff --git a/dashboard/src/dependencies.md b/dashboard/src/dependencies.md index a4dbce9..ca26cd0 100644 --- a/dashboard/src/dependencies.md +++ b/dashboard/src/dependencies.md @@ -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; } diff --git a/dashboard/src/docs/dashboard.md b/dashboard/src/docs/dashboard.md index 3d45666..765a06c 100644 --- a/dashboard/src/docs/dashboard.md +++ b/dashboard/src/docs/dashboard.md @@ -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 ` 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.", diff --git a/dashboard/src/workstreams.md b/dashboard/src/workstreams.md index 9aafa27..c66837e 100644 --- a/dashboard/src/workstreams.md +++ b/dashboard/src/workstreams.md @@ -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; } diff --git a/docs/cron-migration.md b/docs/cron-migration.md index 8058bbd..9fb780f 100644 --- a/docs/cron-migration.md +++ b/docs/cron-migration.md @@ -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=` | 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 diff --git a/docs/nats-event-subjects.md b/docs/nats-event-subjects.md index 57da555..4176bdd 100644 --- a/docs/nats-event-subjects.md +++ b/docs/nats-event-subjects.md @@ -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` | diff --git a/docs/task-flow-engine-spec.md b/docs/task-flow-engine-spec.md index f44e831..1721f1d 100644 --- a/docs/task-flow-engine-spec.md +++ b/docs/task-flow-engine-spec.md @@ -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 diff --git a/docs/workplan-convention.md b/docs/workplan-convention.md index b4f9a47..1cf34ac 100644 --- a/docs/workplan-convention.md +++ b/docs/workplan-convention.md @@ -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. diff --git a/flows/workstream.yaml b/flows/workstream.yaml index 3e4a6be..453b264 100644 --- a/flows/workstream.yaml +++ b/flows/workstream.yaml @@ -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: [] diff --git a/mcp_server/TOOLS.md b/mcp_server/TOOLS.md index 915bb2a..a622ef3 100644 --- a/mcp_server/TOOLS.md +++ b/mcp_server/TOOLS.md @@ -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. | --- diff --git a/mcp_server/server.py b/mcp_server/server.py index fefbf56..820177e 100644 --- a/mcp_server/server.py +++ b/mcp_server/server.py @@ -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*. diff --git a/migrations/versions/u8p9q0r1s2t3_workstream_canonical_states.py b/migrations/versions/u8p9q0r1s2t3_workstream_canonical_states.py new file mode 100644 index 0000000..59e71e5 --- /dev/null +++ b/migrations/versions/u8p9q0r1s2t3_workstream_canonical_states.py @@ -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')") diff --git a/scripts/cleanup_stale_tasks.py b/scripts/cleanup_stale_tasks.py index 1e08b81..35904e3 100644 --- a/scripts/cleanup_stale_tasks.py +++ b/scripts/cleanup_stale_tasks.py @@ -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), diff --git a/scripts/consistency_check.py b/scripts/consistency_check.py index a3440f9..d745409 100644 --- a/scripts/consistency_check.py +++ b/scripts/consistency_check.py @@ -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 = [ diff --git a/scripts/project_rules/agents-codex.template b/scripts/project_rules/agents-codex.template index e6dc3d5..b071f43 100644 --- a/scripts/project_rules/agents-codex.template +++ b/scripts/project_rules/agents-codex.template @@ -82,7 +82,7 @@ curl -s -X PATCH "http://127.0.0.1:8000/tasks/" \ **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-.md` -**Archived location:** completed workplans may move to +**Archived location:** finished workplans may move to `workplans/archived/YYMMDD-{WP_PREFIX}-NNNN-.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: "" # 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): ``` diff --git a/scripts/project_rules/session-protocol.template b/scripts/project_rules/session-protocol.template index 7c19686..a471ad7 100644 --- a/scripts/project_rules/session-protocol.template +++ b/scripts/project_rules/session-protocol.template @@ -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** diff --git a/scripts/project_rules/workplan-convention.template b/scripts/project_rules/workplan-convention.template index c358a25..fa8e1d6 100644 --- a/scripts/project_rules/workplan-convention.template +++ b/scripts/project_rules/workplan-convention.template @@ -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-.md`. The frontmatter id remains unchanged; the prefix is only for quick visual reference. diff --git a/scripts/validate_repo_adr.py b/scripts/validate_repo_adr.py index c2f6643..da30e8d 100644 --- a/scripts/validate_repo_adr.py +++ b/scripts/validate_repo_adr.py @@ -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", "") diff --git a/tests/test_consistency_check.py b/tests/test_consistency_check.py index 500a8f0..7c3aee9 100644 --- a/tests/test_consistency_check.py +++ b/tests/test_consistency_check.py @@ -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) # --------------------------------------------------------------------------- diff --git a/tests/test_routers_core.py b/tests/test_routers_core.py index 8f2a310..4390670 100644 --- a/tests/test_routers_core.py +++ b/tests/test_routers_core.py @@ -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" diff --git a/tests/test_task_flow_engine.py b/tests/test_task_flow_engine.py index cd58990..f6805c6 100644 --- a/tests/test_task_flow_engine.py +++ b/tests/test_task_flow_engine.py @@ -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( diff --git a/workplans/CUST-WP-0042-workplan-state-model-cleanup.md b/workplans/CUST-WP-0042-workplan-state-model-cleanup.md index 6df7fcd..4c357e5 100644 --- a/workplans/CUST-WP-0042-workplan-state-model-cleanup.md +++ b/workplans/CUST-WP-0042-workplan-state-model-cleanup.md @@ -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" ```