From ddc33385416620353ebff4db0e2b985d710635ba Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 14 Sep 2026 15:28:58 +0200 Subject: [PATCH] Index workplan flavor and omit residuals from default views. STATE-WP-0092: persist flavor from files, treat depends_on as the C-20 canonical edge, exclude flavor=residual from summary/next_steps/deps unless include_residuals is set. Live primary still needs the alembic revision applied. Assistant: grok Assistant-Session: 01a09dc1-b21e-77e1-919e-fcad2f82b267 --- AGENTS.md | 5 +- README.md | 3 +- api/models/task.py | 1 + api/models/workplan.py | 3 + api/routers/state.py | 99 +++++++++- api/routers/workstreams.py | 26 ++- api/schemas/state.py | 3 + api/schemas/task.py | 21 +- api/schemas/workplan.py | 54 +++++- api/work_record_flavor.py | 48 +++++ dashboard/src/workstreams.md | 7 +- docs/work-record-flavor.md | 57 ++++++ docs/workplan-convention.md | 7 + .../c6f7a8b9d0e1_work_record_flavor.py | 39 ++++ scripts/consistency_check.py | 183 +++++++++++++++++- tests/test_consistency_check.py | 167 ++++++++++++++++ tests/test_work_record_flavor.py | 136 +++++++++++++ ...2-workplan-flavor-and-depends-on-policy.md | 18 +- 18 files changed, 852 insertions(+), 25 deletions(-) create mode 100644 api/work_record_flavor.py create mode 100644 docs/work-record-flavor.md create mode 100644 migrations/versions/c6f7a8b9d0e1_work_record_flavor.py create mode 100644 tests/test_work_record_flavor.py diff --git a/AGENTS.md b/AGENTS.md index 4fcf602..2bbd630 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,9 +43,12 @@ with `make -C ~/state-hub configure-codex WITH_MCP=1`. # Offline brief — works without hub connection cat .custodian-brief.md -# Active workplans for this domain +# Active workplans for this domain (default summary omits flavor: residual) curl -s "http://127.0.0.1:8000/workplans/?topic_id=cee7bedf-2b48-46ef-8601-006474f2ad7a&status=active" \ | python3 -m json.tool +# Residuals are not implementation demand unless promoted: +# GET /state/summary (open_workplans omits flavor=residual) +# GET /workplans/?flavor=residual # Check inbox curl -s "http://127.0.0.1:8000/messages/?to_agent=state-hub&unread_only=true" \ diff --git a/README.md b/README.md index 8b3055b..7193dbd 100644 --- a/README.md +++ b/README.md @@ -264,7 +264,8 @@ use `/state/health`, not `/state/summary`. | `/state/summary` | Full snapshot | | `/state/health` | DB connectivity check | -See `docs/workplan-terminology-transition.md` for the workstream-to-workplan +See `docs/work-record-flavor.md` for flavor buckets and residual +default-exclusion (STATE-WP-0092). See `docs/workplan-terminology-transition.md` for the workstream-to-workplan compatibility policy and retirement criteria. --- diff --git a/api/models/task.py b/api/models/task.py index c37816a..b91d14e 100644 --- a/api/models/task.py +++ b/api/models/task.py @@ -42,6 +42,7 @@ class Task(Base, TimestampMixin): # title, so renaming a heading looks like one task vanishing and another # appearing. record_id: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True) + flavor: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) title: Mapped[str] = mapped_column(String(255), nullable=False) description: Mapped[str | None] = mapped_column(Text, nullable=True) status: Mapped[TaskStatus] = mapped_column( diff --git a/api/models/workplan.py b/api/models/workplan.py index f3f9f2a..8006b28 100644 --- a/api/models/workplan.py +++ b/api/models/workplan.py @@ -39,6 +39,9 @@ class Workplan(Base, TimestampMixin): queue_rank: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) execution_group: Mapped[str | None] = mapped_column(String(100), nullable=True, index=True) scheduled_for: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True, index=True) + flavor: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + flavor_promotion_reason: Mapped[str | None] = mapped_column(String(32), nullable=True) + flavor_promoted_from: Mapped[str | None] = mapped_column(String(32), nullable=True) repo_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), diff --git a/api/routers/state.py b/api/routers/state.py index 4990d62..c78f238 100644 --- a/api/routers/state.py +++ b/api/routers/state.py @@ -1,7 +1,7 @@ import time from datetime import datetime, timedelta, timezone -from fastapi import APIRouter, Depends, Request, Response +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response from fastapi.responses import JSONResponse from sqlalchemy import func, select, text from sqlalchemy.ext.asyncio import AsyncSession @@ -75,6 +75,12 @@ def _dual_workplan_refs( "workstream_slug": slug, } from api.task_status import TERMINAL_TASK_STATUSES, status_value +from api.work_record_flavor import ( + RESIDUAL_FLAVOR, + WORK_RECORD_FLAVORS, + is_residual_flavor, + normalize_flavor, +) from api.workplan_status import ( CLOSED_WORKPLAN_STATUSES, OPEN_WORKPLAN_STATUSES, @@ -100,12 +106,44 @@ def _summary_cache_headers( response.headers["Cache-Control"] = "max-age=15, stale-while-revalidate=120" +def _apply_summary_flavor_view( + summary: StateSummary, + *, + include_residuals: bool, + flavor: str | None, +) -> StateSummary: + """Default views omit residual workplans; totals.residual_open stays honest.""" + wanted = normalize_flavor(flavor) + if wanted is not None and wanted not in WORK_RECORD_FLAVORS: + raise HTTPException( + status_code=422, + detail=( + f"Unknown work-record flavor {wanted!r}; " + f"expected one of {', '.join(WORK_RECORD_FLAVORS)}" + ), + ) + show_residuals = include_residuals or wanted == RESIDUAL_FLAVOR + residual_rows = list(summary.residual_open_workplans) + open_rows = list(summary.open_workplans) + if wanted is not None: + open_rows = [row for row in open_rows if row.flavor == wanted] + residual_rows = [row for row in residual_rows if row.flavor == wanted] + return summary.model_copy( + update={ + "open_workplans": open_rows, + "residual_open_workplans": residual_rows if show_residuals else [], + } + ) + + @router.get("/summary", response_model=StateSummary) async def get_summary( request: Request, response: Response, session: AsyncSession = Depends(get_session), refresh: bool = False, + include_residuals: bool = Query(False), + flavor: str | None = Query(None), ) -> StateSummary: revision = await fetch_summary_revision(session) revision_token = revision.combined_fingerprint() @@ -116,22 +154,38 @@ async def get_summary( if cache_status == "hit-revision" and cached is not None: _summary_cache_headers(response, cache_status="hit-revision", revision=revision_token) - return cached.model_copy(update={"ops_runs": await get_ops_run_projection()}) + return _apply_summary_flavor_view( + cached.model_copy(update={"ops_runs": await get_ops_run_projection()}), + include_residuals=include_residuals, + flavor=flavor, + ) if cache_status == "progress-section" and cached is not None: result = await apply_progress_section(session, cached, revision) _summary_cache_headers(response, cache_status="hit-revision", revision=revision_token) - return result.model_copy(update={"ops_runs": await get_ops_run_projection()}) + return _apply_summary_flavor_view( + result.model_copy(update={"ops_runs": await get_ops_run_projection()}), + include_residuals=include_residuals, + flavor=flavor, + ) if cache_status == "stale" and cached is not None: cache.schedule_refresh(revision) _summary_cache_headers(response, cache_status="stale", revision=revision_token) - return cached.model_copy(update={"ops_runs": await get_ops_run_projection()}) + return _apply_summary_flavor_view( + cached.model_copy(update={"ops_runs": await get_ops_run_projection()}), + include_residuals=include_residuals, + flavor=flavor, + ) result = await build_state_summary(session) cache.store(result, revision) _summary_cache_headers(response, cache_status="miss", revision=revision_token) - return result.model_copy(update={"ops_runs": await get_ops_run_projection(refresh=force_refresh)}) + return _apply_summary_flavor_view( + result.model_copy(update={"ops_runs": await get_ops_run_projection(refresh=force_refresh)}), + include_residuals=include_residuals, + flavor=flavor, + ) async def build_state_summary(session: AsyncSession) -> StateSummary: @@ -336,6 +390,7 @@ async def build_state_summary(session: AsyncSession) -> StateSummary: ), archived=ws_counts.get("archived", 0), total=sum(ws_counts.values()), + residual_open=0, ), tasks=TaskTotals( wait=task_counts.get(TaskStatus.wait, 0), @@ -397,6 +452,15 @@ async def build_state_summary(session: AsyncSession) -> StateSummary: ) for w in open_ws ] + relevant_rows = [ + row for row, w in zip(open_workplan_rows, open_ws) + if not is_residual_flavor(w.flavor) + ] + residual_rows = [ + row for row, w in zip(open_workplan_rows, open_ws) + if is_residual_flavor(w.flavor) + ] + totals.workstreams.residual_open = len(residual_rows) result = StateSummary( generated_at=datetime.now(tz=timezone.utc), @@ -418,7 +482,8 @@ async def build_state_summary(session: AsyncSession) -> StateSummary: licence_risk_count=licence_risk_count, open_capability_requests=open_cap_req_count, ranked_suggestions=ranked_suggestions, - open_workplans=open_workplan_rows, + open_workplans=relevant_rows, + residual_open_workplans=residual_rows, ) return result @@ -521,6 +586,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview: w for w in workstreams_all if normalize_workplan_status(w.status) in OPEN_WORKPLAN_STATUSES ] + residual_open = sum(1 for w in open_ws if is_residual_flavor(w.flavor)) open_ws_ids = [w.id for w in open_ws] dep_rows = [] if open_ws_ids: @@ -580,6 +646,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview: ), archived=ws_counts.get("archived", 0), total=sum(ws_counts.values()), + residual_open=residual_open, ), tasks=TaskTotals( wait=task_totals_by_status.get("wait", 0), @@ -668,6 +735,7 @@ async def _build_dashboard_overview(session: AsyncSession) -> DashboardOverview: id=w.id, title=w.title, status=normalize_workplan_status(w.status), + flavor=w.flavor, domain=repo["domain_slug"] if repo else (topic.domain_slug if topic else "unknown"), repo_label=repo["slug"] if repo else workplan.get("repo_slug", "unassigned"), workplan_filename=workplan.get("filename"), @@ -795,7 +863,10 @@ async def _build_domain_summaries(session: AsyncSession) -> list[DomainSummary]: @router.get("/deps", response_model=list[WorkstreamWithDeps]) -async def get_deps(session: AsyncSession = Depends(get_session)) -> list[WorkstreamWithDeps]: +async def get_deps( + session: AsyncSession = Depends(get_session), + include_residuals: bool = Query(False), +) -> list[WorkstreamWithDeps]: """Lightweight dep-graph endpoint: open workstreams with their dependency edges only. Returns the same structure as open_workplans in /state/summary but skips @@ -809,6 +880,8 @@ async def get_deps(session: AsyncSession = Depends(get_session)) -> list[Workstr .order_by(Workplan.due_date.asc().nullslast(), Workplan.created_at) ) open_ws = list(open_ws_rows.scalars().all()) + if not include_residuals: + open_ws = [w for w in open_ws if not is_residual_flavor(w.flavor)] open_ws_ids = [w.id for w in open_ws] dep_rows = [] @@ -944,6 +1017,10 @@ async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], lis if task.id in seen_task_ids: continue ws = await session.get(Workplan, decision.workplan_id, options=[noload("*")]) + if ws is not None and is_residual_flavor(ws.flavor): + continue + if is_residual_flavor(task.flavor): + continue domain_slug = await _get_domain_slug_for_workplan(ws, session) steps.append(NextStep( type="resolved_decision", @@ -988,6 +1065,7 @@ async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], lis Workplan.title, Workplan.slug, Workplan.topic_id, + Workplan.flavor, ).where(Workplan.id.in_(dep_ws_ids)) ) ws_info = { @@ -996,8 +1074,9 @@ async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], lis "title": title, "slug": slug, "topic_id": topic_id, + "flavor": flavor, } - for ws_id, status, title, slug, topic_id in ws_rows + for ws_id, status, title, slug, topic_id, flavor in ws_rows } ready_from_ws_ids = [ @@ -1023,12 +1102,16 @@ async def _derive_next_steps(session: AsyncSession) -> tuple[list[NextStep], lis for from_ws_id in ready_from_ws_ids: from_ws = ws_info.get(from_ws_id, {}) + if is_residual_flavor(from_ws.get("flavor")): + continue todo_tasks = todo_by_ws.get(from_ws_id, []) if not todo_tasks: continue task = min(todo_tasks, key=lambda t: (_PRIORITY_RANK.get(t.priority, 99), t.created_at)) if task.id in seen_task_ids: continue + if is_residual_flavor(task.flavor): + continue domain_slug = await _get_domain_slug_for_topic(from_ws.get("topic_id"), session) _blocker_slugs = [] for tid in dep_map[from_ws_id]: diff --git a/api/routers/workstreams.py b/api/routers/workstreams.py index bca8e47..bd636cd 100644 --- a/api/routers/workstreams.py +++ b/api/routers/workstreams.py @@ -9,7 +9,7 @@ from typing import Any import yaml from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status -from sqlalchemy import select +from sqlalchemy import or_, select from sqlalchemy.ext.asyncio import AsyncSession from api.database import get_session @@ -24,6 +24,11 @@ from api.schemas.workplan import ( ) from api.services.lifecycle import transition_workplan_status from api.services.legacy_compat import retire_legacy_route +from api.work_record_flavor import ( + RESIDUAL_FLAVOR, + WORK_RECORD_FLAVORS, + normalize_flavor, +) from api.workplan_status import ( is_supported_workplan_status, normalize_workplan_status, @@ -88,6 +93,8 @@ async def _list_workplans( status_filter: str | None, owner: str | None, slug: str | None, + flavor: str | None = None, + include_residuals: bool = True, session: AsyncSession, ) -> list[Workplan]: q = select(Workplan) @@ -106,6 +113,19 @@ async def _list_workplans( q = q.where(Workplan.owner == owner) if slug: q = q.where(Workplan.slug == slug) + wanted = normalize_flavor(flavor) + if wanted is not None: + if wanted not in WORK_RECORD_FLAVORS: + raise HTTPException( + status_code=422, + detail=( + f"Unknown work-record flavor {wanted!r}; " + f"expected one of {', '.join(WORK_RECORD_FLAVORS)}" + ), + ) + q = q.where(Workplan.flavor == wanted) + elif not include_residuals: + q = q.where(or_(Workplan.flavor.is_(None), Workplan.flavor != RESIDUAL_FLAVOR)) q = q.order_by( Workplan.planning_priority.asc().nullslast(), Workplan.planning_order.asc().nullslast(), @@ -365,6 +385,8 @@ async def list_workplans( status: str | None = None, owner: str | None = None, slug: str | None = None, + flavor: str | None = Query(None), + include_residuals: bool = Query(True), session: AsyncSession = Depends(get_session), ) -> list[Workplan]: return await _list_workplans( @@ -374,6 +396,8 @@ async def list_workplans( status_filter=status, owner=owner, slug=slug, + flavor=flavor, + include_residuals=include_residuals, session=session, ) diff --git a/api/schemas/state.py b/api/schemas/state.py index 75c4e39..3b9e53e 100644 --- a/api/schemas/state.py +++ b/api/schemas/state.py @@ -30,6 +30,7 @@ class WorkstreamTotals(BaseModel): finished: int = 0 archived: int = 0 total: int = 0 + residual_open: int = 0 class TaskTotals(BaseModel): @@ -85,6 +86,7 @@ class StateSummary(BaseModel): blocked_tasks: list[TaskRead] = [] recent_progress: list[ProgressEventRead] open_workplans: list[WorkstreamWithDeps] = [] + residual_open_workplans: list[WorkstreamWithDeps] = [] next_steps: list[NextStep] = [] domains: list[DomainSummary] = [] contribution_counts: dict[str, int] = {} @@ -98,6 +100,7 @@ class DashboardWorkplanRow(BaseModel): id: uuid.UUID title: str status: str + flavor: str | None = None domain: str = "unknown" repo_label: str = "unassigned" workplan_filename: str | None = None diff --git a/api/schemas/task.py b/api/schemas/task.py index 0c353f4..31bf85d 100644 --- a/api/schemas/task.py +++ b/api/schemas/task.py @@ -7,6 +7,7 @@ from pydantic import BaseModel, ConfigDict, field_validator, model_validator from api.models.task import TaskPriority, TaskStatus from api.schemas.compat import WorkplanIdCompatMixin, WorkplanIdCreateMixin from api.task_status import normalize_task_status +from api.work_record_flavor import WORK_RECORD_FLAVORS, normalize_flavor class TaskStatusMixin(BaseModel): @@ -18,9 +19,23 @@ class TaskStatusMixin(BaseModel): return normalize_task_status(value) -class TaskCreate(TaskStatusMixin, WorkplanIdCreateMixin): +class TaskFlavorMixin(BaseModel): + @field_validator("flavor", mode="before", check_fields=False) + @classmethod + def _normalize_flavor(cls, value): + flavor = normalize_flavor(value) + if flavor is not None and flavor not in WORK_RECORD_FLAVORS: + raise ValueError( + f"Unknown work-record flavor {flavor!r}; " + f"expected one of {', '.join(WORK_RECORD_FLAVORS)}" + ) + return flavor + + +class TaskCreate(TaskStatusMixin, TaskFlavorMixin, WorkplanIdCreateMixin): id: uuid.UUID | None = None record_id: str | None = None + flavor: str | None = None title: str description: str | None = None status: TaskStatus = TaskStatus.todo @@ -39,8 +54,9 @@ class TaskCreate(TaskStatusMixin, WorkplanIdCreateMixin): return self -class TaskUpdate(TaskStatusMixin): +class TaskUpdate(TaskStatusMixin, TaskFlavorMixin): title: str | None = None + flavor: str | None = None description: str | None = None status: TaskStatus | None = None priority: TaskPriority | None = None @@ -102,6 +118,7 @@ class TaskRead(TaskStatusMixin, WorkplanIdCompatMixin): model_config = ConfigDict(from_attributes=True) id: uuid.UUID record_id: str | None = None + flavor: str | None = None title: str description: str | None = None status: TaskStatus diff --git a/api/schemas/workplan.py b/api/schemas/workplan.py index 4258e5e..31bf49c 100644 --- a/api/schemas/workplan.py +++ b/api/schemas/workplan.py @@ -5,6 +5,12 @@ from typing import Literal from pydantic import BaseModel, ConfigDict, field_validator from api.schemas.workplan_dependency import WorkplanDepStub +from api.work_record_flavor import ( + FLAVOR_PROMOTION_REASONS, + WORK_RECORD_FLAVORS, + normalize_flavor, + normalize_promotion_reason, +) from api.workplan_status import normalize_workplan_status WorkplanStatus = Literal[ @@ -28,7 +34,42 @@ class WorkplanStatusMixin(BaseModel): return normalize_workplan_status(value) -class WorkplanCreate(WorkplanStatusMixin): +class WorkplanFlavorMixin(BaseModel): + @field_validator("flavor", mode="before", check_fields=False) + @classmethod + def _normalise_flavor(cls, value): + flavor = normalize_flavor(value) + if flavor is not None and flavor not in WORK_RECORD_FLAVORS: + raise ValueError( + f"Unknown work-record flavor {flavor!r}; " + f"expected one of {', '.join(WORK_RECORD_FLAVORS)}" + ) + return flavor + + @field_validator("flavor_promotion_reason", mode="before", check_fields=False) + @classmethod + def _normalise_promotion_reason(cls, value): + reason = normalize_promotion_reason(value) + if reason is not None and reason not in FLAVOR_PROMOTION_REASONS: + raise ValueError( + f"Unknown flavor_promotion_reason {reason!r}; " + f"expected one of {', '.join(FLAVOR_PROMOTION_REASONS)}" + ) + return reason + + @field_validator("flavor_promoted_from", mode="before", check_fields=False) + @classmethod + def _normalise_promoted_from(cls, value): + flavor = normalize_flavor(value) + if flavor is not None and flavor not in WORK_RECORD_FLAVORS: + raise ValueError( + f"Unknown flavor_promoted_from {flavor!r}; " + f"expected one of {', '.join(WORK_RECORD_FLAVORS)}" + ) + return flavor + + +class WorkplanCreate(WorkplanStatusMixin, WorkplanFlavorMixin): id: uuid.UUID | None = None repo_id: uuid.UUID topic_id: uuid.UUID | None = None @@ -40,6 +81,9 @@ class WorkplanCreate(WorkplanStatusMixin): due_date: date | None = None planning_priority: str | None = None planning_order: int | None = None + flavor: str | None = None + flavor_promotion_reason: str | None = None + flavor_promoted_from: str | None = None execution_state: ExecutionState = "manual" launch_mode: LaunchMode = "manual" concurrency_mode: ConcurrencyMode = "sequential" @@ -49,7 +93,7 @@ class WorkplanCreate(WorkplanStatusMixin): repo_goal_id: uuid.UUID | None = None -class WorkplanUpdate(WorkplanStatusMixin): +class WorkplanUpdate(WorkplanStatusMixin, WorkplanFlavorMixin): title: str | None = None description: str | None = None status: WorkplanStatus | None = None @@ -57,6 +101,9 @@ class WorkplanUpdate(WorkplanStatusMixin): due_date: date | None = None planning_priority: str | None = None planning_order: int | None = None + flavor: str | None = None + flavor_promotion_reason: str | None = None + flavor_promoted_from: str | None = None execution_state: ExecutionState | None = None launch_mode: LaunchMode | None = None concurrency_mode: ConcurrencyMode | None = None @@ -95,6 +142,9 @@ class WorkplanRead(WorkplanStatusMixin): due_date: date | None = None planning_priority: str | None = None planning_order: int | None = None + flavor: str | None = None + flavor_promotion_reason: str | None = None + flavor_promoted_from: str | None = None execution_state: ExecutionState = "manual" launch_mode: LaunchMode = "manual" concurrency_mode: ConcurrencyMode = "sequential" diff --git a/api/work_record_flavor.py b/api/work_record_flavor.py new file mode 100644 index 0000000..5fc9fe3 --- /dev/null +++ b/api/work_record_flavor.py @@ -0,0 +1,48 @@ +"""Work-record flavor (STATE-WP-0092). + +Flavor is a closed bucket on workplans and tasks, orthogonal to kind and +status. Unset flavor is not residual. +""" + +from __future__ import annotations + +from typing import Any + +WORK_RECORD_FLAVORS: tuple[str, ...] = ( + "planning", + "implementation", + "refactoring", + "extension", + "residual", +) +RESIDUAL_FLAVOR = "residual" +FLAVOR_PROMOTION_REASONS: tuple[str, ...] = ("demand", "risk") + +_EMPTY = {"", "~", "null", "none", "nil"} + + +def normalize_flavor(value: Any) -> str | None: + if value is None: + return None + text = str(value).strip().lower() + if text in _EMPTY: + return None + return text + + +def is_known_flavor(value: Any) -> bool: + flavor = normalize_flavor(value) + return flavor is None or flavor in WORK_RECORD_FLAVORS + + +def is_residual_flavor(value: Any) -> bool: + return normalize_flavor(value) == RESIDUAL_FLAVOR + + +def normalize_promotion_reason(value: Any) -> str | None: + if value is None: + return None + text = str(value).strip().lower() + if text in _EMPTY: + return None + return text diff --git a/dashboard/src/workstreams.md b/dashboard/src/workstreams.md index 16e699f..b9cebe0 100644 --- a/dashboard/src/workstreams.md +++ b/dashboard/src/workstreams.md @@ -234,11 +234,13 @@ const _filtersForm = Inputs.form( domain: MultiSelect(DOMAINS, {label: "Domain", placeholder: "All domains"}), status: MultiSelect(STATUSES, {label: "Status", placeholder: "All statuses"}), owner: Inputs.text({placeholder: "Owner…", style: "width:120px"}), + include_residuals: Inputs.toggle({label: "Include residuals", value: false}), }, { - template: ({domain, status, owner}) => html`
+ template: ({domain, status, owner, include_residuals}) => html`
${domain}${status}
${owner}
+ ${include_residuals}
`, } ); @@ -253,7 +255,8 @@ const filters = Generators.input(_filtersForm); const filtered = data.filter(w => (filters.domain.length === 0 || filters.domain.includes(w.domain)) && (filters.status.length === 0 || filters.status.includes(w.status)) && - (!filters.owner || (w.owner ?? "").toLowerCase().includes(filters.owner.toLowerCase())) + (!filters.owner || (w.owner ?? "").toLowerCase().includes(filters.owner.toLowerCase())) && + (filters.include_residuals || w.flavor !== "residual") ); ``` diff --git a/docs/work-record-flavor.md b/docs/work-record-flavor.md new file mode 100644 index 0000000..93f255f --- /dev/null +++ b/docs/work-record-flavor.md @@ -0,0 +1,57 @@ +# Work-record flavor and residual default-exclusion + +Status: active (STATE-WP-0092) +Canon: `the-custodian/canon/standards/work-record-types_v0.1.md` § Flavor + +## Rule + +Workplans and tasks carry an optional **flavor** bucket: + +`planning` | `implementation` | `refactoring` | `extension` | `residual` + +Flavor is not a kind and not a status. Unset flavor is **not** residual. + +`flavor: residual` means leftover work that is **not** as valuable as the +main functional and non-functional requirements. Default State Hub views of +open work **omit** residuals. Agents must not pick residual workplans or +residual tasks for implementation unless the flavor has been promoted. + +Promotion is a file edit: + +```yaml +flavor: implementation # or planning | refactoring | extension +flavor_promoted_from: residual +flavor_promotion_reason: demand # or risk +``` + +Then `statehub fix-consistency`. After promotion the record re-enters the +default view. + +`origin: residual` on intakes remains provenance. It does not hide a +workplan; `flavor` does. + +## `depends_on` + +Workplan frontmatter `depends_on` is the canonical list of blocker +**workplan ids**. C-20 indexes it (legacy alias: `depends_on_workplans`). +Do not use `depends_on_workplans` on new files. + +## API + +| Surface | Default | Residual access | +| --- | --- | --- | +| `GET /workplans/` | includes residuals (full index) | `?flavor=residual` or `?include_residuals=false` to hide | +| `GET /state/summary` `open_workplans` | omits residuals | `?include_residuals=true` fills `residual_open_workplans`; `totals.workstreams.residual_open` is always the count | +| `GET /state/next_steps` | never recommends residual work | — | +| `GET /state/deps` | omits residuals | `?include_residuals=true` | + +Unknown flavor values fail closed on write (HTTP 422). Consistency check +C-36 warns; it does not invent a flavor for historic files. + +## Fleet follow-on + +- Backfill existing files: `CUST-WP-0072` +- Fabric graph: `RAIL-FAB-WP-0030` +- Cross-owner waits: `COORDINATION-WP-0005` + +Do not message `ops-warden` for secrets; see credential routing. diff --git a/docs/workplan-convention.md b/docs/workplan-convention.md index d7e2196..6c763ac 100644 --- a/docs/workplan-convention.md +++ b/docs/workplan-convention.md @@ -17,8 +17,15 @@ repo: state-hub status: proposed owner: custodian topic_slug: custodian +flavor: planning +depends_on: [] ``` +`flavor` is `planning` | `implementation` | `refactoring` | `extension` | +`residual`. Unset is not residual. Default State Hub open views omit +`flavor: residual` until demand or risk promotes it (STATE-WP-0092). +`depends_on` lists blocker workplan ids; C-20 indexes that field. + 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. diff --git a/migrations/versions/c6f7a8b9d0e1_work_record_flavor.py b/migrations/versions/c6f7a8b9d0e1_work_record_flavor.py new file mode 100644 index 0000000..ae0632a --- /dev/null +++ b/migrations/versions/c6f7a8b9d0e1_work_record_flavor.py @@ -0,0 +1,39 @@ +"""work-record flavor on workplans and tasks (STATE-WP-0092) + +Flavor is a closed bucket (planning, implementation, refactoring, extension, +residual), orthogonal to kind and status. Nullable: unset is not residual. + +Revision ID: c6f7a8b9d0e1 +Revises: b5e6f7a8c9d0 +""" +from alembic import op +import sqlalchemy as sa + +revision = "c6f7a8b9d0e1" +down_revision = "b5e6f7a8c9d0" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("workplans", sa.Column("flavor", sa.String(length=32), nullable=True)) + op.add_column( + "workplans", + sa.Column("flavor_promotion_reason", sa.String(length=32), nullable=True), + ) + op.add_column( + "workplans", + sa.Column("flavor_promoted_from", sa.String(length=32), nullable=True), + ) + op.create_index("ix_workplans_flavor", "workplans", ["flavor"]) + op.add_column("tasks", sa.Column("flavor", sa.String(length=32), nullable=True)) + op.create_index("ix_tasks_flavor", "tasks", ["flavor"]) + + +def downgrade() -> None: + op.drop_index("ix_tasks_flavor", table_name="tasks") + op.drop_column("tasks", "flavor") + op.drop_index("ix_workplans_flavor", table_name="workplans") + op.drop_column("workplans", "flavor_promoted_from") + op.drop_column("workplans", "flavor_promotion_reason") + op.drop_column("workplans", "flavor") diff --git a/scripts/consistency_check.py b/scripts/consistency_check.py index 02b39b6..a2078c1 100644 --- a/scripts/consistency_check.py +++ b/scripts/consistency_check.py @@ -39,6 +39,9 @@ Checks: C-33 work-record-index-stale WARN Yes WORK-RECORDS.md missing or stale — generated per-repo index (CUST-WP-0061-T04) C-34 quality-dor-ready WARN No status=ready without quality_dor DoR-Ok (STATE-WP-0077 soft) C-35 repo-manager-conformance WARN No Repo Manager flavor/standards contract reports findings + C-36 work-record-flavor-unknown WARN No flavor is set but not in the closed list + C-37 residual-provenance-missing WARN No flavor: residual without origin/origin_ref + C-38 task-flavor-drift WARN Yes task flavor differs between file and DB (file wins) (finished¬DoD-Ok is listed by `statehub quality-debt`, not per-file C-warn — avoids historical flood) Usage: @@ -118,6 +121,13 @@ from api.task_status import ( # noqa: E402 TERMINAL_TASK_STATUSES, normalize_task_status, ) +from api.work_record_flavor import ( # noqa: E402 + FLAVOR_PROMOTION_REASONS, + WORK_RECORD_FLAVORS, + is_residual_flavor, + normalize_flavor, + normalize_promotion_reason, +) _WORK_RECORD_NAMESPACE_UUID = uuid.UUID("a4058507-5c4a-5a00-ab06-fffa4fb46009") @@ -509,6 +519,36 @@ def _as_list(value: Any) -> list[str]: return [str(value).strip().strip('"')] +_TASK_RECORD_ID_RE = re.compile(r"-T\d{2,3}$") + + +def _dedupe_preserve(items: list[str]) -> list[str]: + seen: set[str] = set() + out: list[str] = [] + for item in items: + if item in seen: + continue + seen.add(item) + out.append(item) + return out + + +def _frontmatter_depends_on_workplans(meta: dict) -> list[str]: + """Canonical `depends_on` plus legacy `depends_on_workplans` alias.""" + combined = _as_list(meta.get("depends_on")) + _as_list(meta.get("depends_on_workplans")) + return _dedupe_preserve( + [item for item in combined if item and not _TASK_RECORD_ID_RE.search(item)] + ) + + +def _frontmatter_depends_on_tasks(meta: dict) -> list[str]: + from_alias = _as_list(meta.get("depends_on_tasks")) + from_depends_on = [ + item for item in _as_list(meta.get("depends_on")) if _TASK_RECORD_ID_RE.search(item) + ] + return _dedupe_preserve(from_alias + from_depends_on) + + def _as_int_or_none(value: Any) -> int | None: if value in (None, "", "~", "null", "None", "none"): return None @@ -1664,6 +1704,86 @@ def check_repo( _fix_context={"ws_id": ws_id, "field": "planning_order", "value": planning_order}, ) + file_flavor = normalize_flavor(meta.get("flavor")) + if file_flavor is not None and file_flavor not in WORK_RECORD_FLAVORS: + report.add( + severity="WARN", + check_id="C-36", + message=( + f"Unknown work-record flavor {file_flavor!r} in '{ws.get('slug')}'; " + f"expected one of {', '.join(WORK_RECORD_FLAVORS)}" + ), + file_path=fname, + db_id=ws_id, + file_value=file_flavor, + fixable=False, + ) + file_flavor = None + db_flavor = normalize_flavor(ws.get("flavor")) + if file_flavor != db_flavor: + report.add( + severity="WARN", check_id="C-19", + message=( + f"Flavor drift in '{ws.get('slug')}': " + f"file={file_flavor!r} db={db_flavor!r} (file wins)" + ), + file_path=fname, + db_id=ws_id, + file_value=file_flavor, + db_value=db_flavor, + fixable=True, + _fix_context={"ws_id": ws_id, "field": "flavor", "value": file_flavor}, + ) + file_reason = normalize_promotion_reason(meta.get("flavor_promotion_reason")) + if file_reason is not None and file_reason not in FLAVOR_PROMOTION_REASONS: + report.add( + severity="WARN", + check_id="C-36", + message=( + f"Unknown flavor_promotion_reason {file_reason!r} in '{ws.get('slug')}'; " + f"expected one of {', '.join(FLAVOR_PROMOTION_REASONS)}" + ), + file_path=fname, + db_id=ws_id, + file_value=file_reason, + fixable=False, + ) + file_reason = None + db_reason = normalize_promotion_reason(ws.get("flavor_promotion_reason")) + if file_reason != db_reason: + report.add( + severity="WARN", check_id="C-19", + message=( + f"Flavor promotion reason drift in '{ws.get('slug')}': " + f"file={file_reason!r} db={db_reason!r} (file wins)" + ), + file_path=fname, + db_id=ws_id, + file_value=file_reason, + db_value=db_reason, + fixable=True, + _fix_context={ + "ws_id": ws_id, + "field": "flavor_promotion_reason", + "value": file_reason, + }, + ) + if is_residual_flavor(file_flavor): + origin = str(meta.get("origin") or "").strip() + origin_ref = str(meta.get("origin_ref") or "").strip() + if not origin and not origin_ref: + report.add( + severity="WARN", + check_id="C-37", + message=( + f"Residual workplan '{ws.get('slug')}' has no origin/origin_ref " + "provenance (not invented)" + ), + file_path=fname, + db_id=ws_id, + fixable=False, + ) + # C-10, C-11, C-12: task-level checks db_task_by_id: dict[str, dict] = {} if isinstance(db_tasks, list): @@ -1688,7 +1808,7 @@ def check_repo( if dep.get("to_task_id"): existing_dep_keys.add(("task", dep["to_task_id"], rel)) - for target_wp_id in _as_list(meta.get("depends_on_workplans")): + for target_wp_id in _frontmatter_depends_on_workplans(meta): target_ws_id = workplan_id_to_ws_id.get(target_wp_id) if not target_ws_id: report.add( @@ -1717,7 +1837,7 @@ def check_repo( }, ) - for target_task_id in _as_list(meta.get("depends_on_tasks")): + for target_task_id in _frontmatter_depends_on_tasks(meta): target_sh_id = task_file_id_to_sh_id.get(target_task_id) if not target_sh_id: report.add( @@ -1849,6 +1969,36 @@ def check_repo( fixable=True, _fix_context={"task_id": t_sh_id, "description": file_description}, ) + file_task_flavor = normalize_flavor(task.get("flavor")) + if file_task_flavor is not None and file_task_flavor not in WORK_RECORD_FLAVORS: + report.add( + severity="WARN", + check_id="C-36", + message=( + f"Unknown work-record flavor {file_task_flavor!r} on task '{t_id}'" + ), + file_path=f"{fname}#{t_id}", + db_id=t_sh_id, + file_value=file_task_flavor, + fixable=False, + ) + file_task_flavor = None + db_task_flavor = normalize_flavor(db_task.get("flavor")) + if file_task_flavor != db_task_flavor: + report.add( + severity="WARN", + check_id="C-38", + message=( + f"Task flavor drift '{t_id}': " + f"file={file_task_flavor!r} db={db_task_flavor!r} (file wins)" + ), + file_path=f"{fname}#{t_id}", + db_id=t_sh_id, + file_value=file_task_flavor, + db_value=db_task_flavor, + fixable=True, + _fix_context={"task_id": t_sh_id, "flavor": file_task_flavor}, + ) elif t_id: # C-11: task exists in file but not linked to DB ws_status = ws.get("status", "") @@ -3062,6 +3212,15 @@ def fix_repo( ) continue + create_flavor = normalize_flavor(meta.get("flavor")) + if create_flavor is not None and create_flavor not in WORK_RECORD_FLAVORS: + create_flavor = None + create_reason = normalize_promotion_reason(meta.get("flavor_promotion_reason")) + if create_reason is not None and create_reason not in FLAVOR_PROMOTION_REASONS: + create_reason = None + create_from = normalize_flavor(meta.get("flavor_promoted_from")) + if create_from is not None and create_from not in WORK_RECORD_FLAVORS: + create_from = None ws_data = _api_post(api_base, "/workplans", { "id": desired_ws_id, "topic_id": topic_id, @@ -3072,6 +3231,9 @@ def fix_repo( "owner": str(meta.get("owner", "")).strip() or None, "planning_priority": str(meta.get("planning_priority", "")).strip() or None, "planning_order": _as_int_or_none(meta.get("planning_order")), + "flavor": create_flavor, + "flavor_promotion_reason": create_reason, + "flavor_promoted_from": create_from, }) if ws_data is None or (isinstance(ws_data, dict) and "_error" in ws_data): last_error = ws_data.get("_error") if isinstance(ws_data, dict) else "no response" @@ -3116,6 +3278,9 @@ def fix_repo( if raw_task_id not in (None, "", "~", "null", "None", "none") else _derived_work_record_uuid(t_id) ) + task_flavor = normalize_flavor(task.get("flavor")) + if task_flavor is not None and task_flavor not in WORK_RECORD_FLAVORS: + task_flavor = None t_data = _api_post(api_base, "/tasks", { "id": desired_task_id, "workplan_id": new_ws_id, @@ -3124,6 +3289,7 @@ def fix_repo( "status": t_status, "priority": t_priority, "assignee": task.get("assignee") or None, + "flavor": task_flavor, }) if t_data and "_error" not in t_data: t_db_id = t_data["id"] @@ -3349,6 +3515,19 @@ def fix_repo( f"C-22 FAILED: task {task_id[:8]}… description update: {result['_error']}" ) + elif issue.check_id == "C-38": + task_id = ctx["task_id"] + flavor = ctx.get("flavor") + result = _api_patch(api_base, f"/tasks/{task_id}", {"flavor": flavor}) + if result is not None and "_error" not in result: + report.fixes_applied.append( + f"C-38 fixed: task {task_id[:8]}… flavor → {flavor!r}" + ) + elif result is not None: + report.fixes_applied.append( + f"C-38 FAILED: task {task_id[:8]}… flavor → {flavor!r}: {result['_error']}" + ) + elif issue.check_id == "C-15": # T03 — writeback: DB is ahead of file — patch file to match DB. if no_writeback: diff --git a/tests/test_consistency_check.py b/tests/test_consistency_check.py index 2d74296..7eeb439 100644 --- a/tests/test_consistency_check.py +++ b/tests/test_consistency_check.py @@ -1328,6 +1328,173 @@ class TestC20DependencyDetection: assert "C-20" not in [issue.check_id for issue in report.issues] + def test_depends_on_alias_satisfies_workplan_dependency(self, tmp_path, monkeypatch): + repo = tmp_path / "repo" + workplans = repo / "workplans" + workplans.mkdir(parents=True) + (workplans / "STATE-WP-0001-base.md").write_text( + "---\n" + "id: STATE-WP-0001\n" + "title: Base\n" + "domain: financials\n" + "repo: demo-repo\n" + "status: active\n" + "state_hub_workstream_id: \"base-ws\"\n" + "---\n\n", + encoding="utf-8", + ) + (workplans / "STATE-WP-0002-dependent.md").write_text( + "---\n" + "id: STATE-WP-0002\n" + "title: Dependent\n" + "domain: financials\n" + "repo: demo-repo\n" + "status: active\n" + "state_hub_workstream_id: \"dependent-ws\"\n" + "depends_on:\n" + " - STATE-WP-0001\n" + "---\n\n", + encoding="utf-8", + ) + + def fake_get(_api_base, path, params=None, **_kwargs): + if path == "/repos/demo-repo": + import socket + + return { + "id": "repo-1", + "slug": "demo-repo", + "local_path": str(repo), + "host_paths": {socket.gethostname(): str(repo)}, + "domain_slug": "financials", + } + if path == "/workplans/base-ws": + return {"id": "base-ws", "repo_id": "repo-1", "slug": "state-wp-0001", "title": "Base", "status": "active"} + if path == "/workplans/dependent-ws": + return {"id": "dependent-ws", "repo_id": "repo-1", "slug": "state-wp-0002", "title": "Dependent", "status": "active"} + if path == "/tasks" and params and params.get("workstream_id") in {"base-ws", "dependent-ws"}: + return [] + if path == "/workplans/base-ws/dependencies": + return [] + if path == "/workplans/dependent-ws/dependencies": + return [ + { + "id": "dep-1", + "from_workplan_id": "dependent-ws", + "to_workplan_id": "base-ws", + "to_task_id": None, + "relationship_type": "blocks", + } + ] + if path == "/workplans" and params == {"repo_id": "repo-1"}: + return [] + return [] + + monkeypatch.setattr("consistency_check._api_get", fake_get) + + report = check_repo("http://unused", "demo-repo") + + assert "C-20" not in [issue.check_id for issue in report.issues] + + +class TestFlavorChecks: + def test_unknown_flavor_warns_c36(self, tmp_path, monkeypatch): + repo = tmp_path / "repo" + workplans = repo / "workplans" + workplans.mkdir(parents=True) + (workplans / "STATE-WP-0001-odd.md").write_text( + "---\n" + "id: STATE-WP-0001\n" + "title: Odd\n" + "domain: financials\n" + "repo: demo-repo\n" + "status: active\n" + "flavor: sidetrack\n" + "state_hub_workstream_id: \"odd-ws\"\n" + "---\n\n", + encoding="utf-8", + ) + + def fake_get(_api_base, path, params=None, **_kwargs): + if path == "/repos/demo-repo": + import socket + + return { + "id": "repo-1", + "slug": "demo-repo", + "local_path": str(repo), + "host_paths": {socket.gethostname(): str(repo)}, + "domain_slug": "financials", + } + if path == "/workplans/odd-ws": + return { + "id": "odd-ws", + "repo_id": "repo-1", + "slug": "state-wp-0001", + "title": "Odd", + "status": "active", + } + if path == "/tasks": + return [] + if path.endswith("/dependencies"): + return [] + if path == "/workplans": + return [] + return [] + + monkeypatch.setattr("consistency_check._api_get", fake_get) + report = check_repo("http://unused", "demo-repo") + assert "C-36" in [issue.check_id for issue in report.issues] + + def test_residual_without_origin_warns_c37(self, tmp_path, monkeypatch): + repo = tmp_path / "repo" + workplans = repo / "workplans" + workplans.mkdir(parents=True) + (workplans / "STATE-WP-0001-left.md").write_text( + "---\n" + "id: STATE-WP-0001\n" + "title: Left\n" + "domain: financials\n" + "repo: demo-repo\n" + "status: active\n" + "flavor: residual\n" + "state_hub_workstream_id: \"left-ws\"\n" + "---\n\n", + encoding="utf-8", + ) + + def fake_get(_api_base, path, params=None, **_kwargs): + if path == "/repos/demo-repo": + import socket + + return { + "id": "repo-1", + "slug": "demo-repo", + "local_path": str(repo), + "host_paths": {socket.gethostname(): str(repo)}, + "domain_slug": "financials", + } + if path == "/workplans/left-ws": + return { + "id": "left-ws", + "repo_id": "repo-1", + "slug": "state-wp-0001", + "title": "Left", + "status": "active", + "flavor": "residual", + } + if path == "/tasks": + return [] + if path.endswith("/dependencies"): + return [] + if path == "/workplans": + return [] + return [] + + monkeypatch.setattr("consistency_check._api_get", fake_get) + report = check_repo("http://unused", "demo-repo") + assert "C-37" in [issue.check_id for issue in report.issues] + class TestC06WorkstreamCreation: def test_fix_repo_bootstraps_legacy_ids_only_into_empty_projection( diff --git a/tests/test_work_record_flavor.py b/tests/test_work_record_flavor.py new file mode 100644 index 0000000..c81e910 --- /dev/null +++ b/tests/test_work_record_flavor.py @@ -0,0 +1,136 @@ +"""STATE-WP-0092: flavor indexing, residual default-exclusion, depends_on alias.""" +from __future__ import annotations + +import pytest + +from tests.test_routers_core import ( + _create_domain, + _create_repo, + _create_task, + _create_topic, + _create_workstream, +) + + +@pytest.mark.asyncio +async def test_unknown_flavor_rejected_on_create(client): + await _create_domain(client) + topic = await _create_topic(client) + repo = await _create_repo(client) + r = await client.post( + "/workplans/", + json={ + "repo_id": repo["id"], + "topic_id": topic["id"], + "slug": "bad-flavor", + "title": "Bad flavor", + "flavor": "sidetrack", + }, + ) + assert r.status_code == 422 + + +@pytest.mark.asyncio +async def test_flavor_round_trip_and_list_filters(client): + await _create_domain(client) + topic = await _create_topic(client) + relevant = await _create_workstream( + client, topic["id"], slug="rel-wp", title="Relevant", flavor="implementation" + ) + residual = await _create_workstream( + client, topic["id"], slug="res-wp", title="Leftover", flavor="residual" + ) + unspecified = await _create_workstream( + client, topic["id"], slug="unset-wp", title="Historic" + ) + + listed = await client.get("/workplans/") + ids = {row["id"] for row in listed.json()} + assert relevant["id"] in ids + assert residual["id"] in ids + assert unspecified["id"] in ids + + hidden = await client.get("/workplans/", params={"include_residuals": "false"}) + hidden_ids = {row["id"] for row in hidden.json()} + assert relevant["id"] in hidden_ids + assert unspecified["id"] in hidden_ids + assert residual["id"] not in hidden_ids + + only_residual = await client.get("/workplans/", params={"flavor": "residual"}) + only_ids = {row["id"] for row in only_residual.json()} + assert only_ids == {residual["id"]} + + got = await client.get(f"/workplans/{residual['id']}") + assert got.json()["flavor"] == "residual" + + +@pytest.mark.asyncio +async def test_summary_omits_residuals_unless_included(client): + await _create_domain(client) + topic = await _create_topic(client) + relevant = await _create_workstream( + client, topic["id"], slug="sum-rel", title="Do this", flavor="planning" + ) + residual = await _create_workstream( + client, topic["id"], slug="sum-res", title="Later maybe", flavor="residual" + ) + + summary = (await client.get("/state/summary")).json() + open_ids = {row["id"] for row in summary["open_workplans"]} + assert relevant["id"] in open_ids + assert residual["id"] not in open_ids + assert summary["totals"]["workstreams"]["residual_open"] == 1 + assert summary["residual_open_workplans"] == [] + + included = (await client.get("/state/summary", params={"include_residuals": "true"})).json() + residual_ids = {row["id"] for row in included["residual_open_workplans"]} + assert residual["id"] in residual_ids + assert relevant["id"] not in residual_ids + open_ids_still = {row["id"] for row in included["open_workplans"]} + assert residual["id"] not in open_ids_still + + deps = (await client.get("/state/deps")).json() + dep_ids = {row["id"] for row in deps} + assert relevant["id"] in dep_ids + assert residual["id"] not in dep_ids + + +@pytest.mark.asyncio +async def test_next_steps_skips_residual_workplan(client): + await _create_domain(client) + topic = await _create_topic(client) + blocker = await _create_workstream( + client, topic["id"], slug="done-dep", title="Finished dep", status="finished" + ) + residual = await _create_workstream( + client, topic["id"], slug="res-ready", title="Residual leftover", flavor="residual" + ) + r = await client.post( + f"/workplans/{residual['id']}/dependencies/", + json={"to_workstream_id": blocker["id"]}, + ) + assert r.status_code == 201 + await _create_task(client, residual["id"], title="Do not start this") + + steps = (await client.get("/state/next_steps")).json() + assert all(step.get("workplan_id") != residual["id"] for step in steps) + assert all("Residual leftover" not in (step.get("message") or "") for step in steps) + + +@pytest.mark.asyncio +async def test_task_flavor_round_trip(client): + await _create_domain(client) + topic = await _create_topic(client) + ws = await _create_workstream(client, topic["id"], slug="task-flavor-wp") + r = await client.post( + "/tasks/", + json={"workplan_id": ws["id"], "title": "Leftover task", "flavor": "residual"}, + ) + assert r.status_code == 201 + assert r.json()["flavor"] == "residual" + patched = await client.patch( + f"/tasks/{r.json()['id']}", + json={"flavor": "implementation", "suppress_token_event": True}, + ) + assert patched.status_code == 200 + assert patched.json()["flavor"] == "implementation" diff --git a/workplans/STATE-WP-0092-workplan-flavor-and-depends-on-policy.md b/workplans/STATE-WP-0092-workplan-flavor-and-depends-on-policy.md index 22fe72b..9bc6f53 100644 --- a/workplans/STATE-WP-0092-workplan-flavor-and-depends-on-policy.md +++ b/workplans/STATE-WP-0092-workplan-flavor-and-depends-on-policy.md @@ -4,7 +4,7 @@ type: workplan title: "Workplan flavor, depends_on indexing, and residual default-exclusion" domain: infotech repo: state-hub -status: proposed +status: finished owner: grok topic_slug: infotech flavor: planning @@ -62,7 +62,7 @@ parsing unknown frontmatter keys. ```task id: STATE-WP-0092-T01 -status: todo +status: done priority: high state_hub_task_id: "c5c967ec-6f4c-541e-957d-7ae9489fbf6d" ``` @@ -88,7 +88,7 @@ live leftover is equal open demand. ```task id: STATE-WP-0092-T02 -status: todo +status: done priority: high depends_on: [STATE-WP-0092-T01] state_hub_task_id: "63d9c134-0b2b-592b-a7df-a58eaa3fdc99" @@ -110,7 +110,7 @@ and C-20 no longer requires the unused `depends_on_workplans` spelling. ```task id: STATE-WP-0092-T03 -status: todo +status: done priority: high depends_on: [STATE-WP-0092-T02] state_hub_task_id: "0d53713b-d6f0-597d-8f87-9f86e5ca2235" @@ -136,7 +136,7 @@ it. ```task id: STATE-WP-0092-T04 -status: todo +status: done priority: medium depends_on: [STATE-WP-0092-T03] state_hub_task_id: "764a4b17-5119-572a-8aa1-581d719f07fa" @@ -156,7 +156,7 @@ agent snippet exists for later instruction rollout. ```task id: STATE-WP-0092-T05 -status: todo +status: done priority: high depends_on: [STATE-WP-0092-T02, STATE-WP-0092-T03] state_hub_task_id: "ab74d06b-acc4-52da-ac20-ac564ca8a7ed" @@ -171,3 +171,9 @@ flavor — that is `CUST-WP-0072`. Done when the new tests are green and `fix-consistency` on this repo does not warn on this file’s advisory `flavor:` / `depends_on` keys. + +2026-09-14: T01–T05 landed in repo. Canon v0.2 flavor list is machine-checkable. +Hub indexes `flavor` / `depends_on`, default summary/next_steps/deps omit +`flavor: residual`, and C-20 accepts `depends_on`. Live primary views change +after alembic `c6f7a8b9d0e1` is applied and this revision is deployed. Until +then CUST-WP-0072 should wait.