diff --git a/backend/data_layer/initiative_snapshot.py b/backend/data_layer/initiative_snapshot.py index 6c4760b..a5b8945 100644 --- a/backend/data_layer/initiative_snapshot.py +++ b/backend/data_layer/initiative_snapshot.py @@ -16,6 +16,7 @@ from steering.context import get_steering_context_dto from steering.kernel import evaluate_steering from steering.signals.snapshot_signals import derive_initiative_signals from services.initiatives import get_initiative +from services.progression_graph import compute_progression_state from tenant_context import TenantContext OPEN_BLOCKER = ("open", "in_progress") @@ -383,6 +384,9 @@ def get_initiative_steering_snapshot( "actions": actions, "roadmap_items": roadmap_items, "milestones": roadmap_items, + "progression_state": compute_progression_state( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ), "initiative_level": { "blockers": unlinked_blockers, "evidence": unlinked_evidence, diff --git a/backend/migrations/032_a1_progression_join.sql b/backend/migrations/032_a1_progression_join.sql new file mode 100644 index 0000000..c10eca7 --- /dev/null +++ b/backend/migrations/032_a1_progression_join.sql @@ -0,0 +1,26 @@ +-- AP-A1-PM-2: Progression graph — join_gate, strand binding, recurring strand + +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', 'join_gate' + )); + +ALTER TABLE roadmap_items + ADD COLUMN IF NOT EXISTS strand_project_id UUID NULL + REFERENCES projects(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_roadmap_items_strand + ON roadmap_items(tenant_id, strand_project_id) + WHERE strand_project_id IS NOT NULL; + +ALTER TABLE recurring_elements + ADD COLUMN IF NOT EXISTS project_id UUID NULL + REFERENCES projects(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_recurring_elements_project + ON recurring_elements(tenant_id, project_id) + WHERE project_id IS NOT NULL; diff --git a/backend/routers/roadmap.py b/backend/routers/roadmap.py index c328f3b..a6d9067 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", "work_cycle"] = "milestone" + item_type: Literal["milestone", "review_gate", "maturity_stage", "work_cycle", "join_gate"] = "milestone" goal_description: str = "" definition_of_done: list[Any] = Field(default_factory=list) status: Literal[ @@ -27,12 +27,13 @@ class RoadmapItemCreateRequest(BaseModel): sequencing_mode: Literal["sequential", "parallel", "optional"] = "sequential" target_date: Optional[date] = None sort_order: int = 0 + strand_project_id: Optional[str] = None initial_criterion_title: Optional[str] = Field(default=None, max_length=500) class RoadmapItemUpdateRequest(BaseModel): title: Optional[str] = Field(default=None, min_length=1, max_length=255) - item_type: Optional[Literal["milestone", "review_gate", "maturity_stage", "work_cycle"]] = None + item_type: Optional[Literal["milestone", "review_gate", "maturity_stage", "work_cycle", "join_gate"]] = None goal_description: Optional[str] = None definition_of_done: Optional[list[Any]] = None status: Optional[ @@ -42,6 +43,8 @@ class RoadmapItemUpdateRequest(BaseModel): target_date: Optional[date] = None clear_target_date: bool = False sort_order: Optional[int] = None + strand_project_id: Optional[str] = None + clear_strand_project: bool = False class DependencyCreateRequest(BaseModel): @@ -166,6 +169,21 @@ def get_initiative_roadmap_graph_state( raise HTTPException(status_code=400, detail=str(exc)) from exc +@initiative_router.get("/{initiative_id}/roadmap/progression-state") +def get_initiative_progression_state( + initiative_id: str, + ctx: TenantContext = Depends(require_capability("kairo.milestone.read")), +): + try: + from services.progression_graph import compute_progression_state + + return compute_progression_state( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @initiative_router.get("/{initiative_id}/roadmap/plan-ist-view") def get_initiative_plan_ist_view( initiative_id: str, @@ -228,6 +246,7 @@ def create_initiative_roadmap_item( sequencing_mode=body.sequencing_mode, target_date=body.target_date, sort_order=body.sort_order, + strand_project_id=body.strand_project_id, initial_criterion_title=body.initial_criterion_title, ) except ValueError as exc: @@ -265,6 +284,8 @@ def update_roadmap_item( target_date=body.target_date, clear_target_date=body.clear_target_date, sort_order=body.sort_order, + strand_project_id=body.strand_project_id, + clear_strand_project=body.clear_strand_project, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc diff --git a/backend/services/archetype_starter_kit.py b/backend/services/archetype_starter_kit.py index 7da7fb3..c61f18d 100644 --- a/backend/services/archetype_starter_kit.py +++ b/backend/services/archetype_starter_kit.py @@ -312,6 +312,130 @@ def _apply_a2( } +def apply_a1_progression_demo( + *, + tenant_id: str, + initiative_id: str, + user_id: Optional[str] = None, +) -> dict[str, Any]: + """PO-Kernbild: 2 Stränge → Join → Ausgänge (AP-A1-PM-6). Idempotent wenn Struktur existiert.""" + from services.maturity_practice import seed_practices_for_stage + from services.projects import create_project, list_projects_for_initiative + from services.roadmap import add_dependency, create_roadmap_item, list_roadmap_items_for_initiative + + existing = list_roadmap_items_for_initiative( + tenant_id=tenant_id, initiative_id=initiative_id + ) + if any(item.get("item_type") == "join_gate" for item in existing): + return {"applied": False, "reason": "progression_exists"} + + projects = list_projects_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id) + dehnung = _find_by_title(projects, "Dehnung") + if not dehnung: + dehnung = create_project( + tenant_id=tenant_id, + initiative_id=initiative_id, + title="Dehnung", + description="Strang Dehnung", + container_kind="stream", + sort_order=0, + user_id=user_id, + ) + huft = _find_by_title(projects, "Hüftrotation") + if not huft: + huft = create_project( + tenant_id=tenant_id, + initiative_id=initiative_id, + title="Hüftrotation", + description="Strang Hüftrotation", + container_kind="stream", + sort_order=1, + user_id=user_id, + ) + + d1 = create_roadmap_item( + tenant_id=tenant_id, + initiative_id=initiative_id, + title="Dehnung — Einstieg", + item_type="maturity_stage", + status="active", + sort_order=10, + strand_project_id=dehnung["id"], + user_id=user_id, + ) + h1 = create_roadmap_item( + tenant_id=tenant_id, + initiative_id=initiative_id, + title="Hüftrotation — Einstieg", + item_type="maturity_stage", + status="active", + sort_order=20, + strand_project_id=huft["id"], + user_id=user_id, + ) + join = create_roadmap_item( + tenant_id=tenant_id, + initiative_id=initiative_id, + title="Mawashi Geri zur Hüfte", + item_type="join_gate", + status="planned", + sort_order=30, + user_id=user_id, + ) + d2 = create_roadmap_item( + tenant_id=tenant_id, + initiative_id=initiative_id, + title="Dehnung — nach Join", + item_type="maturity_stage", + status="planned", + sort_order=40, + strand_project_id=dehnung["id"], + user_id=user_id, + ) + h2 = create_roadmap_item( + tenant_id=tenant_id, + initiative_id=initiative_id, + title="Hüftrotation — nach Join", + item_type="maturity_stage", + status="planned", + sort_order=50, + strand_project_id=huft["id"], + user_id=user_id, + ) + + for from_id, to_id in ( + (join["id"], d1["id"]), + (join["id"], h1["id"]), + (d2["id"], join["id"]), + (h2["id"], join["id"]), + ): + try: + add_dependency( + tenant_id=tenant_id, + from_item_id=from_id, + to_item_id=to_id, + dependency_type="requires", + user_id=user_id, + ) + except ValueError as exc: + if "existiert bereits" not in str(exc): + raise + + seed_practices_for_stage( + tenant_id=tenant_id, initiative_id=initiative_id, stage=d1, user_id=user_id + ) + seed_practices_for_stage( + tenant_id=tenant_id, initiative_id=initiative_id, stage=h1, user_id=user_id + ) + + return { + "applied": True, + "projects": ["Dehnung", "Hüftrotation"], + "join": join["title"], + "active_gates": [d1["title"], h1["title"]], + } + + def _apply_b2b( *, tenant_id: str, diff --git a/backend/services/maturity_practice.py b/backend/services/maturity_practice.py index 910bf8f..b074deb 100644 --- a/backend/services/maturity_practice.py +++ b/backend/services/maturity_practice.py @@ -25,12 +25,24 @@ STAGE_PRACTICE_SETS: dict[str, list[tuple[str, str]]] = { "Stufe 3 — Ziel": [ ("Voller Spagat", "Täglich Zielposition — Messwert dokumentieren"), ], + "Dehnung — Einstieg": [ + ("Vorbeuge halten", "Täglich 3×30 Sek."), + ], + "Hüftrotation — Einstieg": [ + ("Hüftkreisen im Stand", "Täglich 10 Wiederholungen je Richtung"), + ], + "Dehnung — nach Join": [ + ("Enddehnung Mawashi-Vorbereitung", "Täglich 2×45 Sek."), + ], + "Hüftrotation — nach Join": [ + ("Explosive Hüftrotation", "Täglich 3×8 Wiederholungen"), + ], } -def get_active_maturity_stage( +def get_active_maturity_stages( *, tenant_id: str, initiative_id: str -) -> Optional[dict[str, Any]]: +) -> list[dict[str, Any]]: from services.roadmap import list_roadmap_items_for_initiative stages = [ @@ -40,34 +52,17 @@ def get_active_maturity_stage( ) if item.get("item_type") == "maturity_stage" and item.get("status") == "active" ] - if not stages: - return None stages.sort(key=lambda s: (s.get("sort_order", 0), s.get("title", ""))) - return stages[0] + return stages -def enforce_single_active_maturity_stage( - cur, - *, - tenant_id: str, - initiative_id: str, - keep_active_id: str, -) -> None: - """At most one maturity_stage may be active.""" - cur.execute( - """ - UPDATE roadmap_items ri - SET status = 'planned', updated_at = NOW() - FROM roadmaps r - WHERE ri.roadmap_id = r.id - AND ri.tenant_id = %s - AND r.initiative_id = %s - AND ri.item_type = 'maturity_stage' - AND ri.status IN ('active', 'at_risk') - AND ri.id != %s - """, - (tenant_id, initiative_id, keep_active_id), +def get_active_maturity_stage( + *, tenant_id: str, initiative_id: str +) -> Optional[dict[str, Any]]: + stages = get_active_maturity_stages( + tenant_id=tenant_id, initiative_id=initiative_id ) + return stages[0] if stages else None def list_practices_for_stage( @@ -107,6 +102,34 @@ def pause_all_practices( return paused +def pause_practices_except_stage( + *, + tenant_id: str, + initiative_id: str, + keep_stage_id: str, + strand_project_id: Optional[str] = None, + user_id: Optional[str] = None, +) -> list[str]: + paused: list[str] = [] + for item in list_recurring_for_initiative( + tenant_id=tenant_id, initiative_id=initiative_id + ): + if item.get("status") != "active": + continue + if item.get("roadmap_item_id") == keep_stage_id: + continue + if strand_project_id is not None and item.get("project_id") != strand_project_id: + continue + update_recurring_element( + tenant_id=tenant_id, + recurring_id=item["id"], + status="paused", + user_id=user_id, + ) + paused.append(item["title"]) + return paused + + def seed_practices_for_stage( *, tenant_id: str, @@ -117,6 +140,7 @@ def seed_practices_for_stage( """Idempotent: create missing exercises for a stage; activate existing.""" stage_id = stage["id"] stage_title = stage.get("title") or "" + strand_project_id = stage.get("strand_project_id") defs = STAGE_PRACTICE_SETS.get(stage_title) if not defs: defs = [ @@ -161,6 +185,7 @@ def seed_practices_for_stage( interval_days=1, next_due_at=now, roadmap_item_id=stage_id, + project_id=strand_project_id, user_id=user_id, ) created.append(row) @@ -175,9 +200,19 @@ def activate_stage_practice_set( stage: dict[str, Any], user_id: Optional[str] = None, ) -> dict[str, Any]: - paused = pause_all_practices( - tenant_id=tenant_id, initiative_id=initiative_id, user_id=user_id - ) + strand_project_id = stage.get("strand_project_id") + if strand_project_id: + paused = pause_practices_except_stage( + tenant_id=tenant_id, + initiative_id=initiative_id, + keep_stage_id=stage["id"], + strand_project_id=strand_project_id, + user_id=user_id, + ) + else: + paused = pause_all_practices( + tenant_id=tenant_id, initiative_id=initiative_id, user_id=user_id + ) exercises = seed_practices_for_stage( tenant_id=tenant_id, initiative_id=initiative_id, diff --git a/backend/services/maturity_stage_transition.py b/backend/services/maturity_stage_transition.py index 9c8c8c0..b07be1f 100644 --- a/backend/services/maturity_stage_transition.py +++ b/backend/services/maturity_stage_transition.py @@ -1,12 +1,9 @@ -"""AP2.0e — Recurring transition when a maturity_stage is reached.""" +"""AP2.0e — maturity stage transition on verify reached (delegates to progression_graph).""" from __future__ import annotations from typing import Any, Optional -from services.maturity_practice import activate_stage_practice_set -from services.roadmap import get_roadmap_item, list_roadmap_items_for_initiative - def on_maturity_stage_reached( *, @@ -15,61 +12,14 @@ def on_maturity_stage_reached( reached_item_id: str, user_id: Optional[str] = None, ) -> Optional[dict[str, Any]]: - """Pause old routines, activate next stage, seed exercise set for next stage.""" - item = get_roadmap_item(tenant_id=tenant_id, item_id=reached_item_id) - if not item or item.get("item_type") != "maturity_stage": - return None + from services.progression_graph import on_work_gate_reached - stages = [ - s - for s in list_roadmap_items_for_initiative( - tenant_id=tenant_id, initiative_id=initiative_id - ) - if s.get("item_type") == "maturity_stage" - ] - stages.sort(key=lambda s: (s.get("sort_order", 0), s.get("title", ""))) - - reached_index = next( - (index for index, stage in enumerate(stages) if stage["id"] == reached_item_id), - None, + return on_work_gate_reached( + tenant_id=tenant_id, + initiative_id=initiative_id, + reached_item_id=reached_item_id, + user_id=user_id, ) - if reached_index is None: - return None - - next_stage = None - for stage in stages[reached_index + 1 :]: - if stage.get("status") in {"planned", "active", "at_risk"}: - next_stage = stage - break - - practice_transition = None - if next_stage: - from services.roadmap import update_roadmap_item - - update_roadmap_item( - tenant_id=tenant_id, - item_id=next_stage["id"], - status="active", - user_id=user_id, - ) - next_stage = get_roadmap_item(tenant_id=tenant_id, item_id=next_stage["id"]) - if next_stage: - practice_transition = activate_stage_practice_set( - tenant_id=tenant_id, - initiative_id=initiative_id, - stage=next_stage, - user_id=user_id, - ) - - return { - "reached_stage_id": reached_item_id, - "next_stage_id": next_stage["id"] if next_stage else None, - "paused_recurring": (practice_transition or {}).get("paused_practices", []), - "new_recurring_id": ( - (practice_transition or {}).get("practice_ids") or [None] - )[0], - "active_practices": (practice_transition or {}).get("active_practices", []), - } def on_maturity_stage_reopened( @@ -79,7 +29,10 @@ def on_maturity_stage_reopened( stage_id: str, user_id: Optional[str] = None, ) -> dict[str, Any]: - """Re-enable exercise set for reopened stage; pause others.""" + """Re-enable exercise set for reopened stage; pause others on same strand.""" + from services.maturity_practice import activate_stage_practice_set + from services.roadmap import get_roadmap_item + stage = get_roadmap_item(tenant_id=tenant_id, item_id=stage_id) if not stage: return {"stage_id": stage_id, "active_practices": []} diff --git a/backend/services/progression_graph.py b/backend/services/progression_graph.py new file mode 100644 index 0000000..9b3cad8 --- /dev/null +++ b/backend/services/progression_graph.py @@ -0,0 +1,546 @@ +"""A1 progression graph — multi-strand work gates, join switch nodes (PO-Lock §10).""" + +from __future__ import annotations + +from typing import Any, Optional + +from psycopg2.extras import RealDictCursor + +from db import get_connection + +WORK_GATE_TYPES = frozenset({"maturity_stage"}) +JOIN_GATE_TYPE = "join_gate" +ACTIVE_WORK_STATUSES = frozenset({"active", "at_risk"}) +TERMINAL_STATUSES = frozenset({"reached", "moved", "discarded"}) + + +def is_work_gate(item: dict[str, Any]) -> bool: + return item.get("item_type") in WORK_GATE_TYPES + + +def is_join_gate(item: dict[str, Any]) -> bool: + return item.get("item_type") == JOIN_GATE_TYPE + + +def _requires_deps(dependencies: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [ + dep + for dep in dependencies + if (dep.get("dependency_type") or "requires") == "requires" + ] + + +def join_input_gate_ids( + join_id: str, dependencies: list[dict[str, Any]] +) -> list[str]: + """Join requires input gates (from=join, to=input).""" + return [ + str(dep["to_item_id"]) + for dep in _requires_deps(dependencies) + if str(dep["from_item_id"]) == str(join_id) + ] + + +def join_output_gate_ids( + join_id: str, dependencies: list[dict[str, Any]] +) -> list[str]: + """Output gates require join (from=output, to=join).""" + return [ + str(dep["from_item_id"]) + for dep in _requires_deps(dependencies) + if str(dep["to_item_id"]) == str(join_id) + ] + + +def successor_work_gate_ids( + gate_id: str, dependencies: list[dict[str, Any]] +) -> list[str]: + """Work gates that require this gate (from=successor, to=gate).""" + return [ + str(dep["from_item_id"]) + for dep in _requires_deps(dependencies) + if str(dep["to_item_id"]) == str(gate_id) + ] + + +def list_active_work_gates( + *, tenant_id: str, initiative_id: str +) -> list[dict[str, Any]]: + from services.roadmap import list_roadmap_items_for_initiative + + items = list_roadmap_items_for_initiative( + tenant_id=tenant_id, initiative_id=initiative_id + ) + active = [ + item + for item in items + if is_work_gate(item) and item.get("status") in ACTIVE_WORK_STATUSES + ] + active.sort(key=lambda item: (item.get("sort_order", 0), item.get("title", ""))) + return active + + +def compute_join_states( + *, + tenant_id: str, + initiative_id: str, +) -> list[dict[str, Any]]: + from services.projects import list_projects_for_initiative + from services.roadmap import list_dependencies_for_initiative, list_roadmap_items_for_initiative + + items = list_roadmap_items_for_initiative( + tenant_id=tenant_id, initiative_id=initiative_id + ) + item_by_id = {str(item["id"]): item for item in items} + dependencies = list_dependencies_for_initiative( + tenant_id=tenant_id, initiative_id=initiative_id + ) + projects = list_projects_for_initiative( + tenant_id=tenant_id, initiative_id=initiative_id + ) + project_title = {str(p["id"]): p.get("title") or "" for p in projects} + + joins = [item for item in items if is_join_gate(item)] + states: list[dict[str, Any]] = [] + + for join in joins: + join_id = str(join["id"]) + input_ids = join_input_gate_ids(join_id, dependencies) + output_ids = join_output_gate_ids(join_id, dependencies) + pending: list[dict[str, Any]] = [] + for input_id in input_ids: + gate = item_by_id.get(input_id) + if not gate: + continue + if gate.get("status") != "reached": + strand_id = gate.get("strand_project_id") + pending.append( + { + "gate_id": input_id, + "title": gate.get("title") or input_id, + "strand_project_id": strand_id, + "strand_title": project_title.get(str(strand_id or ""), ""), + "status": gate.get("status"), + } + ) + states.append( + { + "join_id": join_id, + "title": join.get("title") or "", + "status": join.get("status"), + "ready_to_switch": len(pending) == 0 and join.get("status") not in TERMINAL_STATUSES, + "pending_inputs": pending, + "output_gate_ids": output_ids, + "outputs": [ + { + "gate_id": output_id, + "title": (item_by_id.get(output_id) or {}).get("title") or output_id, + "strand_project_id": (item_by_id.get(output_id) or {}).get( + "strand_project_id" + ), + } + for output_id in output_ids + if output_id in item_by_id + ], + } + ) + return states + + +def compute_progression_state( + *, tenant_id: str, initiative_id: str +) -> dict[str, Any]: + active_gates = list_active_work_gates( + tenant_id=tenant_id, initiative_id=initiative_id + ) + return { + "active_work_gates": [ + { + "id": gate["id"], + "title": gate.get("title"), + "strand_project_id": gate.get("strand_project_id"), + "status": gate.get("status"), + } + for gate in active_gates + ], + "joins": compute_join_states( + tenant_id=tenant_id, initiative_id=initiative_id + ), + } + + +def enforce_single_active_work_gate_per_strand( + cur, + *, + tenant_id: str, + initiative_id: str, + keep_active_id: str, + strand_project_id: Optional[str], +) -> None: + if strand_project_id: + cur.execute( + """ + UPDATE roadmap_items ri + SET status = 'planned', updated_at = NOW() + FROM roadmaps r + WHERE ri.roadmap_id = r.id + AND ri.tenant_id = %s + AND r.initiative_id = %s + AND ri.item_type = 'maturity_stage' + AND ri.status IN ('active', 'at_risk') + AND ri.strand_project_id = %s + AND ri.id != %s + """, + (tenant_id, initiative_id, strand_project_id, keep_active_id), + ) + return + + cur.execute( + """ + UPDATE roadmap_items ri + SET status = 'planned', updated_at = NOW() + FROM roadmaps r + WHERE ri.roadmap_id = r.id + AND ri.tenant_id = %s + AND r.initiative_id = %s + AND ri.item_type = 'maturity_stage' + AND ri.status IN ('active', 'at_risk') + AND ri.strand_project_id IS NULL + AND ri.id != %s + """, + (tenant_id, initiative_id, keep_active_id), + ) + + +def _graph_ready( + item_id: str, + *, + item_by_id: dict[str, dict[str, Any]], + dependencies: list[dict[str, Any]], +) -> bool: + for dep in _requires_deps(dependencies): + if str(dep["from_item_id"]) != str(item_id): + continue + prereq = item_by_id.get(str(dep["to_item_id"])) + if not prereq: + continue + if prereq.get("status") != "reached": + return False + return True + + +def activate_work_gate( + *, + tenant_id: str, + initiative_id: str, + gate: dict[str, Any], + user_id: Optional[str] = None, + seed_practices: bool = True, +) -> dict[str, Any]: + from services.maturity_practice import activate_stage_practice_set + from services.roadmap import get_roadmap_item, update_roadmap_item + + gate_id = gate["id"] + updated = update_roadmap_item( + tenant_id=tenant_id, + item_id=gate_id, + status="active", + user_id=user_id, + ) + if not updated: + raise ValueError("Work-Gate konnte nicht aktiviert werden") + + practice_result = None + if seed_practices: + fresh = get_roadmap_item(tenant_id=tenant_id, item_id=gate_id) or updated + practice_result = activate_stage_practice_set( + tenant_id=tenant_id, + initiative_id=initiative_id, + stage=fresh, + user_id=user_id, + ) + + return { + "gate_id": gate_id, + "gate_title": updated.get("title"), + "practice_transition": practice_result, + } + + +def _mark_join_reached( + cur, + *, + tenant_id: str, + join_id: str, +) -> None: + cur.execute( + """ + UPDATE roadmap_items + SET status = 'reached', updated_at = NOW() + WHERE id = %s AND tenant_id = %s AND item_type = 'join_gate' + """, + (join_id, tenant_id), + ) + + +def try_switch_join( + *, + tenant_id: str, + initiative_id: str, + join_id: str, + user_id: Optional[str] = None, +) -> Optional[dict[str, Any]]: + from services.audit import log_audit + from services.roadmap import get_roadmap_item, list_dependencies_for_initiative + + join = get_roadmap_item(tenant_id=tenant_id, item_id=join_id) + if not join or not is_join_gate(join): + return None + if join.get("status") in TERMINAL_STATUSES: + return None + + dependencies = list_dependencies_for_initiative( + tenant_id=tenant_id, initiative_id=initiative_id + ) + item_by_id = { + str(item["id"]): item + for item in __import__( + "services.roadmap", fromlist=["list_roadmap_items_for_initiative"] + ).list_roadmap_items_for_initiative( + tenant_id=tenant_id, initiative_id=initiative_id + ) + } + + pending = [ + input_id + for input_id in join_input_gate_ids(join_id, dependencies) + if item_by_id.get(input_id, {}).get("status") != "reached" + ] + if pending: + return None + + output_ids = join_output_gate_ids(join_id, dependencies) + activated: list[dict[str, Any]] = [] + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + _mark_join_reached(cur, tenant_id=tenant_id, join_id=join_id) + conn.commit() + finally: + conn.close() + + for output_id in output_ids: + output_gate = item_by_id.get(output_id) + if not output_gate or not is_work_gate(output_gate): + continue + if output_gate.get("status") in TERMINAL_STATUSES: + continue + if not _graph_ready(output_id, item_by_id=item_by_id, dependencies=dependencies): + continue + activated.append( + activate_work_gate( + tenant_id=tenant_id, + initiative_id=initiative_id, + gate=output_gate, + user_id=user_id, + ) + ) + + log_audit( + "progression.join_switched", + user_id=user_id, + tenant_id=tenant_id, + details={ + "initiative_id": initiative_id, + "join_id": join_id, + "join_title": join.get("title"), + "activated_gate_ids": [item["gate_id"] for item in activated], + }, + ) + + return { + "join_id": join_id, + "join_title": join.get("title"), + "activated": activated, + } + + +def try_switch_joins_for_gate( + *, + tenant_id: str, + initiative_id: str, + gate_id: str, + user_id: Optional[str] = None, +) -> list[dict[str, Any]]: + from services.roadmap import list_dependencies_for_initiative, list_roadmap_items_for_initiative + + dependencies = list_dependencies_for_initiative( + tenant_id=tenant_id, initiative_id=initiative_id + ) + joins = [ + item + for item in list_roadmap_items_for_initiative( + tenant_id=tenant_id, initiative_id=initiative_id + ) + if is_join_gate(item) + ] + switched: list[dict[str, Any]] = [] + for join in joins: + join_id = str(join["id"]) + inputs = join_input_gate_ids(join_id, dependencies) + if gate_id not in inputs: + continue + result = try_switch_join( + tenant_id=tenant_id, + initiative_id=initiative_id, + join_id=join_id, + user_id=user_id, + ) + if result: + switched.append(result) + return switched + + +def _legacy_linear_next_stage( + *, + tenant_id: str, + initiative_id: str, + reached_item_id: str, + user_id: Optional[str] = None, +) -> Optional[dict[str, Any]]: + from services.roadmap import get_roadmap_item, list_roadmap_items_for_initiative, update_roadmap_item + + stages = [ + item + for item in list_roadmap_items_for_initiative( + tenant_id=tenant_id, initiative_id=initiative_id + ) + if is_work_gate(item) + ] + stages.sort(key=lambda item: (item.get("sort_order", 0), item.get("title", ""))) + reached_index = next( + (index for index, stage in enumerate(stages) if stage["id"] == reached_item_id), + None, + ) + if reached_index is None: + return None + + next_stage = None + for stage in stages[reached_index + 1 :]: + if stage.get("status") in {"planned", "active", "at_risk"}: + next_stage = stage + break + if not next_stage: + return None + + update_roadmap_item( + tenant_id=tenant_id, + item_id=next_stage["id"], + status="active", + user_id=user_id, + ) + fresh = get_roadmap_item(tenant_id=tenant_id, item_id=next_stage["id"]) + if not fresh: + return None + return activate_work_gate( + tenant_id=tenant_id, + initiative_id=initiative_id, + gate=fresh, + user_id=user_id, + ) + + +def on_work_gate_reached( + *, + tenant_id: str, + initiative_id: str, + reached_item_id: str, + user_id: Optional[str] = None, +) -> dict[str, Any]: + from services.maturity_practice import list_practices_for_stage, update_recurring_element + from services.roadmap import get_roadmap_item, list_dependencies_for_initiative, list_roadmap_items_for_initiative + + reached = get_roadmap_item(tenant_id=tenant_id, item_id=reached_item_id) + if not reached or not is_work_gate(reached): + return {"reached_gate_id": reached_item_id} + + for practice in list_practices_for_stage( + tenant_id=tenant_id, + initiative_id=initiative_id, + stage_id=reached_item_id, + ): + if practice.get("status") == "active": + update_recurring_element( + tenant_id=tenant_id, + recurring_id=practice["id"], + status="paused", + user_id=user_id, + ) + + dependencies = list_dependencies_for_initiative( + tenant_id=tenant_id, initiative_id=initiative_id + ) + has_progression_deps = any( + (dep.get("dependency_type") or "requires") == "requires" + for dep in dependencies + if str(dep["from_item_id"]) in { + str(reached_item_id), + *(str(item["id"]) for item in list_roadmap_items_for_initiative( + tenant_id=tenant_id, initiative_id=initiative_id + ) if is_join_gate(item)), + } + or str(dep["to_item_id"]) == str(reached_item_id) + ) + + joined = try_switch_joins_for_gate( + tenant_id=tenant_id, + initiative_id=initiative_id, + gate_id=reached_item_id, + user_id=user_id, + ) + + activated_successors: list[dict[str, Any]] = [] + if not joined: + item_by_id = { + str(item["id"]): item + for item in list_roadmap_items_for_initiative( + tenant_id=tenant_id, initiative_id=initiative_id + ) + } + for successor_id in successor_work_gate_ids(reached_item_id, dependencies): + successor = item_by_id.get(successor_id) + if not successor or not is_work_gate(successor): + continue + if is_join_gate(successor): + continue + if successor.get("status") in TERMINAL_STATUSES: + continue + if not _graph_ready( + successor_id, item_by_id=item_by_id, dependencies=dependencies + ): + continue + activated_successors.append( + activate_work_gate( + tenant_id=tenant_id, + initiative_id=initiative_id, + gate=successor, + user_id=user_id, + ) + ) + + legacy = None + if not joined and not activated_successors and not has_progression_deps: + legacy = _legacy_linear_next_stage( + tenant_id=tenant_id, + initiative_id=initiative_id, + reached_item_id=reached_item_id, + user_id=user_id, + ) + + return { + "reached_gate_id": reached_item_id, + "joins_switched": joined, + "successors_activated": activated_successors, + "legacy_linear": legacy, + } diff --git a/backend/services/recurring.py b/backend/services/recurring.py index 4a8e82e..2e6b3a1 100644 --- a/backend/services/recurring.py +++ b/backend/services/recurring.py @@ -2,7 +2,7 @@ from __future__ import annotations -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Literal, Optional from psycopg2.extras import RealDictCursor @@ -16,14 +16,14 @@ RecurringStatus = Literal["active", "paused", "ended"] RECURRING_STATUSES = frozenset({"active", "paused", "ended"}) _RECURRING_COLUMNS = """ - id, tenant_id, initiative_id, roadmap_item_id, title, description, status, + id, tenant_id, initiative_id, roadmap_item_id, project_id, title, description, status, interval_days, next_due_at, created_at, updated_at """ def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: result = dict(row) - for key in ("id", "tenant_id", "initiative_id", "roadmap_item_id"): + for key in ("id", "tenant_id", "initiative_id", "roadmap_item_id", "project_id"): if result.get(key): result[key] = str(result[key]) for ts_key in ("created_at", "updated_at", "next_due_at"): @@ -47,6 +47,7 @@ def create_recurring_element( interval_days: Optional[int] = None, next_due_at: Optional[datetime] = None, roadmap_item_id: Optional[str] = None, + project_id: Optional[str] = None, user_id: Optional[str] = None, ) -> dict[str, Any]: title = title.strip() @@ -64,16 +65,17 @@ def create_recurring_element( cur.execute( f""" INSERT INTO recurring_elements ( - tenant_id, initiative_id, roadmap_item_id, title, description, status, + tenant_id, initiative_id, roadmap_item_id, project_id, title, description, status, interval_days, next_due_at ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING {_RECURRING_COLUMNS} """, ( tenant_id, initiative_id, roadmap_item_id, + project_id, title, description, status, @@ -166,6 +168,8 @@ def update_recurring_element( clear_next_due_at: bool = False, roadmap_item_id: Optional[str] = None, clear_roadmap_item_id: bool = False, + project_id: Optional[str] = None, + clear_project_id: bool = False, ) -> Optional[dict[str, Any]]: existing = get_recurring_element(tenant_id=tenant_id, recurring_id=recurring_id) if not existing: @@ -205,6 +209,11 @@ def update_recurring_element( elif roadmap_item_id is not None: updates.append("roadmap_item_id = %s") params.append(roadmap_item_id) + if clear_project_id: + updates.append("project_id = NULL") + elif project_id is not None: + updates.append("project_id = %s") + params.append(project_id) if not updates: return existing diff --git a/backend/services/roadmap.py b/backend/services/roadmap.py index 0898b74..26c528b 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", "work_cycle"] +RoadmapItemType = Literal["milestone", "review_gate", "maturity_stage", "work_cycle", "join_gate"] 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", "work_cycle"}) +ROADMAP_ITEM_TYPES = frozenset({"milestone", "review_gate", "maturity_stage", "work_cycle", "join_gate"}) ROADMAP_ITEM_STATUSES = frozenset( {"planned", "active", "at_risk", "reached", "moved", "discarded"} ) @@ -32,7 +32,7 @@ TERMINAL_STATUSES = frozenset({"reached", "moved", "discarded"}) def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: result = dict(row) - for key in ("id", "tenant_id", "roadmap_id", "initiative_id", "from_item_id", "to_item_id"): + for key in ("id", "tenant_id", "roadmap_id", "initiative_id", "from_item_id", "to_item_id", "strand_project_id"): if result.get(key): result[key] = str(result[key]) if result.get("target_date"): @@ -185,6 +185,7 @@ def list_roadmap_items_for_initiative( ri.id, ri.tenant_id, ri.roadmap_id, r.initiative_id, ri.item_type, ri.title, ri.goal_description, ri.definition_of_done, ri.status, ri.sequencing_mode, ri.target_date, ri.sort_order, + ri.strand_project_id, ri.created_at, ri.updated_at FROM roadmap_items ri JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id @@ -208,6 +209,7 @@ def get_roadmap_item(*, tenant_id: str, item_id: str) -> Optional[dict[str, Any] ri.id, ri.tenant_id, ri.roadmap_id, r.initiative_id, ri.item_type, ri.title, ri.goal_description, ri.definition_of_done, ri.status, ri.sequencing_mode, ri.target_date, ri.sort_order, + ri.strand_project_id, ri.created_at, ri.updated_at FROM roadmap_items ri JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id @@ -233,6 +235,7 @@ def create_roadmap_item( sequencing_mode: SequencingMode = "sequential", target_date: Optional[date] = None, sort_order: int = 0, + strand_project_id: Optional[str] = None, initial_criterion_title: Optional[str] = None, user_id: Optional[str] = None, ) -> dict[str, Any]: @@ -255,12 +258,13 @@ def create_roadmap_item( """ INSERT INTO roadmap_items ( tenant_id, roadmap_id, item_type, title, goal_description, - definition_of_done, status, sequencing_mode, target_date, sort_order + definition_of_done, status, sequencing_mode, target_date, sort_order, + strand_project_id ) - VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s, %s, %s) RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description, definition_of_done, status, sequencing_mode, target_date, - sort_order, created_at, updated_at + sort_order, strand_project_id, created_at, updated_at """, ( tenant_id, @@ -273,11 +277,12 @@ def create_roadmap_item( sequencing_mode, target_date, sort_order, + strand_project_id, ), ) row = _serialize_row(dict(cur.fetchone())) row["initiative_id"] = initiative_id - if item_type != "work_cycle": + if item_type not in ("work_cycle", "join_gate"): create_default_criterion( cur, tenant_id=tenant_id, @@ -324,6 +329,8 @@ def update_roadmap_item( target_date: Optional[date] = None, clear_target_date: bool = False, sort_order: Optional[int] = None, + strand_project_id: Optional[str] = None, + clear_strand_project: bool = False, ) -> Optional[dict[str, Any]]: existing = get_roadmap_item(tenant_id=tenant_id, item_id=item_id) if not existing: @@ -373,6 +380,11 @@ def update_roadmap_item( if sort_order is not None: updates.append("sort_order = %s") params.append(sort_order) + if clear_strand_project: + updates.append("strand_project_id = NULL") + elif strand_project_id is not None: + updates.append("strand_project_id = %s") + params.append(strand_project_id) if not updates: return existing @@ -383,14 +395,22 @@ def update_roadmap_item( conn = get_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cur: - if status == "active" and existing.get("item_type") == "maturity_stage": - from services.maturity_practice import enforce_single_active_maturity_stage + if ( + status == "active" + and existing.get("item_type") == "maturity_stage" + ): + from services.progression_graph import ( + enforce_single_active_work_gate_per_strand, + ) - enforce_single_active_maturity_stage( + enforce_single_active_work_gate_per_strand( cur, tenant_id=tenant_id, initiative_id=str(existing["initiative_id"]), keep_active_id=item_id, + strand_project_id=strand_project_id + if strand_project_id is not None + else existing.get("strand_project_id"), ) cur.execute( f""" @@ -399,7 +419,7 @@ def update_roadmap_item( WHERE id = %s AND tenant_id = %s RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description, definition_of_done, status, sequencing_mode, target_date, - sort_order, created_at, updated_at + sort_order, strand_project_id, created_at, updated_at """, params, ) @@ -652,6 +672,10 @@ def verify_reached( raise ValueError("RoadmapItem nicht gefunden") if item["status"] in TERMINAL_STATUSES: raise ValueError(f"RoadmapItem bereits terminal: {item['status']}") + if item.get("item_type") == "join_gate": + raise ValueError( + "Join-Knoten schaltet automatisch, wenn alle Eingänge erreicht sind — kein manuelles Verify" + ) initiative_id = item["initiative_id"] conn = get_connection() @@ -694,7 +718,7 @@ def verify_reached( WHERE id = %s AND tenant_id = %s RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description, definition_of_done, status, sequencing_mode, target_date, - sort_order, created_at, updated_at + sort_order, strand_project_id, created_at, updated_at """, (item_id, tenant_id), ) @@ -718,9 +742,9 @@ def verify_reached( details={"roadmap_item_id": item_id, "verify_reason": verify_reason}, ) if row.get("item_type") == "maturity_stage": - from services.maturity_stage_transition import on_maturity_stage_reached + from services.progression_graph import on_work_gate_reached - transition = on_maturity_stage_reached( + transition = on_work_gate_reached( tenant_id=tenant_id, initiative_id=initiative_id, reached_item_id=item_id, @@ -770,13 +794,16 @@ def reopen_roadmap_item( try: with conn.cursor(cursor_factory=RealDictCursor) as cur: if item.get("item_type") == "maturity_stage": - from services.maturity_practice import enforce_single_active_maturity_stage + from services.progression_graph import ( + enforce_single_active_work_gate_per_strand, + ) - enforce_single_active_maturity_stage( + enforce_single_active_work_gate_per_strand( cur, tenant_id=tenant_id, initiative_id=str(item["initiative_id"]), keep_active_id=item_id, + strand_project_id=item.get("strand_project_id"), ) cur.execute( """ @@ -785,7 +812,7 @@ def reopen_roadmap_item( WHERE id = %s AND tenant_id = %s RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description, definition_of_done, status, sequencing_mode, target_date, - sort_order, created_at, updated_at + sort_order, strand_project_id, created_at, updated_at """, (item_id, tenant_id), ) diff --git a/backend/services/roadmap_criteria.py b/backend/services/roadmap_criteria.py index 7490509..96185ab 100644 --- a/backend/services/roadmap_criteria.py +++ b/backend/services/roadmap_criteria.py @@ -74,13 +74,14 @@ def _reopen_item_if_reached( if item["status"] != "reached": return if item.get("item_type") == "maturity_stage": - from services.maturity_practice import enforce_single_active_maturity_stage + from services.progression_graph import enforce_single_active_work_gate_per_strand - enforce_single_active_maturity_stage( + enforce_single_active_work_gate_per_strand( cur, tenant_id=tenant_id, initiative_id=str(item["initiative_id"]), keep_active_id=item["id"], + strand_project_id=item.get("strand_project_id"), ) cur.execute( """ diff --git a/backend/steering/strategies/next_action/maturity_progression.py b/backend/steering/strategies/next_action/maturity_progression.py index 34a864c..d45f753 100644 --- a/backend/steering/strategies/next_action/maturity_progression.py +++ b/backend/steering/strategies/next_action/maturity_progression.py @@ -7,7 +7,6 @@ from typing import Any from steering.signals import default_rules from steering.strategies.next_action.default_strategy import DefaultNextActionStrategy from steering.strategies.next_action.execution_ready import ( - first_active_gate_id, list_execution_ready_candidates, merge_candidates, ) @@ -43,7 +42,12 @@ class MaturityProgressionStrategy: recurring = recurring_due_candidates(ctx, initiative_id, limit=limit) remaining = limit - len(recurring) - gate_scope = first_active_gate_id(ctx, initiative_id) + from services.progression_graph import list_active_work_gates + + active_gates = list_active_work_gates( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ) + gate_scope = active_gates[0]["id"] if len(active_gates) == 1 else None ready: list[dict[str, Any]] = [] if remaining > 0: ready = list_execution_ready_candidates( diff --git a/backend/tests/test_a1_progression_join.py b/backend/tests/test_a1_progression_join.py new file mode 100644 index 0000000..3396db8 --- /dev/null +++ b/backend/tests/test_a1_progression_join.py @@ -0,0 +1,111 @@ +"""AP-A1-PM — progression join graph tests.""" + +from __future__ import annotations + +from tests.factories import provision_user_in_tenant +from tests.test_initiatives_actions import _auth, _create_initiative, _login + + +def test_progression_join_switches_and_activates_outputs(client): + user = provision_user_in_tenant(tenant_role="admin") + token = _login(client, user) + + created = _create_initiative( + client, + token, + title="Mawashi Progression", + archetype_key="initiative.maturity_journey", + ) + initiative_id = created.json()["id"] + + from services.archetype_starter_kit import apply_a1_progression_demo + + demo = apply_a1_progression_demo( + tenant_id=user["tenant_id"], + initiative_id=initiative_id, + user_id=user["id"], + ) + assert demo["applied"] is True + + progression = client.get( + f"/api/initiatives/{initiative_id}/roadmap/progression-state", + headers=_auth(token), + ) + assert progression.status_code == 200 + body = progression.json() + assert len(body["active_work_gates"]) == 2 + assert len(body["joins"]) == 1 + assert body["joins"][0]["pending_inputs"] + + items = client.get( + f"/api/initiatives/{initiative_id}/roadmap/items", + headers=_auth(token), + ).json() + by_title = {item["title"]: item for item in items} + d1 = by_title["Dehnung — Einstieg"] + h1 = by_title["Hüftrotation — Einstieg"] + join = by_title["Mawashi Geri zur Hüfte"] + + for gate in (d1, h1): + client.post( + f"/api/initiatives/{initiative_id}/evidence", + json={ + "title": f"Nachweis {gate['title']}", + "roadmap_item_id": gate["id"], + "status": "accepted", + }, + headers=_auth(token), + ) + verify = client.post( + f"/api/roadmap-items/{gate['id']}/verify-reached", + headers=_auth(token), + ) + assert verify.status_code == 200 + + items_after = client.get( + f"/api/initiatives/{initiative_id}/roadmap/items", + headers=_auth(token), + ).json() + by_title_after = {item["title"]: item for item in items_after} + assert by_title_after[join["title"]]["status"] == "reached" + assert by_title_after["Dehnung — nach Join"]["status"] == "active" + assert by_title_after["Hüftrotation — nach Join"]["status"] == "active" + + recurring = client.get( + f"/api/initiatives/{initiative_id}/recurring", + headers=_auth(token), + ).json() + active_titles = {r["title"] for r in recurring if r["status"] == "active"} + assert "Enddehnung Mawashi-Vorbereitung" in active_titles + assert "Explosive Hüftrotation" in active_titles + + +def test_multi_active_work_gates_both_in_next_actions(client): + user = provision_user_in_tenant(tenant_role="admin") + token = _login(client, user) + + created = _create_initiative( + client, + token, + title="Multi Active", + archetype_key="initiative.maturity_journey", + ) + initiative_id = created.json()["id"] + + from services.archetype_starter_kit import apply_a1_progression_demo + + apply_a1_progression_demo( + tenant_id=user["tenant_id"], + initiative_id=initiative_id, + user_id=user["id"], + ) + + snap = client.get( + f"/api/initiatives/{initiative_id}/steering-snapshot", + headers=_auth(token), + ) + assert snap.status_code == 200 + recurring_items = [ + a for a in snap.json().get("next_actions") or [] if a.get("kind") == "recurring_due" + ] + assert len(recurring_items) >= 2 diff --git a/backend/tests/test_ap20e_maturity_transition.py b/backend/tests/test_ap20e_maturity_transition.py index b3c7c0d..c00ac97 100644 --- a/backend/tests/test_ap20e_maturity_transition.py +++ b/backend/tests/test_ap20e_maturity_transition.py @@ -44,7 +44,10 @@ def test_maturity_stage_reached_rotates_recurring(client): assert verify.status_code == 200 body = verify.json() assert body["status"] == "reached" - assert body.get("maturity_transition", {}).get("new_recurring_id") + transition = body.get("maturity_transition") or {} + legacy = transition.get("legacy_linear") or {} + practice_ids = (legacy.get("practice_transition") or {}).get("practice_ids") or [] + assert practice_ids or transition.get("successors_activated") or transition.get("joins_switched") recurring = client.get( f"/api/initiatives/{initiative_id}/recurring", diff --git a/docs/architecture/methods/README.md b/docs/architecture/methods/README.md index 3e08c19..4ef4e6d 100644 --- a/docs/architecture/methods/README.md +++ b/docs/architecture/methods/README.md @@ -2,14 +2,14 @@ **Prozess:** `../M1_Hybrid_Path_and_Depth_D_v0.1.md` **Methodenkern (Freeze):** [`../Kairo_Steering_Method_Kernel_v0.1.md`](../Kairo_Steering_Method_Kernel_v0.1.md) — Methodentypen: Kernel §6 -**PO 2026-07-26:** Architektur beibehalten; Spec-D-Batch **vorläufig freigegeben** (Implementierungsausreichendheit noch offen) +**PO 2026-07-28:** Spec-D `maturity_progression` — **Horizon/Progression PO-Lock** in [`SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](../../product/archetypes/SPEC_A1_Progression_Model_PO_Lock_v0.1.md); Spec-D D3/D6/D8 angepasst. | Methode | Typ | Datei | Status | |---------|-----|-------|--------| | `sequential_dependency` | Primary (Ausführung) | SPEC_D_sequential_dependency_v0.1.md | vorläufig freigegeben| | `continuous_product` | Primary (Ausführung) | SPEC_D_continuous_product_v0.1.md | vorläufig freigegeben| | `recurring_control` | Primary (Ausführung) | SPEC_D_recurring_control_v0.1.md | vorläufig freigegeben| -| `maturity_progression` | Primary (Ausführung) | SPEC_D_maturity_progression_v0.1.md | vorläufig freigegeben| +| `maturity_progression` | Primary (Ausführung) | SPEC_D_maturity_progression_v0.1.md | PO-Lock Horizon (2026-07-28) | | `checklist_flow` | Primary (Ausführung) | SPEC_D_checklist_flow_v0.1.md | vorläufig freigegeben| | `care_navigation` | Primary (Ausführung) | SPEC_D_care_navigation_v0.1.md | vorläufig freigegeben| | `dispute_procedure` | Primary (Ausführung) | SPEC_D_dispute_procedure_v0.1.md | vorläufig freigegeben| diff --git a/docs/architecture/methods/SPEC_D_maturity_progression_v0.1.md b/docs/architecture/methods/SPEC_D_maturity_progression_v0.1.md index df7bfa8..b442388 100644 --- a/docs/architecture/methods/SPEC_D_maturity_progression_v0.1.md +++ b/docs/architecture/methods/SPEC_D_maturity_progression_v0.1.md @@ -2,7 +2,7 @@ ## Steuerungsmethode — Spec-Tiefe D v0.1 **Status:** vorläufig freigegeben (PO 2026-07-26) — mit zugehöriger Archetyp-Vollspec; Implementierungsausreichendheit noch offen -**Stand:** 2026-07-26 +**Stand:** 2026-07-28 (PO-Lock Progressionsmodell — [`SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](../../product/archetypes/SPEC_A1_Progression_Model_PO_Lock_v0.1.md)) **Default für:** A1 `initiative.maturity_journey` **Prozess:** `M1_Hybrid_Path_and_Depth_D_v0.1.md` **Quellen:** Decision-Lock A1, Archetyp-Vollspec A1, Kernel Freeze, AP2.0e @@ -17,7 +17,7 @@ | `method_key` | `maturity_progression` | | Rolle | primary (**Ausführung** — Kernel §6) | | Label (DE) | Reifegrad-Progression | -| Zweck | Fähigkeit über Stufen steuern: Active Stage als Horizont; Next = fällige/alternierende Übung oder Stage-Action; bei Stage `reached` Activity Set wechseln. Weiches Vorhaben-Ende. | +| Zweck | Fähigkeit über **parallele Strang-Pfade** (Gate-Graph) steuern; Next = fällige Übungen auf **aktiven Gates**; bei Gate `reached` strangspezifischer Activity-Set-Wechsel. Weiches Vorhaben-Ende. | --- @@ -34,20 +34,26 @@ ## D3 Horizon +**PO-Lock (2026-07-28):** [`SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](../../product/archetypes/SPEC_A1_Progression_Model_PO_Lock_v0.1.md) §3 — ersetzt „eine aktive Stufe initiative-weit“ für Multi-Strang. + | Horizont | Bedeutung | |----------|-----------| -| **Aktive `maturity_stage`** | fokussierte Stufe; nur deren Stage Activity Set liefert Next | -| Plan-Nebenblick | nächste Stufe / Graph-Voraussetzungen — nicht Leading für Next | -| Ist | CadenceInstances + Stage-Actions der aktiven Stufe | +| **Aktive Gates (Menge)** | Alle `maturity_stage`/Gate-Knoten mit Status `active`, die graph-ready sind; **mehrere parallel** (je Strang max. ein aktives Gate auf dem Pfad) | +| **Strang** | `Project` — paralleler Fähigkeits-Pfad; Übungen mit `project_id` | +| **Join-Gate** | wird `active`, wenn alle `requires`-Vorgänger `reached` | +| Plan-Nebenblick | blockierte / geplante Gates — nicht Leading für Next | +| Ist | CadenceInstances + Stage-Actions der **aktiven Gates** | -Keine Next-Kandidaten aus inaktiven Stufen. +Keine Next-Kandidaten aus Gates, die nicht `active`/graph-ready sind. + +**Lineares Demo-Kit (1 Strang, Stufe 1–3):** Teilmenge mit effektiv einem aktiven Gate — kein PO-Kernbild. --- ## D4 Leading Work Item -**Primär:** `CadenceInstance` (Übung/Ritual am RecurringElement der aktiven Stufe). -**Sekundär:** `Action` (Stage-gebunden: Prüfung, Workshop, Nachweis). +**Primär:** `CadenceInstance` (Übung am `RecurringElement` eines **aktiven Gates**, optional Strang via `project_id`). +**Sekundär:** `Action` (Gate-gebunden: Prüfung, Workshop — selten bei A1). **Hinweis-Objekt (kein Leading Work):** offenes Stufen-Kriterium / Verify-Fortschritt — erscheint in Next/Attention als Steuerhinweis. Pro Recurring: genau **eine** offene Instanz (kein Duplikat bei Verpassen). @@ -77,12 +83,13 @@ Kernel-Schlitznamen: Abbildung auf §4 Kernel (Intake … Closure). ## D6 Next Action — konkrete Regeln -### Ready-Menge (aktive Stufe) +### Ready-Menge (aktive Gates) Ein Work Item ist ready, wenn: -1. gebunden an die **aktive** `maturity_stage` und Aktivität **aktiv**, und -2. eines von: +1. gebunden an ein **aktives**, graph-ready **Gate** und Aktivität **aktiv**, und +2. (Multi-Strang) `project_id` passt zum Strang des Gates, und +3. eines von: - **Cadence ohne Alternanz:** Instanz fällig oder überfällig (heute laut Cadence / overdue), oder - **Cadence in Alternanz-Gruppe:** Instanz ist das **aktuelle** Gruppenmitglied **und** fällig/überfällig (bzw. „dran“ laut Gruppenpolicy, wenn Cadence der Gruppe geteilt ist), oder - **Stage-Action:** Status offen und Deps/Blocker erlauben Arbeit @@ -141,7 +148,7 @@ Unabhängige Cadences am gleichen Stage → mehrere ready am selben Tag möglich | practice done / Cadence erfüllt | Instanz schließen; nächste Instanz laut Cadence; bei Alternanz Rotation | | Stage-Action done | aus Ready; ggf. Kriterien-Fortschritt | | Evidence / Kriterium erfüllt | Validation-Fortschritt | -| stage `reached` (Verify ok) | altes Activity Set deaktivieren; nächste Stufe aktiv; neues Set aktiv (AP2.0e erweitert) | +| stage `reached` (Verify ok) | Activity Set **dieses Gates** deaktivieren; **pro Strang** nächstes Gate / neues Set (Join: strangspezifisch) | | manuelle Adaptation (Set/Cadence/Alternanz) | Ready-Menge neu; auditiert | | Cadence-Zeitpunkt erreicht | Event-Schub → Ready neu bewerten | @@ -175,7 +182,7 @@ Felder `alternation_group_id`, `alternation_order`: Archetyp-Vollspec A1 §10. |-----|------| | next_work_strategy | `maturity_progression` | | attention_rules | Codes D7 | -| horizon_resolver | aktive `maturity_stage` | +| horizon_resolver | Menge aktiver, graph-ready Gates (Multi-Strang) | | dod_policy | Stage-Kriterien / Verify | | adaptation_hooks | `on_stage_reached` → Set-Wechsel; manuelle Set-Änderung | | steering_elements | `next_action_primary`, `maturity_stage`, `recurring_rhythm` | @@ -191,6 +198,7 @@ Felder `alternation_group_id`, `alternation_order`: Archetyp-Vollspec A1 §10. | Hybrid Dominanz | Cadence-Event + Stage-Plan — Kernel trägt Dual-Auslöser | | Leading polymorph | Cadence + Action — Kernel Q2 | | Alternanz-State | Persistenz am Group-/Recurring-Kontext — Implementierungsdetail, fachlich hier verbindlich | -| Multi-Fähigkeit | mehrere Projects ok; kein Abnahme-Zwang für Kern-Kit | +| Multi-Fähigkeit | mehrere Projects / Stränge — **Kernbild A1** (PO-Lock); Gate-Graph verbindet Pfade | +| Single-active initiative-wide | **widerrufen** für Multi-Strang — nur Demo-Kit | **PO-Empfehlung:** A1-Vollspec §4 OM + diese Spec-D = Steuervertrag; UI nur in Archetyp-Vollspec. diff --git a/docs/product/archetypes/README.md b/docs/product/archetypes/README.md index 11670fb..78ab44a 100644 --- a/docs/product/archetypes/README.md +++ b/docs/product/archetypes/README.md @@ -7,12 +7,12 @@ **UI-Navigation (Archetyp):** [`ARCHETYPE_UI_NAVIGATION_v0.1.md`](ARCHETYPE_UI_NAVIGATION_v0.1.md) **Backlog / Epic / Dual Commit:** [`../../architecture/ADP_Backlog_Hierarchy_and_Dual_Commit_Path_v0.1.md`](../../architecture/ADP_Backlog_Hierarchy_and_Dual_Commit_Path_v0.1.md) -**PO 2026-07-26:** Specs **vorläufig freigegeben** — Implementierungsausreichendheit noch nicht abschließend bewertet. +**PO 2026-07-28:** A1 **PO-Lock Progressionsmodell** — [`SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](SPEC_A1_Progression_Model_PO_Lock_v0.1.md) (Option C nach Validation No-Go). | ID | Datei | Status | |----|-------|--------| | C1 | [SPEC_C1_dispute_case_v0.1.md](SPEC_C1_dispute_case_v0.1.md) | vorläufig freigegeben | -| A1 | [SPEC_A1_maturity_journey_v0.1.md](SPEC_A1_maturity_journey_v0.1.md) | vorläufig freigegeben | +| A1 | [SPEC_A1_maturity_journey_v0.1.md](SPEC_A1_maturity_journey_v0.1.md) | PO-Lock Progression (2026-07-28) | | A2 | [SPEC_A2_linear_project_v0.1.md](SPEC_A2_linear_project_v0.1.md) | vorläufig freigegeben | | A3 | [SPEC_A3_recurring_program_v0.1.md](SPEC_A3_recurring_program_v0.1.md) | vorläufig freigegeben | | B1 | [SPEC_B1_checklists_v0.1.md](SPEC_B1_checklists_v0.1.md) | vorläufig freigegeben | diff --git a/docs/product/archetypes/SPEC_A1_Progression_Model_PO_Lock_v0.1.md b/docs/product/archetypes/SPEC_A1_Progression_Model_PO_Lock_v0.1.md new file mode 100644 index 0000000..1d87410 --- /dev/null +++ b/docs/product/archetypes/SPEC_A1_Progression_Model_PO_Lock_v0.1.md @@ -0,0 +1,220 @@ +# A1 — Progressionsmodell PO-Lock +## v0.1 (Option C, AP2.1) + +**Status:** **PO-lock** (2026-07-28) — präzisiert Spec A1 §4 und Spec-D D3/D6 vor Implementierung +**Auslöser:** AP2.1 PO-Validation — flache Rhythmus-Liste deckt Nutzerbild nicht ab +**Bezug:** `SPEC_A1_maturity_journey_v0.1.md`, `SPEC_D_maturity_progression_v0.1.md` + +--- + +## 1. PO-Kernentscheidung + +A1 ist **kein lineares Stufenmodell mit losem „Wiederkehrend“**, sondern ein **Progressionsmodell über parallele Stränge (Pfade)** mit **Gate-Graph**, **Activity Sets pro Gate** und **wiederkehrenden Übungen als Ist-Tracking**. + +| PO-These | Lock | +|----------|------| +| Primär ist **Üben / Wiederholung**, nicht committete Einmal-APs | ✓ Spec-D D4 unverändert | +| Komplexität liegt in **Planung + Durchführung paralleler Progressionen** | ✓ ab jetzt explizit | +| **Rhythmus** ist Eigenschaft der **Übung** (`RecurringElement`), kein losgelöstes Planungsobjekt | ✓ | +| **Strang** = `Project` (`container_kind=stream`); **Work-Gate** = `maturity_stage` mit Activity Set | ✓ | +| **Join** = Schaltknoten — kein Activity Set; konsolidierter Status; multi-in / multi-out | ✓ (§10) | +| **Kein Terminalgate-Typ** — letztes Gate vor Join ist normales Gate; Kante genügt | ✓ (§10) | + +--- + +## 2. Referenzbeispiel (PO) — Mawashi Geri + +**Vorhaben:** Fähigkeit „Mawashi Geri zum Kopf treffen“. + +**Stränge (Projects):** + +| Strang | Beispiel-Gates (Pfad) | +|--------|------------------------| +| Dehnung | D1 → D2 | +| Hüftrotation | H1 → H2 | +| Technik | T1 → T2 | + +**Join (Schaltknoten):** „Mawashi Geri zur Hüfte“ — **keine Übungen**; Eingänge von D2, H2, T2; Ausgänge z. B. zu D3, G1 (Gleichgewicht). Konsolidierter Status **liegt am Join**, nicht an D2/H2/T2. + +**Nach Schalten (MVP):** alle Ausgänge feuern → Nachfolge-Gates je Strang `active` → Activity Sets frei. + +**Leitfrage:** *Welche Übungen sind heute auf aktiven Strang-Gates fällig — und welche Joins warten noch auf welche Eingänge?* + +--- + +## 3. Horizon — Ersetzt „eine aktive Stufe initiative-weit“ + +### 3.1 Alt (v0.1, zu eng) + +> Genau **eine** `maturity_stage` mit Status `active` pro Initiative. + +Gilt **nicht mehr** für Multi-Strang-Progression (nur noch für lineares Demo-Kit „Spagat 1–2–3“). + +### 3.2 Neu (PO-lock) + +| Regel | Bedeutung | +|-------|-----------| +| **H1 — Parallele aktive Gates** | Initiative-weit **mehrere** Gates dürfen `active` sein — je Strang höchstens **ein** aktives Gate auf dem linearen Pfadfortschritt | +| **H2 — Strang** | `Project` mit Archetyp-Spiegel `project.maturity_journey`; optional `roadmap_item_id` am Gate für Zuordnung | +| **H3 — Join (Schaltknoten)** | Join **nie** in Today/Next; Status am Join = welche **Eingänge** noch nicht `reached`; Schaltbedingung = alle Eingänge erfüllt | +| **H4 — Ready / Next** | Ready-Menge = Activity Sets aller **aktiven Work-Gates** (`maturity_stage`); Joins ausgeschlossen | +| **H5 — Inaktive Pfade** | Gates `planned` / blockiert durch Graph → deren Übungen **nicht** in Next/Today | + +**Horizon-Resolver (Ziel):** Menge `{ work_gate_id | status=active ∧ graph_ready ∧ hat Activity Set }` — Join-Knoten nie enthalten. + +--- + +## 4. Activity Set — Bindung Übung ↔ Pfad + +### 4.1 Was eine „Übung“ ist + +| Begriff UI | OM-Entität | Pflicht-Bindung | +|------------|------------|-----------------| +| Übung / Ritual | `RecurringElement` | `roadmap_item_id` → Gate | +| Strang | `project_id` → Project (Strang) | **ja**, wenn ≥2 Stränge | +| Rhythmus | `interval_days`, Alternanz-Felder | am Recurring | +| Heute erledigt | `CadenceInstance` | pro Recurring | + +**Anti-Pattern (PO):** flache Liste „Wiederkehrend“ ohne Gate- und Strang-Bezug. + +### 4.2 Einmal-Aktivitäten + +`Action` nur **sekundär** (Prüfung, Workshop, Nachweis) — an Gate + optional Strang. Nicht Träger täglicher Übung. + +### 4.3 Messbarkeit + +| Ebene | Mechanismus | +|-------|-------------| +| Ist (Tages-) | `CadenceInstance.measurement_note` | +| Plan (Gate) | `RoadmapItemCriterion` mit `criterion_kind: metric` | +| Verify | Evidence + Kriterien vor `reached` | + +--- + +## 5. Verify, Schalten, Set-Wechsel + +### 5.1 Work-Gate (mit Activity Set) + +Bei `reached` eines Work-Gates **G**: + +1. Activity Set von **G** deaktivieren. +2. Hat **G** einen **Join** als direkten Nachfolger (Kante G → Join): **kein** auto-next auf dem Strang — Beitrag zum Join ist erfüllt; Strang pausiert bis Join schaltet. +3. Hat **G** ein **Work-Nachfolge-Gate** auf demselben Strang: dieses → `active` (wenn unblocked). +4. Audit + Journey-Event. + +### 5.2 Join (Schaltknoten, §10) + +Bei **Schaltbedingung erfüllt** (alle Eingänge `reached`): + +1. Join-Status → `reached` (automatisch oder explizites „Schalten“ — **kein** Verify über Übungs-Kriterien). +2. **MVP:** **alle** Ausgangs-Gates → `active` (sofern Work-Gates mit Activity Set). +3. Activity Sets der neu aktiven Gates freischalten. +4. Audit + Journey-Event („Join geschaltet: {Titel}“). + +**Stufe B:** selektives Schalten von Ausgängen (If/Else) — §10.4. + +**Nicht:** pauschal „nächste lineare Stufe 2“ initiative-weit (Demo-Kit-Ausnahme). + +--- + +## 6. UI-Pflicht (Plan vor Today) + +| Fläche | Inhalt (PO) | +|--------|-------------| +| **Plan → Struktur** | Stränge + Graph; Joins als **Knoten** (Eingänge/Ausgänge sichtbar) | +| **Plan → Gate-Detail** | Nur **Work-Gates**: Activity Set, Strang, Cadence, metric-Kriterien | +| **Plan → Join-Detail** | Titel, Eingänge, Ausgänge, Schaltstatus — **kein** Activity Set | +| **Ausführen → Today** | fällige Übungen nur von **aktiven Work-Gates** | +| **Kontrolle** | Join-Warteliste: *„{Join-Titel} wartet auf: Dehnung/D2, …“* | +| **Journey** | Set-Wechsel, Verify, Messwerte | + +**Nicht** als Haupt-IA: generisches „Wiederkehrend“ ohne Plan-Kontext. + +--- + +## 7. MVP-Stufe A — Abgrenzung nach PO-Lock + +| Ebene | Inhalt | Stufe A | +|-------|--------|---------| +| **Demo Minimum** | Lineares Kit (Spagat 3 Stufen, 1 Strang) | Validation **technisch** — nicht PO-Kernbild | +| **PO-Kernbild** | ≥2 Stränge + Graph + Gate-Activity-Set + Today | **Voraussetzung** für A1 Go in MVP §4 | +| **Reich** | Alternanz, Templates (Kumite, Mawashi) | Stufe B / Template-APs | + +**Konsequenz AP2.1:** A1 bleibt **No-Go** bis Progressions-IA (§6) mindestens für **2 Stränge + 1 Join** durchspielbar — nicht nur Cadence-Plumbing. + +--- + +## 8. Implementierungsfolge (nach Lock, keine Spec-Neuerfindung) + +```text +AP-A1-PM-1 Spec-D D3/D6/D8 + A1 §4 (dieses Lock) ✓ +AP-A1-PM-2 Persistenz: recurring.project_id; strand_project_id am Gate; gate-activity-set API +AP-A1-PM-3 Horizon multi-active; Join-Resolver (§10); single-active revoke +AP-A1-PM-3b Join-Schalten: alle Ausgänge (MVP) +AP-A1-PM-4 UI: Plan Struktur + Work-Gate-Detail + Join-Knoten +AP-A1-PM-5 Today/Kontrolle: strang-gruppiert; Join-Warteliste +AP-A1-PM-6 Template: 2 Stränge → 1 Join → 2+ Ausgänge +``` + +Kein ADP für OM-Grundmodell — Erweiterung innerhalb RoadmapItem + Project + Recurring + CadenceInstance. + +--- + +## 9. Offene Feinentscheide ( später ) + +| # | Thema | Default bis PO anders | +|---|--------|------------------------| +| 1 | Join als `item_type: join_gate` vs. `review_gate` + Flag | `join_gate` (neu, schlank) | +| 2 | Max. aktive Work-Gates pro Strang | 1 | +| 3 | Ranking zwischen Strängen | überfällig > heute > EFS später | +| 4 | Join-Schalten automatisch vs. Bestätigung | automatisch wenn alle Eingänge `reached` | + +--- + +## 10. Join als Schaltknoten (PO-Lock 2026-07-28) + +### 10.1 Warum kein Terminalgate + +Der Nutzer modelliert **normale Gates** auf Strang-Pfaden und **verbindet** deren letztes relevantes Gate per Kante mit einem **Join**. Ein extra Typ „Terminalgate“ ist **nicht nötig** und würde Graph-Logik sichtbar machen, die die Engine ohnehin aus Kanten ableitet. + +### 10.2 Join — Definition + +| Eigenschaft | Lock | +|-------------|------| +| **Rolle** | Konsolidierungs- und Schaltpunkt; **trägt den sichtbaren Meilenstein-Status** (z. B. „Mawashi Geri zur Hüfte“) | +| **Activity Set** | **Keins** — keine `RecurringElement`, keine Tagesarbeit am Join | +| **Eingänge** | Work-Gates aus ≥1 Strang (`requires`: Join wartet auf Gate) | +| **Ausgänge** | Work-Gates auf Strang-Pfaden (`requires`: Nachfolger wartet auf Join) | +| **Schaltbedingung (MVP)** | Alle Eingänge `reached` | +| **Effekt (MVP)** | **Alle** Ausgänge schalten → Nachfolge-Gates `active` → deren Activity Sets frei | +| **Horizon / Today** | Join erscheint **nicht** — nur in Kontrolle / Plan-Graph | + +### 10.3 Graph-Kanten (bestehende Engine) + +Semantik unverändert (`roadmap_engine`): + +```text +Eingang: join requires D2 (D2 muss reached sein, bevor Join schaltet) +Eingang: join requires H2 +Ausgang: D3 requires join (D3 wird active, wenn Join reached) +Ausgang: G1 requires join +``` + +Der Join **entscheidet**, auf welche Stränge/Gates noch gewartet wird: UI listet offene Eingänge am Join-Titel — nicht als leere Strang-Strecke. + +### 10.4 Stufe B — bedingte Ausgänge (If/Else) + +| Stufe | Verhalten | +|-------|-----------| +| **A (MVP)** | Alle Ausgänge feuern gemeinsam | +| **B** | Pro Ausgang optional Bedingung (Kriterium, Metrik, manuelle Wahl); Engine schaltet nur erfüllte Ausgänge | + +Technische Andockpunkte vorhanden: `optional_branch`, später Kriterium an Kante — **nicht** Stufe A. + +### 10.5 Strang ohne Ausgang nach Join + +Hat ein Strang **keinen** Ausgangs-Gate am Join, endet sein Beitrag dort — **ohne** Dummy-Gates. Strang-Project kann `completed` / ruhend; Today bleibt leer für diesen Strang bis ggf. ein späterer Join einen neuen Ausgang öffnet. + +--- + +*Bei Widerspruch zu A1 v0.1 / Spec-D v0.1 ohne PO-Lock-Vermerk gilt **dieses Dokument** für Progressions- und Horizon-Fragen.* diff --git a/docs/product/archetypes/SPEC_A1_maturity_journey_v0.1.md b/docs/product/archetypes/SPEC_A1_maturity_journey_v0.1.md index 9710dd7..b56700a 100644 --- a/docs/product/archetypes/SPEC_A1_maturity_journey_v0.1.md +++ b/docs/product/archetypes/SPEC_A1_maturity_journey_v0.1.md @@ -2,8 +2,8 @@ ## Archetyp-Vollspec (implementierbar) v0.1 **Status:** vorläufig freigegeben (PO 2026-07-26) — Zielbild; Implementierungsausreichendheit noch nicht abschließend bewertet -**Stand:** 2026-07-26 -**MVP-Stufe:** A +**Stand:** 2026-07-28 (PO-Lock Progressionsmodell — siehe §14) +**MVP-Stufe:** A (PO-Kernbild: Multi-Strang — §14.4) **Decision-Lock:** A1 Reifegrad **Default-Methode:** [`SPEC_D_maturity_progression_v0.1.md`](../../architecture/methods/SPEC_D_maturity_progression_v0.1.md) **Methodenkern:** `Kairo_Steering_Method_Kernel_v0.1.md` @@ -52,7 +52,10 @@ Kontinuierliche Entwicklung einer Fähigkeit über **Phasen/Stufen**. Eine Phase 6. Innerhalb Mittelstufe Übung hinzufügen (Adaptation), auditiert. 7. Optional zweites Fähigkeits-Project + Graph-Voraussetzung. -**Leitfrage:** Welche Stufe ist aktiv — und welche Übung/Aktivität ist **jetzt** dran (inkl. Alternanz/Zeitversatz)? +**Leitfrage:** Welche **Strang-Gates** sind aktiv — und welche Übung ist **jetzt** auf welchem Pfad dran (inkl. Alternanz/Zeitversatz)? + +**Referenzbeispiel (PO 2026-07-28) — parallele Progression „Mawashi Geri“:** +Mehrere Stränge (Projects: Dehnung, Hüftrotation, Technik), je Strang Gate-Kette im Graph, Join-Gate („sicher zur Hüfte“), Activity Set pro Gate, nach Join strangspezifischer Set-Wechsel. Vollständig: [`SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](SPEC_A1_Progression_Model_PO_Lock_v0.1.md) §2. --- @@ -67,25 +70,28 @@ evidence, decisions, reviews, steering_methods ## 4. Plan- / Ist-Vertrag -### 4.1 Stufen / Phasen +### 4.1 Stufen / Phasen / Stränge | Element | Rolle | |---------|--------| -| `maturity_stage` (RoadmapItem) | Phase/Stufe; eine **aktive** Stufe (Horizon) | -| Kriterien / Evidence | Verify vor `reached` | -| Graph | Voraussetzungen zwischen Stufen und/oder Fähigkeits-Projects | -| Project `project.maturity_journey` | Fähigkeit/Spur; eine oder mehrere | +| `maturity_stage` (RoadmapItem) | Gate / Reifegrad-Checkpoint auf einem **Pfad** | +| **Strang** | `Project` (`project.maturity_journey`) — z. B. Dehnung, Technik, Hüftrotation | +| Kriterien / Evidence | Verify vor `reached`; inkl. `metric` | +| Graph | Parallele Pfade, `requires`-Kanten, **Join-Gates** zwischen Strängen | +| **Horizon (PO-lock)** | **Mehrere** aktive Gates initiative-weit; **pro Strang** höchstens ein aktives Gate auf dem Pfad — siehe [`SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](SPEC_A1_Progression_Model_PO_Lock_v0.1.md) §3 | + +> **Hinweis:** Lineares Demo-Kit (Stufe 1–3, ein Strang) ist **Teilmenge**, nicht das PO-Kernbild. ### 4.2 Aktivitäts-Set einer Phase (verbindlich) | Aktivitätstyp | Persistenz | Rolle | |---------------|------------|--------| -| Übung / Ritual | `RecurringElement`, gebunden an `maturity_stage` | wiederkehrend | +| Übung / Ritual | `RecurringElement`, gebunden an **Gate** (`roadmap_item_id`) **und Strang** (`project_id` bei Multi-Strang) | wiederkehrend | | Einmal-/Sonderaktivität | `Action`, gebunden an Stufe | Prüfung, Workshop, Nachweis | -- Pro Stufe: **beliebig viele** Aktivitäten; **≥1 reicht**. +- Pro **Gate**: beliebig viele Übungen; **≥1** pro aktivem Gate auf einem Strang. - Pro Recurring: **eine** offene CadenceInstance (kein Duplikat bei Verpassen). -- Inaktive Stufen: kein Next, keine neuen Instanzen; Historie bleibt. +- Inaktive / nicht-graph-ready Gates: kein Next, keine neuen Instanzen; Historie bleibt. ### 4.3 Zeitversatz und Alternanz (gleiche Phase) @@ -97,29 +103,30 @@ evidence, decisions, reviews, steering_methods Alternanz-Regeln: Rotation nach Erfüllung; Overdue nur auf aktuelles Mitglied; Gruppe an Stufe gebunden. -### 4.4 Modifikation bei Stage `reached` +### 4.4 Modifikation bei Gate `reached` -1. Altes Activity Set deaktivieren. -2. Nächste Stufe aktiv; neues Set aktiv. -3. Neues Set darf Übungen/Cadences/Alternanz/Actions ersetzen/erweitern/reduzieren. -4. Adaptation innerhalb Stufe (manuell) erlaubt und auditiert. - -Erweitert AP2.0e auf ganze Activity Sets. +1. Activity Set **dieses Gates** deaktivieren. +2. **Pro Strang:** nächstes Gate auf dem Pfad `active` (Graph-regelkonform); bei **Join-Gates** nach Verify. +3. Neues Set **strangspezifisch** — Stränge dürfen divergieren (z. B. Technik ↓, Dehnung ↑). +4. Adaptation innerhalb aktiven Gates (manuell) erlaubt und auditiert. ### 4.5 Datenfelder am Recurring (verbindlich) | Feld | Zweck | |------|-------| -| Bindung an `maturity_stage` | Stage Activity Set | +| `roadmap_item_id` | Gate / Activity Set | +| `project_id` | Strang (Pfad); Pflicht bei Multi-Strang | | Cadence | Zeitversatz | | `alternation_group_id` + `alternation_order` | Alternanz (leer = keine Gruppe) | | status aktiv/inaktiv | Set-Wechsel / Adaptation | ### 4.6 Anti-Patterns +- Flache Liste „Wiederkehrend“ ohne Gate/Strang-Bezug - Übungen aller Stufen als flache Action-Liste - Verpasste Übung → Duplikat-Task - Alternanz ohne Gruppen-Semantik +- Ein uniformes Initiative-Set nach Join (statt strangspezifisch) --- @@ -130,7 +137,7 @@ Erweitert AP2.0e auf ganze Activity Sets. | Aspekt | Kurz | |--------|------| | Dominanz | hybrid | -| Horizon | aktive `maturity_stage` | +| Horizon | aktive Gates (multi-strang) — PO-Lock §14 | | Leading | CadenceInstance; sekundär Stage-Action | | Next / Ranking | Spec-D D6 | | Attention / Events | Spec-D D7 / D8 | @@ -175,12 +182,12 @@ Cadence im Detail am RecurringElement. | Fläche | Inhalt | |--------|--------| -| **Plan → Stufen** | Timeline/Liste; aktiv; Kriterien; Verify | -| **Plan → Stufe Detail** | Activity Set: Übungen (Cadence, Alternanz), Stage-Actions | -| **Plan → Struktur** | Fähigkeits-Projects + Graph | +| **Plan → Struktur** | Stränge (Projects) + Gate-Graph (parallel, Join) | +| **Plan → Gate-Detail** | Activity Set: Übungen, Strang, Cadence, metric-Kriterien | +| **Plan → Stufen** | Graph-/Listen-Ansicht aktiver Gates | | **Plan → Profil** | EFS Fokus/Frequenz/Disziplin | -| **Ausführen / Today** | fällige Übungen + Alternanz-aktuell + Stage-Actions | -| **Kontrolle** | Stufe, Next + Begründung, Attention, Verify-Fortschritt | +| **Ausführen / Today** | fällige Übungen **gruppiert nach Strang/Gate** | +| **Kontrolle** | aktive Gates, Join-Status, Kriterien-Fortschritt | | **Journey** | Stufenwechsel, Set-Modifikationen, erfüllte Übungen | | **Process** | Stufen → Stufe/Übungen → Ausführen → Kontrolle | | **Elements** | `next_action_primary`, `maturity_stage`, `recurring_rhythm` | @@ -231,17 +238,16 @@ Cadence im Detail am RecurringElement. **PO-Validation 2026-07-28:** Detaillierte Findings + Architektur-Urteil → `docs/sprints/Sprint1_AP2_1_Validation_Findings_Register_v0.1.md` (F-A1-01…10) -**PO-Produkturteil A1:** **No-Go** — Leitfrage nicht erfüllt; Tages-Tracking, Messgrößen, Gate-Fortschritt und IA fehlen für echten Einsatz. Architektur/Spec **nicht** falsch; **Minimal-Slice** (CadenceInstance, Work=Übung) vor Re-Abnahme nötig. +**PO-Produkturteil A1 (2026-07-28):** **No-Go** — Cadence ohne Progressions-IA. **Option C (PO-Lock):** [`SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](SPEC_A1_Progression_Model_PO_Lock_v0.1.md) — Multi-Strang vor Re-Abnahme. | Bereich | Spec | Code/Truth | Lücke | |---------|------|------------|-------| -| Registry / Kit | A1 + Stufen | weitgehend AP2.0e/Kit | Multi-Übung/Alternanz | -| Methode | `maturity_progression` | vorhanden | Ranking Alternanz Spec-D | -| Stage Activity Set | verbindlich | Teilmenge | Stage-Bindung + UI Detail | -| Alternanz-Felder | verbindlich | fehlt/teilweise | Persistenz + Strategy | -| EFS Pflicht | focus/frequency | Seeds prüfen | Seed-AP | -| steering_elements | D11 | teilweise | FE/BE Parität | -| UI Stufe-Detail | Pflicht | lückenhaft | UI-AP | +| Progressionsmodell | PO-Lock | linear + flat recurring | Multi-Strang + Gate-Activity-Set | +| Horizon multi-active | PO-Lock §3 | single-active (widerrufen) | AP-A1-PM-3 | +| Stage Activity Set | Gate + Strang | Teilmenge | Gate-Detail UI + `project_id` | +| Registry / Kit | Demo Spagat | geliefert | reicht nicht für PO-Kernbild | +| UI Plan Struktur/Gate | Pflicht | fehlt | AP-A1-PM-4 | +| Alternanz / metric | verbindlich | fehlt/teilweise | AP-A1-PM-5+ | --- @@ -254,5 +260,25 @@ Fachmodell vollständig. Lücken (Alternanz, Multi-Übung-UI, EFS-Seeds) = Imple ## 13. Nachfolger - Alternanz-Persistenz + Strategy -- UI Stufe-Detail / Activity Set -- Template Kumite +- UI Plan → Struktur + Gate-Detail / Activity Set +- Template Kumite / Mawashi (Multi-Strang) + +--- + +## 14. PO-Lock Progressionsmodell (2026-07-28, Option C) + +**Verbindlich** für Horizon, Stränge, Gate-Activity-Sets und MVP-Abgrenzung: + +→ [`SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](SPEC_A1_Progression_Model_PO_Lock_v0.1.md) + +Kurzfassung: + +| Thema | Lock | +|-------|------| +| Strang | `Project` — parallele Fähigkeits-Pfade | +| Gate | `maturity_stage` im Graph; Join-Gates mit `requires` | +| Übung | `RecurringElement` = Übung; Rhythmus = Feld; **nicht** losgelöstes Objekt | +| Horizon | **Multi-active** Gates; pro Strang max. 1 aktives Gate auf Pfad | +| MVP A1 Go | ≥2 Stränge + Join + Gate-Activity-Set + Today — nicht nur lineares Spagat-Demo | + +Implementierungsfolge: PO-Lock §8 (`AP-A1-PM-1` … `PM-6`). diff --git a/docs/sprints/Sprint1_AP2_1_MVP_Validation_Report_v0.3.md b/docs/sprints/Sprint1_AP2_1_MVP_Validation_Report_v0.3.md index 67f32f8..0c8c0e8 100644 --- a/docs/sprints/Sprint1_AP2_1_MVP_Validation_Report_v0.3.md +++ b/docs/sprints/Sprint1_AP2_1_MVP_Validation_Report_v0.3.md @@ -19,18 +19,18 @@ ## 1. Executive Summary -| Entscheidung | ☐ Go Stufe A · ☐ Bedingt · ☑ No-Go (bis A1 nachgezogen oder Scope-PO) | +| Entscheidung | ☐ Go Stufe A · ☐ Bedingt · ☑ No-Go (A1 bis Progressions-IA; Option C PO-Lock) | |--------------|-------------------------------------| **Kurzfazit (3–5 Sätze):** Technisch: pytest grün, A1-Happy-Path (Anlage → Verify → Stufenwechsel) durchspielbar. -Produkt (PO): **A1 No-Go** — Leitfrage nicht beantwortbar; tagesbezogenes Tracking, Messgrößen und Gate-Fortschritt fehlen; extreme Unübersichtlichkeit; **Hürden > Nutzen**. A2/B2b noch offen. +Produkt (PO): **A1 No-Go** — Cadence-Plumbing reicht nicht; Nutzerbild = **parallele Stränge + Gate-Graph + Activity Sets**, nicht flache „Wiederkehrend“-Liste. **PO-Entscheid Option C:** Spec schärfen → [`SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](../product/archetypes/SPEC_A1_Progression_Model_PO_Lock_v0.1.md). A2/B2b noch offen. **Blocker (falls No-Go / Bedingt):** -- **A1:** CadenceInstance/Tages-„erledigt“, Work=Übung, Gate-Fortschritt, IA — siehe Findings Register §6.1 (F-A1-02…10) -- **Gesamt Stufe A:** MVP §4 verlangt A1 — Go erst nach A1 Minimum Slice **oder** PO-Scope-Entscheidung (A1 aus Stufe-A-Abnahfe) +- **A1:** Progressions-IA gemäß PO-Lock §6–8 (`AP-A1-PM-2` … `PM-6`) — Multi-Strang, Gate-Activity-Set, Plan-UI, Today gruppiert +- **Gesamt Stufe A:** MVP §4 verlangt A1 — Go erst nach PO-Kernbild (≥2 Stränge + Join) durchspielbar --- diff --git a/docs/sprints/Sprint1_AP2_1_Validation_Findings_Register_v0.1.md b/docs/sprints/Sprint1_AP2_1_Validation_Findings_Register_v0.1.md index 746a751..341d555 100644 --- a/docs/sprints/Sprint1_AP2_1_Validation_Findings_Register_v0.1.md +++ b/docs/sprints/Sprint1_AP2_1_Validation_Findings_Register_v0.1.md @@ -206,13 +206,22 @@ Alternanz, volle Metric-Engine, Activity-Set-Automation → **nach** Slice 1–4 ## 7. Konsequenz für AP2.1 / MVP Stufe A -| Option | Bedeutung | -|--------|-----------| -| **A — A1 nachziehen** | Validation pausieren; **A1 Minimum Slice** (§6.1) liefern; A1 erneut smoke-testen | -| **B — MVP-Definition anpassen** | A1 vorübergehend aus Stufe-A-Abnahfe (PO-Entscheidung + Doku) — **nur wenn A2+B2b als MVP reichen** | -| **C — Gesamt No-Go** | AP2.1 stoppt bei Stufe A bis A1 oder Scope-Entscheidung | +| Option | Bedeutung | PO 2026-07-28 | +|--------|-----------|---------------| +| **A — A1 nachziehen** | Minimum Slice liefern | ◐ Cadence done; Progressions-IA offen | +| **B — MVP-Definition anpassen** | A1 aus Stufe-A-Abnahfe | nicht gewählt | +| **C — Spec schärfen, dann implementieren** | PO-Lock vor Coding | **✓ gewählt** | -**Empfehlung:** **Option A** — Architektur trägt; Spec war richtig; Implementierung war zu dünn. A2/B2b-Smoke **parallel** möglich, Gesamt-Go aber nicht ohne A1-Lösung (Option A oder explizite B). +**PO-Lock (Option C):** [`docs/product/archetypes/SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](../product/archetypes/SPEC_A1_Progression_Model_PO_Lock_v0.1.md) + +Kernentscheide: + +- **Multi-Strang** (Projects) + **Gate-Graph** + **Join-Gates** +- **Horizon:** mehrere aktive Gates (nicht eine Stufe initiative-weit) +- **Übung** = `RecurringElement` am Gate + Strang; Rhythmus = Feld +- **A1 Go MVP:** ≥2 Stränge + 1 Join + Gate-Activity-Set + Today — nicht nur Spagat-Demo + +**Validation A1:** pausiert bis `AP-A1-PM-4` (Plan Struktur + Gate-Detail). A2/B2b-Smoke **parallel** möglich. --- diff --git a/docs/sprints/Sprint1_AP_A1_Minimum_Usability_Slice_v0.1.md b/docs/sprints/Sprint1_AP_A1_Minimum_Usability_Slice_v0.1.md index f761430..2d3a2a9 100644 --- a/docs/sprints/Sprint1_AP_A1_Minimum_Usability_Slice_v0.1.md +++ b/docs/sprints/Sprint1_AP_A1_Minimum_Usability_Slice_v0.1.md @@ -1,7 +1,7 @@ # AP A1 — Minimum Usability Slice ## Auftrag v0.1 -**Status:** freigegeben (PO folgt Option A, 2026-07-28) · **Implementierung A1.1–A1.3 abgeschlossen** (Re-Abnahme ausstehend) +**Status:** freigegeben (PO folgt Option A, 2026-07-28) · **superseded by Option C PO-Lock** — siehe [`SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](../archetypes/SPEC_A1_Progression_Model_PO_Lock_v0.1.md) **Auslöser:** AP2.1 PO No-Go A1 — [`Sprint1_AP2_1_Validation_Findings_Register_v0.1.md`](Sprint1_AP2_1_Validation_Findings_Register_v0.1.md) §6.1 **Ziel:** A1 **real nutzbar** für Tages-Tracking + Gate-Einschätzung + klare Today-Ansicht — **ohne** Architektur-Neudesign. diff --git a/frontend/src/components/JoinWaitlistPanel.jsx b/frontend/src/components/JoinWaitlistPanel.jsx new file mode 100644 index 0000000..c6b5170 --- /dev/null +++ b/frontend/src/components/JoinWaitlistPanel.jsx @@ -0,0 +1,55 @@ +import { useMemo } from 'react' +import { EmptyState } from './EmptyState.jsx' + +/** + * A1 — Join-Schaltknoten: wartende Eingänge (kein Activity Set am Join). + */ +export function JoinWaitlistPanel({ progressionState = null }) { + const joins = useMemo(() => { + const list = progressionState?.joins || [] + return list.filter( + (join) => join.status !== 'reached' && (join.pending_inputs?.length || 0) > 0, + ) + }, [progressionState]) + + if (joins.length === 0) { + return ( +
+

Join-Fortschritt

+ +
+ ) + } + + return ( +
+
+
+

Join-Fortschritt

+

+ Konsolidierte Meilensteine — welche Strang-Gates fehlen noch zum Schalten? +

+
+
+ +
+ ) +} diff --git a/frontend/src/components/MaturityStagePanel.jsx b/frontend/src/components/MaturityStagePanel.jsx index 3bc6fe9..3133a0b 100644 --- a/frontend/src/components/MaturityStagePanel.jsx +++ b/frontend/src/components/MaturityStagePanel.jsx @@ -16,7 +16,8 @@ export function MaturityStagePanel({ .filter((item) => item.item_type === 'maturity_stage') .sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)) - const activeStage = stages.find((s) => s.status === 'active') + const activeStages = stages.filter((s) => s.status === 'active') + const activeStage = activeStages[0] useEffect(() => { if (!initiativeId) return undefined @@ -40,15 +41,20 @@ export function MaturityStagePanel({ ) : ( <> - {activeStage && ( + {activeStages.length > 0 && ( <>

- Aktive Stufe:{' '} - - {activeStage.title} - + Aktive Gate(s):{' '} + {activeStages.map((stage, index) => ( + + {index > 0 ? ' · ' : ''} + + {stage.title} + + + ))}

- {activeProgress && ( + {activeStage && activeProgress && (

Gate-Fortschritt:{' '} diff --git a/frontend/src/components/RecurringRhythmPanel.jsx b/frontend/src/components/RecurringRhythmPanel.jsx index 77482d7..3e88546 100644 --- a/frontend/src/components/RecurringRhythmPanel.jsx +++ b/frontend/src/components/RecurringRhythmPanel.jsx @@ -12,10 +12,10 @@ function formatDueAt(iso) { } } -function activeMaturityStage(roadmapItems = []) { +function activeMaturityStages(roadmapItems = []) { return [...roadmapItems] .filter((item) => item.item_type === 'maturity_stage' && item.status === 'active') - .sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))[0] + .sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)) } function practicesForStage(recurringItems = [], stageId) { @@ -32,20 +32,22 @@ export function RecurringRhythmPanel({ canManage = false, embedded = true, }) { - const stage = activeMaturityStage(roadmapItems) - const stagePractices = practicesForStage(recurringItems, stage?.id) + const stages = activeMaturityStages(roadmapItems) + const stagePractices = stages.flatMap((stage) => + practicesForStage(recurringItems, stage.id), + ) const active = stagePractices.filter((item) => item.status === 'active') const paused = stagePractices.filter((item) => item.status === 'paused') const body = - !stage ? ( - + stages.length === 0 ? ( + ) : stagePractices.length === 0 ? ( - + ) : ( <>

- Stufe {stage.title} — {active.length} aktiv + {stages.length} aktive Gate(s) — {active.length} Übung(en) aktiv {paused.length > 0 ? `, ${paused.length} pausiert` : ''}.