diff --git a/.cursor/rules/kairo-vibe-coder-api.mdc b/.cursor/rules/kairo-vibe-coder-api.mdc index 8bdd137..e76fee3 100644 --- a/.cursor/rules/kairo-vibe-coder-api.mdc +++ b/.cursor/rules/kairo-vibe-coder-api.mdc @@ -54,7 +54,9 @@ Alternativ: `Authorization: Bearer {KAIRO_ACTOR_TOKEN}` ```text GET /api/operational/me -GET /api/operational/initiatives/{id}/context +GET /api/operational/initiatives/{id}/context → enthält active_work_cycle +GET /api/initiatives/{id}/work-cycles/active → aktive Sprint-Zeitbox +POST /api/initiatives/{id}/work-cycles → {"title","status":"active"} GET /api/operational/next-action?initiative_id={id}&limit=5 … Arbeit … PATCH /api/operational/actions/{id}/status {"status":"in_progress"} diff --git a/backend/data_layer/initiative_snapshot.py b/backend/data_layer/initiative_snapshot.py index 1f38e02..6da1807 100644 --- a/backend/data_layer/initiative_snapshot.py +++ b/backend/data_layer/initiative_snapshot.py @@ -309,6 +309,12 @@ def get_initiative_steering_snapshot( method_profile_label = profile.get("label") initiative_archetype = initiative.get("archetype_key") or "initiative.generic" + from services.work_cycle import get_active_work_cycle + + active_work_cycle = get_active_work_cycle( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ) + return { "initiative_id": initiative_id, "initiative_title": initiative["title"], @@ -334,6 +340,7 @@ def get_initiative_steering_snapshot( "upcoming_milestones": upcoming_milestones, "upcoming_roadmap_items": upcoming_milestones, "next_actions": next_actions, + "active_work_cycle": active_work_cycle, "counts": { "actions_open": open_actions, "actions_blocked": blocked_actions, diff --git a/backend/main.py b/backend/main.py index d2ff092..8e964d8 100644 --- a/backend/main.py +++ b/backend/main.py @@ -80,6 +80,7 @@ from routers import ( # noqa: E402 tasks, workspace, operational, + work_cycles, ) app.include_router(auth.router) @@ -111,6 +112,7 @@ app.include_router(steering.router) app.include_router(actors.router) app.include_router(workspace.router) app.include_router(operational.router) +app.include_router(work_cycles.initiative_router) @app.get("/api/health") diff --git a/backend/migrations/025_work_cycle.sql b/backend/migrations/025_work_cycle.sql new file mode 100644 index 0000000..ff66d54 --- /dev/null +++ b/backend/migrations/025_work_cycle.sql @@ -0,0 +1,11 @@ +-- AP2.0f — work_cycle (Zeitbox/Sprint) + actions.work_cycle_id + +ALTER TABLE roadmap_items DROP CONSTRAINT IF EXISTS roadmap_items_item_type_check; +ALTER TABLE roadmap_items ADD CONSTRAINT roadmap_items_item_type_check + CHECK (item_type IN ('milestone', 'review_gate', 'maturity_stage', 'work_cycle')); + +ALTER TABLE actions + ADD COLUMN work_cycle_id UUID NULL REFERENCES roadmap_items(id) ON DELETE SET NULL; + +CREATE INDEX idx_actions_work_cycle ON actions(tenant_id, work_cycle_id) + WHERE work_cycle_id IS NOT NULL; diff --git a/backend/routers/actions.py b/backend/routers/actions.py index 6a1344e..9fcd984 100644 --- a/backend/routers/actions.py +++ b/backend/routers/actions.py @@ -31,6 +31,8 @@ class ActionUpdateRequest(BaseModel): clear_roadmap_item: bool = False sort_order: Optional[int] = None action_kind: Optional[Literal["delivery", "planning", "review"]] = None + work_cycle_id: Optional[str] = None + clear_work_cycle: bool = False class TaskCreateRequest(BaseModel): @@ -137,6 +139,8 @@ def update_action( clear_project=body.clear_project, roadmap_item_id=body.roadmap_item_id, clear_roadmap_item=body.clear_roadmap_item, + work_cycle_id=body.work_cycle_id, + clear_work_cycle=body.clear_work_cycle, sort_order=body.sort_order, action_kind=body.action_kind, ) diff --git a/backend/routers/initiatives.py b/backend/routers/initiatives.py index 2e8615a..45aa0d9 100644 --- a/backend/routers/initiatives.py +++ b/backend/routers/initiatives.py @@ -66,6 +66,7 @@ class ActionCreateRequest(BaseModel): due_at: Optional[str] = None project_id: Optional[str] = None roadmap_item_id: Optional[str] = None + work_cycle_id: Optional[str] = None assigned_actor_ids: list[str] = Field(default_factory=list) sort_order: int = 0 action_kind: Literal["delivery", "planning", "review"] = "delivery" @@ -332,6 +333,7 @@ def create_initiative_action( due_at=due_at, project_id=body.project_id, roadmap_item_id=body.roadmap_item_id, + work_cycle_id=body.work_cycle_id, assigned_actor_ids=body.assigned_actor_ids, sort_order=body.sort_order, action_kind=body.action_kind, diff --git a/backend/routers/operational.py b/backend/routers/operational.py index 6b3f474..4bec8ee 100644 --- a/backend/routers/operational.py +++ b/backend/routers/operational.py @@ -109,6 +109,7 @@ def operational_context( "lifecycle": snapshot.get("lifecycle"), "signals": snapshot.get("signals"), "attention": snapshot.get("attention"), + "active_work_cycle": snapshot.get("active_work_cycle"), } return _ok(ctx, subset) diff --git a/backend/routers/roadmap.py b/backend/routers/roadmap.py index 83e7020..c328f3b 100644 --- a/backend/routers/roadmap.py +++ b/backend/routers/roadmap.py @@ -18,7 +18,7 @@ router = APIRouter(tags=["roadmap"]) class RoadmapItemCreateRequest(BaseModel): title: str = Field(min_length=1, max_length=255) - item_type: Literal["milestone", "review_gate", "maturity_stage"] = "milestone" + item_type: Literal["milestone", "review_gate", "maturity_stage", "work_cycle"] = "milestone" goal_description: str = "" definition_of_done: list[Any] = Field(default_factory=list) status: Literal[ @@ -32,7 +32,7 @@ class RoadmapItemCreateRequest(BaseModel): class RoadmapItemUpdateRequest(BaseModel): title: Optional[str] = Field(default=None, min_length=1, max_length=255) - item_type: Optional[Literal["milestone", "review_gate", "maturity_stage"]] = None + item_type: Optional[Literal["milestone", "review_gate", "maturity_stage", "work_cycle"]] = None goal_description: Optional[str] = None definition_of_done: Optional[list[Any]] = None status: Optional[ diff --git a/backend/routers/work_cycles.py b/backend/routers/work_cycles.py new file mode 100644 index 0000000..110179a --- /dev/null +++ b/backend/routers/work_cycles.py @@ -0,0 +1,105 @@ +"""Work cycle (Sprint/Zeitbox) API — AP2.0f.""" + +from __future__ import annotations + +from datetime import date +from typing import Literal, Optional + +from capabilities import require_capability +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from services import work_cycle as work_cycle_service +from tenant_context import TenantContext + +initiative_router = APIRouter( + prefix="/api/initiatives/{initiative_id}/work-cycles", + tags=["work-cycles"], +) + + +class WorkCycleCreateRequest(BaseModel): + title: str = Field(min_length=1, max_length=255) + goal_description: str = "" + status: Literal["planned", "active"] = "planned" + target_date: Optional[date] = None + sort_order: int = 0 + + +@initiative_router.get("") +def list_work_cycles( + initiative_id: str, + ctx: TenantContext = Depends(require_capability("kairo.milestone.read")), +): + return work_cycle_service.list_work_cycles_for_initiative( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ) + + +@initiative_router.get("/active") +def get_active_work_cycle( + initiative_id: str, + ctx: TenantContext = Depends(require_capability("kairo.milestone.read")), +): + item = work_cycle_service.get_active_work_cycle( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ) + if not item: + return None + item["open_action_count"] = work_cycle_service.count_actions_in_work_cycle( + tenant_id=ctx.tenant_id, + initiative_id=initiative_id, + work_cycle_id=item["id"], + ) + return item + + +@initiative_router.post("", status_code=201) +def create_work_cycle( + initiative_id: str, + body: WorkCycleCreateRequest, + ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")), +): + try: + item = work_cycle_service.create_work_cycle( + tenant_id=ctx.tenant_id, + initiative_id=initiative_id, + title=body.title, + goal_description=body.goal_description, + status=body.status, + target_date=body.target_date, + sort_order=body.sort_order, + user_id=ctx.user_id, + ) + if body.status == "active": + item = work_cycle_service.activate_work_cycle( + tenant_id=ctx.tenant_id, + initiative_id=initiative_id, + work_cycle_id=item["id"], + user_id=ctx.user_id, + ) + return item + except ValueError as exc: + detail = str(exc) + if detail == "Initiative nicht gefunden": + raise HTTPException(status_code=404, detail=detail) from exc + raise HTTPException(status_code=400, detail=detail) from exc + + +@initiative_router.post("/{work_cycle_id}/activate") +def activate_work_cycle( + initiative_id: str, + work_cycle_id: str, + ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")), +): + try: + return work_cycle_service.activate_work_cycle( + tenant_id=ctx.tenant_id, + initiative_id=initiative_id, + work_cycle_id=work_cycle_id, + user_id=ctx.user_id, + ) + except ValueError as exc: + detail = str(exc) + if detail in ("Initiative nicht gefunden", "Zeitbox nicht gefunden"): + raise HTTPException(status_code=404, detail=detail) from exc + raise HTTPException(status_code=400, detail=detail) from exc diff --git a/backend/services/actions.py b/backend/services/actions.py index f0336d1..e54c8f5 100644 --- a/backend/services/actions.py +++ b/backend/services/actions.py @@ -11,6 +11,7 @@ from services.audit import log_audit from services.initiatives import PRIORITIES, get_initiative from services.plan_ist import validate_roadmap_item_in_initiative from services.projects import project_is_leaf +from services.work_cycle import validate_work_cycle_in_initiative ActionStatus = Literal[ "open", "ready", "in_progress", "blocked", "review_required", "done", "discarded" @@ -24,7 +25,7 @@ OPEN_ACTION_STATUSES = frozenset( ) _ACTION_COLUMNS = """ - id, tenant_id, initiative_id, project_id, roadmap_item_id, title, description, + id, tenant_id, initiative_id, project_id, roadmap_item_id, work_cycle_id, title, description, status, priority, due_at, sort_order, action_kind, created_at, updated_at """ @@ -33,7 +34,7 @@ ACTION_KINDS = frozenset({"delivery", "planning", "review"}) def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: result = dict(row) - for key in ("id", "tenant_id", "initiative_id", "project_id", "roadmap_item_id", "owner_actor_id"): + for key in ("id", "tenant_id", "initiative_id", "project_id", "roadmap_item_id", "work_cycle_id", "owner_actor_id"): if result.get(key): result[key] = str(result[key]) if result.get("created_at"): @@ -144,6 +145,7 @@ def create_action( due_at: Optional[Any] = None, project_id: Optional[str] = None, roadmap_item_id: Optional[str] = None, + work_cycle_id: Optional[str] = None, assigned_actor_ids: Optional[list[str]] = None, sort_order: int = 0, action_kind: str = "delivery", @@ -175,13 +177,19 @@ def create_action( initiative_id=initiative_id, roadmap_item_id=roadmap_item_id, ) + validate_work_cycle_in_initiative( + tenant_id=tenant_id, + initiative_id=initiative_id, + work_cycle_id=work_cycle_id, + cur=cur, + ) cur.execute( f""" INSERT INTO actions ( - tenant_id, initiative_id, project_id, roadmap_item_id, title, description, - status, priority, due_at, sort_order, action_kind + tenant_id, initiative_id, project_id, roadmap_item_id, work_cycle_id, + title, description, status, priority, due_at, sort_order, action_kind ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING {_ACTION_COLUMNS} """, ( @@ -189,6 +197,7 @@ def create_action( initiative_id, project_id, roadmap_item_id, + work_cycle_id, title, description, status, @@ -286,6 +295,8 @@ def update_action( clear_project: bool = False, roadmap_item_id: Optional[str] = None, clear_roadmap_item: bool = False, + work_cycle_id: Optional[str] = None, + clear_work_cycle: bool = False, sort_order: Optional[int] = None, action_kind: Optional[str] = None, ) -> Optional[dict[str, Any]]: @@ -345,6 +356,16 @@ def update_action( conn.close() updates.append("roadmap_item_id = %s") params.append(roadmap_item_id) + if clear_work_cycle: + updates.append("work_cycle_id = NULL") + elif work_cycle_id is not None: + validate_work_cycle_in_initiative( + tenant_id=tenant_id, + initiative_id=existing["initiative_id"], + work_cycle_id=work_cycle_id, + ) + updates.append("work_cycle_id = %s") + params.append(work_cycle_id) if sort_order is not None: updates.append("sort_order = %s") params.append(sort_order) diff --git a/backend/services/roadmap.py b/backend/services/roadmap.py index 9f576f1..bd6f4a6 100644 --- a/backend/services/roadmap.py +++ b/backend/services/roadmap.py @@ -13,14 +13,14 @@ from services.audit import log_audit from services.initiatives import get_initiative from services.roadmap_criteria import create_default_criterion, prepare_criteria_for_verify -RoadmapItemType = Literal["milestone", "review_gate", "maturity_stage"] +RoadmapItemType = Literal["milestone", "review_gate", "maturity_stage", "work_cycle"] RoadmapItemStatus = Literal[ "planned", "active", "at_risk", "reached", "moved", "discarded" ] SequencingMode = Literal["sequential", "parallel", "optional"] DependencyType = Literal["requires", "blocks", "related", "parallel_group", "optional_branch"] -ROADMAP_ITEM_TYPES = frozenset({"milestone", "review_gate", "maturity_stage"}) +ROADMAP_ITEM_TYPES = frozenset({"milestone", "review_gate", "maturity_stage", "work_cycle"}) ROADMAP_ITEM_STATUSES = frozenset( {"planned", "active", "at_risk", "reached", "moved", "discarded"} ) @@ -277,14 +277,15 @@ def create_roadmap_item( ) row = _serialize_row(dict(cur.fetchone())) row["initiative_id"] = initiative_id - create_default_criterion( - cur, - tenant_id=tenant_id, - roadmap_item_id=row["id"], - title=(initial_criterion_title or "Gate allgemein").strip() - or "Gate allgemein", - description=goal_description or "", - ) + if item_type != "work_cycle": + create_default_criterion( + cur, + tenant_id=tenant_id, + roadmap_item_id=row["id"], + title=(initial_criterion_title or "Gate allgemein").strip() + or "Gate allgemein", + description=goal_description or "", + ) _sync_milestone_compat_row( cur, tenant_id=tenant_id, diff --git a/backend/services/work_cycle.py b/backend/services/work_cycle.py new file mode 100644 index 0000000..a0003e4 --- /dev/null +++ b/backend/services/work_cycle.py @@ -0,0 +1,195 @@ +"""Work cycle (Sprint/Zeitbox) — AP2.0f on RoadmapItem(type=work_cycle).""" + +from __future__ import annotations + +from datetime import date +from typing import Any, Optional + +from psycopg2.extras import RealDictCursor + +from db import get_connection +from services.audit import log_audit +from services.initiatives import get_initiative +from services.roadmap import create_roadmap_item, list_roadmap_items_for_initiative + +WORK_CYCLE_TYPE = "work_cycle" +OPEN_CYCLE_STATUSES = frozenset({"planned", "active", "at_risk"}) + + +def _serialize_cycle(item: dict[str, Any]) -> dict[str, Any]: + return { + "id": item["id"], + "initiative_id": item.get("initiative_id"), + "title": item["title"], + "goal_description": item.get("goal_description") or "", + "status": item["status"], + "target_date": item.get("target_date"), + "sort_order": item.get("sort_order", 0), + "item_type": item.get("item_type", WORK_CYCLE_TYPE), + } + + +def list_work_cycles_for_initiative( + *, tenant_id: str, initiative_id: str +) -> list[dict[str, Any]]: + items = list_roadmap_items_for_initiative( + tenant_id=tenant_id, initiative_id=initiative_id + ) + return [ + _serialize_cycle(item) + for item in items + if item.get("item_type") == WORK_CYCLE_TYPE + ] + + +def get_active_work_cycle( + *, tenant_id: str, initiative_id: str +) -> Optional[dict[str, Any]]: + cycles = list_work_cycles_for_initiative( + tenant_id=tenant_id, initiative_id=initiative_id + ) + active = [c for c in cycles if c["status"] == "active"] + if not active: + return None + active.sort(key=lambda c: (c.get("sort_order", 0), c["id"])) + return active[0] + + +def validate_work_cycle_in_initiative( + *, + tenant_id: str, + initiative_id: str, + work_cycle_id: Optional[str], + cur=None, +) -> None: + if not work_cycle_id: + return + + sql = """ + SELECT ri.item_type + FROM roadmap_items ri + JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id + WHERE ri.id = %s AND ri.tenant_id = %s AND r.initiative_id = %s + """ + params = (work_cycle_id, tenant_id, initiative_id) + + if cur is not None: + cur.execute(sql, params) + row = cur.fetchone() + else: + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cursor: + cursor.execute(sql, params) + row = cursor.fetchone() + finally: + conn.close() + + if not row: + raise ValueError("Zeitbox gehört nicht zu diesem Vorhaben") + item_type = row["item_type"] if isinstance(row, dict) else row[0] + if item_type != WORK_CYCLE_TYPE: + raise ValueError("work_cycle_id muss auf RoadmapItem(type=work_cycle) verweisen") + + +def create_work_cycle( + *, + tenant_id: str, + initiative_id: str, + title: str, + goal_description: str = "", + status: str = "planned", + target_date: Optional[date] = None, + sort_order: int = 0, + user_id: Optional[str] = None, +) -> dict[str, Any]: + if status == "reached": + raise ValueError("Status 'reached' für work_cycle über complete-Endpoint") + item = create_roadmap_item( + tenant_id=tenant_id, + initiative_id=initiative_id, + title=title, + item_type="work_cycle", + goal_description=goal_description, + status=status, # type: ignore[arg-type] + target_date=target_date, + sort_order=sort_order, + initial_criterion_title=None, + user_id=user_id, + ) + return _serialize_cycle(item) + + +def activate_work_cycle( + *, + tenant_id: str, + initiative_id: str, + work_cycle_id: str, + user_id: Optional[str] = None, +) -> dict[str, Any]: + validate_work_cycle_in_initiative( + tenant_id=tenant_id, + initiative_id=initiative_id, + work_cycle_id=work_cycle_id, + ) + if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): + raise ValueError("Initiative nicht gefunden") + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + UPDATE roadmap_items ri + SET status = 'planned', updated_at = NOW() + FROM roadmaps r + WHERE ri.roadmap_id = r.id + AND r.initiative_id = %s AND ri.tenant_id = %s + AND ri.item_type = %s AND ri.status = 'active' + AND ri.id <> %s + """, + (initiative_id, tenant_id, WORK_CYCLE_TYPE, work_cycle_id), + ) + cur.execute( + """ + UPDATE roadmap_items + SET status = 'active', updated_at = NOW() + WHERE id = %s AND tenant_id = %s + RETURNING id, title, goal_description, status, target_date, sort_order, item_type + """, + (work_cycle_id, tenant_id), + ) + row = cur.fetchone() + if not row: + raise ValueError("Zeitbox nicht gefunden") + conn.commit() + finally: + conn.close() + + result = _serialize_cycle({**dict(row), "initiative_id": initiative_id}) + log_audit( + "work_cycle.activated", + user_id=user_id, + tenant_id=tenant_id, + details={"work_cycle_id": work_cycle_id, "initiative_id": initiative_id}, + ) + return result + + +def count_actions_in_work_cycle( + *, tenant_id: str, initiative_id: str, work_cycle_id: str +) -> int: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT COUNT(*) FROM actions + WHERE tenant_id = %s AND initiative_id = %s AND work_cycle_id = %s + AND status NOT IN ('done', 'discarded') + """, + (tenant_id, initiative_id, work_cycle_id), + ) + return int(cur.fetchone()[0]) + finally: + conn.close() diff --git a/backend/steering/methods/registrations/ap20_method_stubs.py b/backend/steering/methods/registrations/ap20_method_stubs.py index 756769b..5de5f31 100644 --- a/backend/steering/methods/registrations/ap20_method_stubs.py +++ b/backend/steering/methods/registrations/ap20_method_stubs.py @@ -34,7 +34,7 @@ def register() -> None: key="agile_iteration", label="Iterations-Zeitbox", description="Sprint/work_cycle Profil auf Product/Programm", - next_action_strategy_key="default", + next_action_strategy_key="agile_iteration", ) register_stub_method( key="dispute_procedure", diff --git a/backend/steering/strategies/next_action/__init__.py b/backend/steering/strategies/next_action/__init__.py index 46cf9be..ed4a802 100644 --- a/backend/steering/strategies/next_action/__init__.py +++ b/backend/steering/strategies/next_action/__init__.py @@ -2,6 +2,9 @@ from __future__ import annotations +from steering.strategies.next_action.agile_iteration import ( + register_agile_iteration_strategy, +) from steering.strategies.next_action.continuous_product import ( continuous_product_strategy, register_continuous_product_strategy, @@ -40,3 +43,4 @@ def register_builtin_strategies() -> None: register_maturity_progression_strategy() register_recurring_control_strategy() register_queue_pull_strategy() + register_agile_iteration_strategy() diff --git a/backend/steering/strategies/next_action/agile_iteration.py b/backend/steering/strategies/next_action/agile_iteration.py new file mode 100644 index 0000000..fecdeac --- /dev/null +++ b/backend/steering/strategies/next_action/agile_iteration.py @@ -0,0 +1,103 @@ +"""Agile iteration NextAction strategy — AP2.0f (active work_cycle first).""" + +from __future__ import annotations + +from typing import Any + +from steering.signals import default_rules +from steering.strategies.next_action.continuous_product import ( + ContinuousProductStrategy, +) +from steering.strategies.next_action.default_strategy import DefaultNextActionStrategy +from steering.strategies.next_action.execution_ready import ( + blocked_execution_action_ids, + build_execution_ready_candidates, + list_execution_ready_candidates, + merge_candidates, +) +from steering.strategies.next_action.registry import ( + get_next_action_strategy, + register_next_action_strategy, +) +from services.work_cycle import get_active_work_cycle +from tenant_context import TenantContext + +_default = DefaultNextActionStrategy() +_continuous = ContinuousProductStrategy() + + +class AgileIterationStrategy: + key = "agile_iteration" + + def evaluate( + self, + ctx: TenantContext, + *, + initiative_id: str | None = None, + limit: int = 10, + ) -> list[dict[str, Any]]: + if limit < 1: + limit = 1 + if not initiative_id: + return _default.evaluate(ctx, limit=limit) + + active = get_active_work_cycle( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ) + if not active: + return _continuous.evaluate(ctx, initiative_id=initiative_id, limit=limit) + + cycle_id = active["id"] + from services import actions as action_service + from steering.graph.execution_engine import load_initiative_execution_graph_state + + actions = action_service.list_actions_for_initiative( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ) + cycle_actions = [ + a for a in actions if str(a.get("work_cycle_id") or "") == cycle_id + ] + if not cycle_actions: + ready = list_execution_ready_candidates( + ctx, initiative_id, limit=limit, prefer_critical_path=True + ) + if ready: + return ready[:limit] + return _continuous.evaluate(ctx, initiative_id=initiative_id, limit=limit) + + graph_state = load_initiative_execution_graph_state( + tenant_id=ctx.tenant_id, + initiative_id=initiative_id, + ) + blocked_ids = blocked_execution_action_ids(ctx, initiative_id) + ready = build_execution_ready_candidates( + actions=cycle_actions, + graph_state=graph_state, + limit=limit, + prefer_critical_path=True, + ) + for item in ready: + item["reason_code"] = "work_cycle_ready" + item["summary"] = f"Aktive Zeitbox „{active['title']}“ — ausführungsbereit" + item["recommended_action"] = "In dieser Iteration ausführen" + + if len(ready) >= limit: + return ready[:limit] + + rest = _continuous.evaluate( + ctx, initiative_id=initiative_id, limit=limit - len(ready) + ) + filtered = [ + item + for item in rest + if not item.get("action_id") or item["action_id"] not in blocked_ids + ] + return merge_candidates(ready, filtered, limit=limit, exclude_action_ids=blocked_ids) + + +agile_iteration_strategy = AgileIterationStrategy() + + +def register_agile_iteration_strategy() -> None: + if not get_next_action_strategy(agile_iteration_strategy.key): + register_next_action_strategy(agile_iteration_strategy) diff --git a/backend/tests/test_ap20f_work_cycle.py b/backend/tests/test_ap20f_work_cycle.py new file mode 100644 index 0000000..43ec486 --- /dev/null +++ b/backend/tests/test_ap20f_work_cycle.py @@ -0,0 +1,82 @@ +"""AP2.0f — work_cycle / Sprint-Zeitbox.""" + +from __future__ import annotations + +from steering.strategies.next_action import register_builtin_strategies +from steering.strategies.next_action.registry import get_next_action_strategy +from tests.factories import provision_user_in_tenant +from tests.test_initiatives_actions import _auth, _create_initiative, _login + + +def test_agile_iteration_strategy_registered(): + register_builtin_strategies() + assert get_next_action_strategy("agile_iteration") is not None + + +def test_work_cycle_crud_and_action_link(client): + user = provision_user_in_tenant(tenant_role="admin") + token = _login(client, user) + + created = _create_initiative( + client, + token, + title="Sprint Product", + archetype_key="initiative.product", + ) + initiative_id = created.json()["id"] + + cycle = client.post( + f"/api/initiatives/{initiative_id}/work-cycles", + json={"title": "Iteration R1", "goal_description": "AP2.0f", "status": "active"}, + headers=_auth(token), + ) + assert cycle.status_code == 201 + cycle_id = cycle.json()["id"] + assert cycle.json()["status"] == "active" + + active = client.get( + f"/api/initiatives/{initiative_id}/work-cycles/active", + headers=_auth(token), + ) + assert active.status_code == 200 + assert active.json()["id"] == cycle_id + + action = client.post( + f"/api/initiatives/{initiative_id}/actions", + json={ + "title": "Sprint AP A", + "work_cycle_id": cycle_id, + "status": "ready", + }, + headers=_auth(token), + ) + assert action.status_code == 201 + assert action.json()["work_cycle_id"] == cycle_id + + listed = client.get( + f"/api/initiatives/{initiative_id}/work-cycles", + headers=_auth(token), + ) + assert listed.status_code == 200 + assert any(item["id"] == cycle_id for item in listed.json()) + + +def test_steering_snapshot_includes_active_work_cycle(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + created = _create_initiative(client, token, title="Snapshot Cycle") + initiative_id = created.json()["id"] + + client.post( + f"/api/initiatives/{initiative_id}/work-cycles", + json={"title": "Sprint 1", "status": "active"}, + headers=_auth(token), + ) + + snap = client.get( + f"/api/initiatives/{initiative_id}/steering-snapshot", + headers=_auth(token), + ) + assert snap.status_code == 200 + assert snap.json().get("active_work_cycle") + assert snap.json()["active_work_cycle"]["title"] == "Sprint 1" diff --git a/backend/version.py b/backend/version.py index 6ed8da6..9cc4614 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "0.19.0-ap1.7" -DB_SCHEMA_VERSION = "024" +APP_VERSION = "0.19.0-ap2.0f" +DB_SCHEMA_VERSION = "025" APP_NAME = "jinkendo-kairo" diff --git a/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md b/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md index aee8641..ca3823e 100644 --- a/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md +++ b/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md @@ -186,7 +186,7 @@ Siehe **`Kairo_Status_Review_and_Next_Steps_v0.1.md` §5** für vollständige Ro | 4 | AP1.16c–d Plan-Outline-Kanten + Planning Debt | **◐ Code** | Remote-Verifikation nach Deploy | | 5 | Dogfooding R2 — Ist + Gitea-Evidence | **→ nächstes** | Seed sync + manuelle Abnahfe | | 6 | AP1.7 Operational Actor API | **◐ Code** | `/api/operational/` + Service Tokens | -| 7 | AP2.0f work_cycle (B3 minimal) | offen | Sprint-Zeitbox | +| 7 | AP2.0f work_cycle (B3 minimal) | **◐ Code** | Sprint-Zeitbox; Remote-Verifikation nach Deploy | | 8 | AP1.7 Operational API | offen | MCP-Voraussetzung | | 9 | Gitea-Webhook + MCP | deferred | Schicht 4 | diff --git a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md index e493a9d..0d368a1 100644 --- a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md +++ b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md @@ -66,7 +66,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden. | Method Profiles (Code-Seeds) | ◐ | `product.kairo_dev`, Kumite, Buch — AP2.0b | | Execution Graph Engine | ◐ | AP1.16b; API `/execution/graph-state` | | Planning Debt (Attention) | ◐ | AP1.16d: Attention + Cockpit-Kacheln AP1.9c | -| `work_cycle` / Sprint | ✗ | 📄 MVP v0.3 B3; Migration AP2.0f geplant | +| `work_cycle` / Sprint | ◐ | AP2.0f Code: RoadmapItem + actions.work_cycle_id; Remote-Verifikation | --- @@ -208,7 +208,7 @@ Details: `Kairo_Status_Review_and_Next_Steps_v0.1.md` §2.1 | AP1.9c | Cockpit-Signale ◐→✓ | | AP2.0d | Strategien ◐ Code | | Dogfooding R1 | Referenz-Vorhaben in Alltag ◐ | Seed R2 sync nach Deploy | -| AP2.0f | work_cycle ○→◐ | +| AP2.0f | work_cycle ◐ Code | | AP1.7 | Operational API ◐ Code | ---