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 (
+
+ Konsolidierte Meilensteine — welche Strang-Gates fehlen noch zum Schalten?
+
+ Wartet auf:{' '}
+ {(join.pending_inputs || [])
+ .map((input) =>
+ input.strand_title
+ ? `${input.strand_title} / ${input.title}`
+ : input.title,
+ )
+ .join(' · ')}
+ Join-Fortschritt
+ Join-Fortschritt
+
+ {joins.map((join) => (
+
+
- 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 ? (
-
- Stufe {stage.title} — {active.length} aktiv
+ {stages.length} aktive Gate(s) — {active.length} Übung(en) aktiv
{paused.length > 0 ? `, ${paused.length} pausiert` : ''}.
- Activity Set der aktiven Reifegrad-Stufe — nicht die Gates selbst (A1).
+ Activity Sets aller aktiven Work-Gates — Join-Knoten haben keine Übungen (A1).
- {stage ? (
- <>
- Aktive Stufe: {stage.title} — pro Übung erledigen, optional Messwert
- notieren.
- >
- ) : (
- 'Tages-Tracking für die aktive Reifegrad-Stufe.'
- )}
+ {gates.length > 0
+ ? `${gates.length} aktive Gate(s) — pro Übung erledigen, optional Messwert notieren.`
+ : 'Tages-Tracking für aktive Work-Gates auf allen Strängen.'}
@@ -92,9 +94,9 @@ export function RecurringRhythmPanel({
Übungen der Stufe
+ Übungen der aktiven Gates
Heutige Übungen