feat(A1): Progression Join-Graph und Multi-Strang-Horizon
Some checks failed
Deploy Development / deploy (push) Successful in 52s
Test Suite / pytest-backend (push) Failing after 5m3s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
Some checks failed
Deploy Development / deploy (push) Successful in 52s
Test Suite / pytest-backend (push) Failing after 5m3s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
join_gate, strand_project_id, Join-Auto-Schalten, multi-active Gates, Progression-Demo und PO-Lock-Spec. FE: Today strang-gruppiert, Join-Warteliste in Kontrolle. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
192a65a798
commit
3a0ef3a734
|
|
@ -16,6 +16,7 @@ from steering.context import get_steering_context_dto
|
||||||
from steering.kernel import evaluate_steering
|
from steering.kernel import evaluate_steering
|
||||||
from steering.signals.snapshot_signals import derive_initiative_signals
|
from steering.signals.snapshot_signals import derive_initiative_signals
|
||||||
from services.initiatives import get_initiative
|
from services.initiatives import get_initiative
|
||||||
|
from services.progression_graph import compute_progression_state
|
||||||
from tenant_context import TenantContext
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
OPEN_BLOCKER = ("open", "in_progress")
|
OPEN_BLOCKER = ("open", "in_progress")
|
||||||
|
|
@ -383,6 +384,9 @@ def get_initiative_steering_snapshot(
|
||||||
"actions": actions,
|
"actions": actions,
|
||||||
"roadmap_items": roadmap_items,
|
"roadmap_items": roadmap_items,
|
||||||
"milestones": roadmap_items,
|
"milestones": roadmap_items,
|
||||||
|
"progression_state": compute_progression_state(
|
||||||
|
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||||
|
),
|
||||||
"initiative_level": {
|
"initiative_level": {
|
||||||
"blockers": unlinked_blockers,
|
"blockers": unlinked_blockers,
|
||||||
"evidence": unlinked_evidence,
|
"evidence": unlinked_evidence,
|
||||||
|
|
|
||||||
26
backend/migrations/032_a1_progression_join.sql
Normal file
26
backend/migrations/032_a1_progression_join.sql
Normal file
|
|
@ -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;
|
||||||
|
|
@ -18,7 +18,7 @@ router = APIRouter(tags=["roadmap"])
|
||||||
|
|
||||||
class RoadmapItemCreateRequest(BaseModel):
|
class RoadmapItemCreateRequest(BaseModel):
|
||||||
title: str = Field(min_length=1, max_length=255)
|
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 = ""
|
goal_description: str = ""
|
||||||
definition_of_done: list[Any] = Field(default_factory=list)
|
definition_of_done: list[Any] = Field(default_factory=list)
|
||||||
status: Literal[
|
status: Literal[
|
||||||
|
|
@ -27,12 +27,13 @@ class RoadmapItemCreateRequest(BaseModel):
|
||||||
sequencing_mode: Literal["sequential", "parallel", "optional"] = "sequential"
|
sequencing_mode: Literal["sequential", "parallel", "optional"] = "sequential"
|
||||||
target_date: Optional[date] = None
|
target_date: Optional[date] = None
|
||||||
sort_order: int = 0
|
sort_order: int = 0
|
||||||
|
strand_project_id: Optional[str] = None
|
||||||
initial_criterion_title: Optional[str] = Field(default=None, max_length=500)
|
initial_criterion_title: Optional[str] = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
class RoadmapItemUpdateRequest(BaseModel):
|
class RoadmapItemUpdateRequest(BaseModel):
|
||||||
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
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
|
goal_description: Optional[str] = None
|
||||||
definition_of_done: Optional[list[Any]] = None
|
definition_of_done: Optional[list[Any]] = None
|
||||||
status: Optional[
|
status: Optional[
|
||||||
|
|
@ -42,6 +43,8 @@ class RoadmapItemUpdateRequest(BaseModel):
|
||||||
target_date: Optional[date] = None
|
target_date: Optional[date] = None
|
||||||
clear_target_date: bool = False
|
clear_target_date: bool = False
|
||||||
sort_order: Optional[int] = None
|
sort_order: Optional[int] = None
|
||||||
|
strand_project_id: Optional[str] = None
|
||||||
|
clear_strand_project: bool = False
|
||||||
|
|
||||||
|
|
||||||
class DependencyCreateRequest(BaseModel):
|
class DependencyCreateRequest(BaseModel):
|
||||||
|
|
@ -166,6 +169,21 @@ def get_initiative_roadmap_graph_state(
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
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")
|
@initiative_router.get("/{initiative_id}/roadmap/plan-ist-view")
|
||||||
def get_initiative_plan_ist_view(
|
def get_initiative_plan_ist_view(
|
||||||
initiative_id: str,
|
initiative_id: str,
|
||||||
|
|
@ -228,6 +246,7 @@ def create_initiative_roadmap_item(
|
||||||
sequencing_mode=body.sequencing_mode,
|
sequencing_mode=body.sequencing_mode,
|
||||||
target_date=body.target_date,
|
target_date=body.target_date,
|
||||||
sort_order=body.sort_order,
|
sort_order=body.sort_order,
|
||||||
|
strand_project_id=body.strand_project_id,
|
||||||
initial_criterion_title=body.initial_criterion_title,
|
initial_criterion_title=body.initial_criterion_title,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
|
|
@ -265,6 +284,8 @@ def update_roadmap_item(
|
||||||
target_date=body.target_date,
|
target_date=body.target_date,
|
||||||
clear_target_date=body.clear_target_date,
|
clear_target_date=body.clear_target_date,
|
||||||
sort_order=body.sort_order,
|
sort_order=body.sort_order,
|
||||||
|
strand_project_id=body.strand_project_id,
|
||||||
|
clear_strand_project=body.clear_strand_project,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
|
||||||
|
|
@ -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(
|
def _apply_b2b(
|
||||||
*,
|
*,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,24 @@ STAGE_PRACTICE_SETS: dict[str, list[tuple[str, str]]] = {
|
||||||
"Stufe 3 — Ziel": [
|
"Stufe 3 — Ziel": [
|
||||||
("Voller Spagat", "Täglich Zielposition — Messwert dokumentieren"),
|
("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
|
*, tenant_id: str, initiative_id: str
|
||||||
) -> Optional[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
from services.roadmap import list_roadmap_items_for_initiative
|
from services.roadmap import list_roadmap_items_for_initiative
|
||||||
|
|
||||||
stages = [
|
stages = [
|
||||||
|
|
@ -40,34 +52,17 @@ def get_active_maturity_stage(
|
||||||
)
|
)
|
||||||
if item.get("item_type") == "maturity_stage" and item.get("status") == "active"
|
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", "")))
|
stages.sort(key=lambda s: (s.get("sort_order", 0), s.get("title", "")))
|
||||||
return stages[0]
|
return stages
|
||||||
|
|
||||||
|
|
||||||
def enforce_single_active_maturity_stage(
|
def get_active_maturity_stage(
|
||||||
cur,
|
*, tenant_id: str, initiative_id: str
|
||||||
*,
|
) -> Optional[dict[str, Any]]:
|
||||||
tenant_id: str,
|
stages = get_active_maturity_stages(
|
||||||
initiative_id: str,
|
tenant_id=tenant_id, initiative_id=initiative_id
|
||||||
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),
|
|
||||||
)
|
)
|
||||||
|
return stages[0] if stages else None
|
||||||
|
|
||||||
|
|
||||||
def list_practices_for_stage(
|
def list_practices_for_stage(
|
||||||
|
|
@ -107,6 +102,34 @@ def pause_all_practices(
|
||||||
return paused
|
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(
|
def seed_practices_for_stage(
|
||||||
*,
|
*,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
|
|
@ -117,6 +140,7 @@ def seed_practices_for_stage(
|
||||||
"""Idempotent: create missing exercises for a stage; activate existing."""
|
"""Idempotent: create missing exercises for a stage; activate existing."""
|
||||||
stage_id = stage["id"]
|
stage_id = stage["id"]
|
||||||
stage_title = stage.get("title") or ""
|
stage_title = stage.get("title") or ""
|
||||||
|
strand_project_id = stage.get("strand_project_id")
|
||||||
defs = STAGE_PRACTICE_SETS.get(stage_title)
|
defs = STAGE_PRACTICE_SETS.get(stage_title)
|
||||||
if not defs:
|
if not defs:
|
||||||
defs = [
|
defs = [
|
||||||
|
|
@ -161,6 +185,7 @@ def seed_practices_for_stage(
|
||||||
interval_days=1,
|
interval_days=1,
|
||||||
next_due_at=now,
|
next_due_at=now,
|
||||||
roadmap_item_id=stage_id,
|
roadmap_item_id=stage_id,
|
||||||
|
project_id=strand_project_id,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
created.append(row)
|
created.append(row)
|
||||||
|
|
@ -175,9 +200,19 @@ def activate_stage_practice_set(
|
||||||
stage: dict[str, Any],
|
stage: dict[str, Any],
|
||||||
user_id: Optional[str] = None,
|
user_id: Optional[str] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
paused = pause_all_practices(
|
strand_project_id = stage.get("strand_project_id")
|
||||||
tenant_id=tenant_id, initiative_id=initiative_id, user_id=user_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(
|
exercises = seed_practices_for_stage(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
initiative_id=initiative_id,
|
initiative_id=initiative_id,
|
||||||
|
|
|
||||||
|
|
@ -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 __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Optional
|
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(
|
def on_maturity_stage_reached(
|
||||||
*,
|
*,
|
||||||
|
|
@ -15,61 +12,14 @@ def on_maturity_stage_reached(
|
||||||
reached_item_id: str,
|
reached_item_id: str,
|
||||||
user_id: Optional[str] = None,
|
user_id: Optional[str] = None,
|
||||||
) -> Optional[dict[str, Any]]:
|
) -> Optional[dict[str, Any]]:
|
||||||
"""Pause old routines, activate next stage, seed exercise set for next stage."""
|
from services.progression_graph import on_work_gate_reached
|
||||||
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
|
|
||||||
|
|
||||||
stages = [
|
return on_work_gate_reached(
|
||||||
s
|
tenant_id=tenant_id,
|
||||||
for s in list_roadmap_items_for_initiative(
|
initiative_id=initiative_id,
|
||||||
tenant_id=tenant_id, initiative_id=initiative_id
|
reached_item_id=reached_item_id,
|
||||||
)
|
user_id=user_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,
|
|
||||||
)
|
)
|
||||||
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(
|
def on_maturity_stage_reopened(
|
||||||
|
|
@ -79,7 +29,10 @@ def on_maturity_stage_reopened(
|
||||||
stage_id: str,
|
stage_id: str,
|
||||||
user_id: Optional[str] = None,
|
user_id: Optional[str] = None,
|
||||||
) -> dict[str, Any]:
|
) -> 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)
|
stage = get_roadmap_item(tenant_id=tenant_id, item_id=stage_id)
|
||||||
if not stage:
|
if not stage:
|
||||||
return {"stage_id": stage_id, "active_practices": []}
|
return {"stage_id": stage_id, "active_practices": []}
|
||||||
|
|
|
||||||
546
backend/services/progression_graph.py
Normal file
546
backend/services/progression_graph.py
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Literal, Optional
|
from typing import Any, Literal, Optional
|
||||||
|
|
||||||
from psycopg2.extras import RealDictCursor
|
from psycopg2.extras import RealDictCursor
|
||||||
|
|
@ -16,14 +16,14 @@ RecurringStatus = Literal["active", "paused", "ended"]
|
||||||
RECURRING_STATUSES = frozenset({"active", "paused", "ended"})
|
RECURRING_STATUSES = frozenset({"active", "paused", "ended"})
|
||||||
|
|
||||||
_RECURRING_COLUMNS = """
|
_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
|
interval_days, next_due_at, created_at, updated_at
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
result = dict(row)
|
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):
|
if result.get(key):
|
||||||
result[key] = str(result[key])
|
result[key] = str(result[key])
|
||||||
for ts_key in ("created_at", "updated_at", "next_due_at"):
|
for ts_key in ("created_at", "updated_at", "next_due_at"):
|
||||||
|
|
@ -47,6 +47,7 @@ def create_recurring_element(
|
||||||
interval_days: Optional[int] = None,
|
interval_days: Optional[int] = None,
|
||||||
next_due_at: Optional[datetime] = None,
|
next_due_at: Optional[datetime] = None,
|
||||||
roadmap_item_id: Optional[str] = None,
|
roadmap_item_id: Optional[str] = None,
|
||||||
|
project_id: Optional[str] = None,
|
||||||
user_id: Optional[str] = None,
|
user_id: Optional[str] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
title = title.strip()
|
title = title.strip()
|
||||||
|
|
@ -64,16 +65,17 @@ def create_recurring_element(
|
||||||
cur.execute(
|
cur.execute(
|
||||||
f"""
|
f"""
|
||||||
INSERT INTO recurring_elements (
|
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
|
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}
|
RETURNING {_RECURRING_COLUMNS}
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
tenant_id,
|
tenant_id,
|
||||||
initiative_id,
|
initiative_id,
|
||||||
roadmap_item_id,
|
roadmap_item_id,
|
||||||
|
project_id,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
status,
|
status,
|
||||||
|
|
@ -166,6 +168,8 @@ def update_recurring_element(
|
||||||
clear_next_due_at: bool = False,
|
clear_next_due_at: bool = False,
|
||||||
roadmap_item_id: Optional[str] = None,
|
roadmap_item_id: Optional[str] = None,
|
||||||
clear_roadmap_item_id: bool = False,
|
clear_roadmap_item_id: bool = False,
|
||||||
|
project_id: Optional[str] = None,
|
||||||
|
clear_project_id: bool = False,
|
||||||
) -> Optional[dict[str, Any]]:
|
) -> Optional[dict[str, Any]]:
|
||||||
existing = get_recurring_element(tenant_id=tenant_id, recurring_id=recurring_id)
|
existing = get_recurring_element(tenant_id=tenant_id, recurring_id=recurring_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
|
|
@ -205,6 +209,11 @@ def update_recurring_element(
|
||||||
elif roadmap_item_id is not None:
|
elif roadmap_item_id is not None:
|
||||||
updates.append("roadmap_item_id = %s")
|
updates.append("roadmap_item_id = %s")
|
||||||
params.append(roadmap_item_id)
|
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:
|
if not updates:
|
||||||
return existing
|
return existing
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,14 @@ from services.audit import log_audit
|
||||||
from services.initiatives import get_initiative
|
from services.initiatives import get_initiative
|
||||||
from services.roadmap_criteria import create_default_criterion, prepare_criteria_for_verify
|
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[
|
RoadmapItemStatus = Literal[
|
||||||
"planned", "active", "at_risk", "reached", "moved", "discarded"
|
"planned", "active", "at_risk", "reached", "moved", "discarded"
|
||||||
]
|
]
|
||||||
SequencingMode = Literal["sequential", "parallel", "optional"]
|
SequencingMode = Literal["sequential", "parallel", "optional"]
|
||||||
DependencyType = Literal["requires", "blocks", "related", "parallel_group", "optional_branch"]
|
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(
|
ROADMAP_ITEM_STATUSES = frozenset(
|
||||||
{"planned", "active", "at_risk", "reached", "moved", "discarded"}
|
{"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]:
|
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
result = dict(row)
|
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):
|
if result.get(key):
|
||||||
result[key] = str(result[key])
|
result[key] = str(result[key])
|
||||||
if result.get("target_date"):
|
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.id, ri.tenant_id, ri.roadmap_id, r.initiative_id,
|
||||||
ri.item_type, ri.title, ri.goal_description, ri.definition_of_done,
|
ri.item_type, ri.title, ri.goal_description, ri.definition_of_done,
|
||||||
ri.status, ri.sequencing_mode, ri.target_date, ri.sort_order,
|
ri.status, ri.sequencing_mode, ri.target_date, ri.sort_order,
|
||||||
|
ri.strand_project_id,
|
||||||
ri.created_at, ri.updated_at
|
ri.created_at, ri.updated_at
|
||||||
FROM roadmap_items ri
|
FROM roadmap_items ri
|
||||||
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
|
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.id, ri.tenant_id, ri.roadmap_id, r.initiative_id,
|
||||||
ri.item_type, ri.title, ri.goal_description, ri.definition_of_done,
|
ri.item_type, ri.title, ri.goal_description, ri.definition_of_done,
|
||||||
ri.status, ri.sequencing_mode, ri.target_date, ri.sort_order,
|
ri.status, ri.sequencing_mode, ri.target_date, ri.sort_order,
|
||||||
|
ri.strand_project_id,
|
||||||
ri.created_at, ri.updated_at
|
ri.created_at, ri.updated_at
|
||||||
FROM roadmap_items ri
|
FROM roadmap_items ri
|
||||||
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
|
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",
|
sequencing_mode: SequencingMode = "sequential",
|
||||||
target_date: Optional[date] = None,
|
target_date: Optional[date] = None,
|
||||||
sort_order: int = 0,
|
sort_order: int = 0,
|
||||||
|
strand_project_id: Optional[str] = None,
|
||||||
initial_criterion_title: Optional[str] = None,
|
initial_criterion_title: Optional[str] = None,
|
||||||
user_id: Optional[str] = None,
|
user_id: Optional[str] = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
|
@ -255,12 +258,13 @@ def create_roadmap_item(
|
||||||
"""
|
"""
|
||||||
INSERT INTO roadmap_items (
|
INSERT INTO roadmap_items (
|
||||||
tenant_id, roadmap_id, item_type, title, goal_description,
|
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,
|
RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description,
|
||||||
definition_of_done, status, sequencing_mode, target_date,
|
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,
|
tenant_id,
|
||||||
|
|
@ -273,11 +277,12 @@ def create_roadmap_item(
|
||||||
sequencing_mode,
|
sequencing_mode,
|
||||||
target_date,
|
target_date,
|
||||||
sort_order,
|
sort_order,
|
||||||
|
strand_project_id,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
row = _serialize_row(dict(cur.fetchone()))
|
row = _serialize_row(dict(cur.fetchone()))
|
||||||
row["initiative_id"] = initiative_id
|
row["initiative_id"] = initiative_id
|
||||||
if item_type != "work_cycle":
|
if item_type not in ("work_cycle", "join_gate"):
|
||||||
create_default_criterion(
|
create_default_criterion(
|
||||||
cur,
|
cur,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
|
|
@ -324,6 +329,8 @@ def update_roadmap_item(
|
||||||
target_date: Optional[date] = None,
|
target_date: Optional[date] = None,
|
||||||
clear_target_date: bool = False,
|
clear_target_date: bool = False,
|
||||||
sort_order: Optional[int] = None,
|
sort_order: Optional[int] = None,
|
||||||
|
strand_project_id: Optional[str] = None,
|
||||||
|
clear_strand_project: bool = False,
|
||||||
) -> Optional[dict[str, Any]]:
|
) -> Optional[dict[str, Any]]:
|
||||||
existing = get_roadmap_item(tenant_id=tenant_id, item_id=item_id)
|
existing = get_roadmap_item(tenant_id=tenant_id, item_id=item_id)
|
||||||
if not existing:
|
if not existing:
|
||||||
|
|
@ -373,6 +380,11 @@ def update_roadmap_item(
|
||||||
if sort_order is not None:
|
if sort_order is not None:
|
||||||
updates.append("sort_order = %s")
|
updates.append("sort_order = %s")
|
||||||
params.append(sort_order)
|
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:
|
if not updates:
|
||||||
return existing
|
return existing
|
||||||
|
|
@ -383,14 +395,22 @@ def update_roadmap_item(
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
try:
|
try:
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
if status == "active" and existing.get("item_type") == "maturity_stage":
|
if (
|
||||||
from services.maturity_practice import enforce_single_active_maturity_stage
|
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,
|
cur,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
initiative_id=str(existing["initiative_id"]),
|
initiative_id=str(existing["initiative_id"]),
|
||||||
keep_active_id=item_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(
|
cur.execute(
|
||||||
f"""
|
f"""
|
||||||
|
|
@ -399,7 +419,7 @@ def update_roadmap_item(
|
||||||
WHERE id = %s AND tenant_id = %s
|
WHERE id = %s AND tenant_id = %s
|
||||||
RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description,
|
RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description,
|
||||||
definition_of_done, status, sequencing_mode, target_date,
|
definition_of_done, status, sequencing_mode, target_date,
|
||||||
sort_order, created_at, updated_at
|
sort_order, strand_project_id, created_at, updated_at
|
||||||
""",
|
""",
|
||||||
params,
|
params,
|
||||||
)
|
)
|
||||||
|
|
@ -652,6 +672,10 @@ def verify_reached(
|
||||||
raise ValueError("RoadmapItem nicht gefunden")
|
raise ValueError("RoadmapItem nicht gefunden")
|
||||||
if item["status"] in TERMINAL_STATUSES:
|
if item["status"] in TERMINAL_STATUSES:
|
||||||
raise ValueError(f"RoadmapItem bereits terminal: {item['status']}")
|
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"]
|
initiative_id = item["initiative_id"]
|
||||||
conn = get_connection()
|
conn = get_connection()
|
||||||
|
|
@ -694,7 +718,7 @@ def verify_reached(
|
||||||
WHERE id = %s AND tenant_id = %s
|
WHERE id = %s AND tenant_id = %s
|
||||||
RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description,
|
RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description,
|
||||||
definition_of_done, status, sequencing_mode, target_date,
|
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),
|
(item_id, tenant_id),
|
||||||
)
|
)
|
||||||
|
|
@ -718,9 +742,9 @@ def verify_reached(
|
||||||
details={"roadmap_item_id": item_id, "verify_reason": verify_reason},
|
details={"roadmap_item_id": item_id, "verify_reason": verify_reason},
|
||||||
)
|
)
|
||||||
if row.get("item_type") == "maturity_stage":
|
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,
|
tenant_id=tenant_id,
|
||||||
initiative_id=initiative_id,
|
initiative_id=initiative_id,
|
||||||
reached_item_id=item_id,
|
reached_item_id=item_id,
|
||||||
|
|
@ -770,13 +794,16 @@ def reopen_roadmap_item(
|
||||||
try:
|
try:
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
if item.get("item_type") == "maturity_stage":
|
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,
|
cur,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
initiative_id=str(item["initiative_id"]),
|
initiative_id=str(item["initiative_id"]),
|
||||||
keep_active_id=item_id,
|
keep_active_id=item_id,
|
||||||
|
strand_project_id=item.get("strand_project_id"),
|
||||||
)
|
)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
|
|
@ -785,7 +812,7 @@ def reopen_roadmap_item(
|
||||||
WHERE id = %s AND tenant_id = %s
|
WHERE id = %s AND tenant_id = %s
|
||||||
RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description,
|
RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description,
|
||||||
definition_of_done, status, sequencing_mode, target_date,
|
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),
|
(item_id, tenant_id),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -74,13 +74,14 @@ def _reopen_item_if_reached(
|
||||||
if item["status"] != "reached":
|
if item["status"] != "reached":
|
||||||
return
|
return
|
||||||
if item.get("item_type") == "maturity_stage":
|
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,
|
cur,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
initiative_id=str(item["initiative_id"]),
|
initiative_id=str(item["initiative_id"]),
|
||||||
keep_active_id=item["id"],
|
keep_active_id=item["id"],
|
||||||
|
strand_project_id=item.get("strand_project_id"),
|
||||||
)
|
)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ from typing import Any
|
||||||
from steering.signals import default_rules
|
from steering.signals import default_rules
|
||||||
from steering.strategies.next_action.default_strategy import DefaultNextActionStrategy
|
from steering.strategies.next_action.default_strategy import DefaultNextActionStrategy
|
||||||
from steering.strategies.next_action.execution_ready import (
|
from steering.strategies.next_action.execution_ready import (
|
||||||
first_active_gate_id,
|
|
||||||
list_execution_ready_candidates,
|
list_execution_ready_candidates,
|
||||||
merge_candidates,
|
merge_candidates,
|
||||||
)
|
)
|
||||||
|
|
@ -43,7 +42,12 @@ class MaturityProgressionStrategy:
|
||||||
recurring = recurring_due_candidates(ctx, initiative_id, limit=limit)
|
recurring = recurring_due_candidates(ctx, initiative_id, limit=limit)
|
||||||
|
|
||||||
remaining = limit - len(recurring)
|
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]] = []
|
ready: list[dict[str, Any]] = []
|
||||||
if remaining > 0:
|
if remaining > 0:
|
||||||
ready = list_execution_ready_candidates(
|
ready = list_execution_ready_candidates(
|
||||||
|
|
|
||||||
111
backend/tests/test_a1_progression_join.py
Normal file
111
backend/tests/test_a1_progression_join.py
Normal file
|
|
@ -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
|
||||||
|
|
@ -44,7 +44,10 @@ def test_maturity_stage_reached_rotates_recurring(client):
|
||||||
assert verify.status_code == 200
|
assert verify.status_code == 200
|
||||||
body = verify.json()
|
body = verify.json()
|
||||||
assert body["status"] == "reached"
|
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(
|
recurring = client.get(
|
||||||
f"/api/initiatives/{initiative_id}/recurring",
|
f"/api/initiatives/{initiative_id}/recurring",
|
||||||
|
|
|
||||||
|
|
@ -2,14 +2,14 @@
|
||||||
|
|
||||||
**Prozess:** `../M1_Hybrid_Path_and_Depth_D_v0.1.md`
|
**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
|
**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 |
|
| Methode | Typ | Datei | Status |
|
||||||
|---------|-----|-------|--------|
|
|---------|-----|-------|--------|
|
||||||
| `sequential_dependency` | Primary (Ausführung) | SPEC_D_sequential_dependency_v0.1.md | vorläufig freigegeben|
|
| `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|
|
| `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|
|
| `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|
|
| `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|
|
| `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|
|
| `dispute_procedure` | Primary (Ausführung) | SPEC_D_dispute_procedure_v0.1.md | vorläufig freigegeben|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
## Steuerungsmethode — Spec-Tiefe D v0.1
|
## Steuerungsmethode — Spec-Tiefe D v0.1
|
||||||
|
|
||||||
**Status:** vorläufig freigegeben (PO 2026-07-26) — mit zugehöriger Archetyp-Vollspec; Implementierungsausreichendheit noch offen
|
**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`
|
**Default für:** A1 `initiative.maturity_journey`
|
||||||
**Prozess:** `M1_Hybrid_Path_and_Depth_D_v0.1.md`
|
**Prozess:** `M1_Hybrid_Path_and_Depth_D_v0.1.md`
|
||||||
**Quellen:** Decision-Lock A1, Archetyp-Vollspec A1, Kernel Freeze, AP2.0e
|
**Quellen:** Decision-Lock A1, Archetyp-Vollspec A1, Kernel Freeze, AP2.0e
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
| `method_key` | `maturity_progression` |
|
| `method_key` | `maturity_progression` |
|
||||||
| Rolle | primary (**Ausführung** — Kernel §6) |
|
| Rolle | primary (**Ausführung** — Kernel §6) |
|
||||||
| Label (DE) | Reifegrad-Progression |
|
| 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
|
## 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 |
|
| Horizont | Bedeutung |
|
||||||
|----------|-----------|
|
|----------|-----------|
|
||||||
| **Aktive `maturity_stage`** | fokussierte Stufe; nur deren Stage Activity Set liefert Next |
|
| **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) |
|
||||||
| Plan-Nebenblick | nächste Stufe / Graph-Voraussetzungen — nicht Leading für Next |
|
| **Strang** | `Project` — paralleler Fähigkeits-Pfad; Übungen mit `project_id` |
|
||||||
| Ist | CadenceInstances + Stage-Actions der aktiven Stufe |
|
| **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
|
## D4 Leading Work Item
|
||||||
|
|
||||||
**Primär:** `CadenceInstance` (Übung/Ritual am RecurringElement der aktiven Stufe).
|
**Primär:** `CadenceInstance` (Übung am `RecurringElement` eines **aktiven Gates**, optional Strang via `project_id`).
|
||||||
**Sekundär:** `Action` (Stage-gebunden: Prüfung, Workshop, Nachweis).
|
**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.
|
**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).
|
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
|
## D6 Next Action — konkrete Regeln
|
||||||
|
|
||||||
### Ready-Menge (aktive Stufe)
|
### Ready-Menge (aktive Gates)
|
||||||
|
|
||||||
Ein Work Item ist ready, wenn:
|
Ein Work Item ist ready, wenn:
|
||||||
|
|
||||||
1. gebunden an die **aktive** `maturity_stage` und Aktivität **aktiv**, und
|
1. gebunden an ein **aktives**, graph-ready **Gate** und Aktivität **aktiv**, und
|
||||||
2. eines von:
|
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 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
|
- **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
|
- **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 |
|
| practice done / Cadence erfüllt | Instanz schließen; nächste Instanz laut Cadence; bei Alternanz Rotation |
|
||||||
| Stage-Action done | aus Ready; ggf. Kriterien-Fortschritt |
|
| Stage-Action done | aus Ready; ggf. Kriterien-Fortschritt |
|
||||||
| Evidence / Kriterium erfüllt | Validation-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 |
|
| manuelle Adaptation (Set/Cadence/Alternanz) | Ready-Menge neu; auditiert |
|
||||||
| Cadence-Zeitpunkt erreicht | Event-Schub → Ready neu bewerten |
|
| 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` |
|
| next_work_strategy | `maturity_progression` |
|
||||||
| attention_rules | Codes D7 |
|
| attention_rules | Codes D7 |
|
||||||
| horizon_resolver | aktive `maturity_stage` |
|
| horizon_resolver | Menge aktiver, graph-ready Gates (Multi-Strang) |
|
||||||
| dod_policy | Stage-Kriterien / Verify |
|
| dod_policy | Stage-Kriterien / Verify |
|
||||||
| adaptation_hooks | `on_stage_reached` → Set-Wechsel; manuelle Set-Änderung |
|
| adaptation_hooks | `on_stage_reached` → Set-Wechsel; manuelle Set-Änderung |
|
||||||
| steering_elements | `next_action_primary`, `maturity_stage`, `recurring_rhythm` |
|
| 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 |
|
| Hybrid Dominanz | Cadence-Event + Stage-Plan — Kernel trägt Dual-Auslöser |
|
||||||
| Leading polymorph | Cadence + Action — Kernel Q2 |
|
| Leading polymorph | Cadence + Action — Kernel Q2 |
|
||||||
| Alternanz-State | Persistenz am Group-/Recurring-Kontext — Implementierungsdetail, fachlich hier verbindlich |
|
| 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.
|
**PO-Empfehlung:** A1-Vollspec §4 OM + diese Spec-D = Steuervertrag; UI nur in Archetyp-Vollspec.
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,12 @@
|
||||||
**UI-Navigation (Archetyp):** [`ARCHETYPE_UI_NAVIGATION_v0.1.md`](ARCHETYPE_UI_NAVIGATION_v0.1.md)
|
**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)
|
**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 |
|
| ID | Datei | Status |
|
||||||
|----|-------|--------|
|
|----|-------|--------|
|
||||||
| C1 | [SPEC_C1_dispute_case_v0.1.md](SPEC_C1_dispute_case_v0.1.md) | vorläufig freigegeben |
|
| 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 |
|
| 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 |
|
| 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 |
|
| B1 | [SPEC_B1_checklists_v0.1.md](SPEC_B1_checklists_v0.1.md) | vorläufig freigegeben |
|
||||||
|
|
|
||||||
|
|
@ -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.*
|
||||||
|
|
@ -2,8 +2,8 @@
|
||||||
## Archetyp-Vollspec (implementierbar) v0.1
|
## Archetyp-Vollspec (implementierbar) v0.1
|
||||||
|
|
||||||
**Status:** vorläufig freigegeben (PO 2026-07-26) — Zielbild; Implementierungsausreichendheit noch nicht abschließend bewertet
|
**Status:** vorläufig freigegeben (PO 2026-07-26) — Zielbild; Implementierungsausreichendheit noch nicht abschließend bewertet
|
||||||
**Stand:** 2026-07-26
|
**Stand:** 2026-07-28 (PO-Lock Progressionsmodell — siehe §14)
|
||||||
**MVP-Stufe:** A
|
**MVP-Stufe:** A (PO-Kernbild: Multi-Strang — §14.4)
|
||||||
**Decision-Lock:** A1 Reifegrad
|
**Decision-Lock:** A1 Reifegrad
|
||||||
**Default-Methode:** [`SPEC_D_maturity_progression_v0.1.md`](../../architecture/methods/SPEC_D_maturity_progression_v0.1.md)
|
**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`
|
**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.
|
6. Innerhalb Mittelstufe Übung hinzufügen (Adaptation), auditiert.
|
||||||
7. Optional zweites Fähigkeits-Project + Graph-Voraussetzung.
|
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. Plan- / Ist-Vertrag
|
||||||
|
|
||||||
### 4.1 Stufen / Phasen
|
### 4.1 Stufen / Phasen / Stränge
|
||||||
|
|
||||||
| Element | Rolle |
|
| Element | Rolle |
|
||||||
|---------|--------|
|
|---------|--------|
|
||||||
| `maturity_stage` (RoadmapItem) | Phase/Stufe; eine **aktive** Stufe (Horizon) |
|
| `maturity_stage` (RoadmapItem) | Gate / Reifegrad-Checkpoint auf einem **Pfad** |
|
||||||
| Kriterien / Evidence | Verify vor `reached` |
|
| **Strang** | `Project` (`project.maturity_journey`) — z. B. Dehnung, Technik, Hüftrotation |
|
||||||
| Graph | Voraussetzungen zwischen Stufen und/oder Fähigkeits-Projects |
|
| Kriterien / Evidence | Verify vor `reached`; inkl. `metric` |
|
||||||
| Project `project.maturity_journey` | Fähigkeit/Spur; eine oder mehrere |
|
| 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)
|
### 4.2 Aktivitäts-Set einer Phase (verbindlich)
|
||||||
|
|
||||||
| Aktivitätstyp | Persistenz | Rolle |
|
| 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 |
|
| 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).
|
- 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)
|
### 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.
|
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.
|
1. Activity Set **dieses Gates** deaktivieren.
|
||||||
2. Nächste Stufe aktiv; neues Set aktiv.
|
2. **Pro Strang:** nächstes Gate auf dem Pfad `active` (Graph-regelkonform); bei **Join-Gates** nach Verify.
|
||||||
3. Neues Set darf Übungen/Cadences/Alternanz/Actions ersetzen/erweitern/reduzieren.
|
3. Neues Set **strangspezifisch** — Stränge dürfen divergieren (z. B. Technik ↓, Dehnung ↑).
|
||||||
4. Adaptation innerhalb Stufe (manuell) erlaubt und auditiert.
|
4. Adaptation innerhalb aktiven Gates (manuell) erlaubt und auditiert.
|
||||||
|
|
||||||
Erweitert AP2.0e auf ganze Activity Sets.
|
|
||||||
|
|
||||||
### 4.5 Datenfelder am Recurring (verbindlich)
|
### 4.5 Datenfelder am Recurring (verbindlich)
|
||||||
|
|
||||||
| Feld | Zweck |
|
| 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 |
|
| Cadence | Zeitversatz |
|
||||||
| `alternation_group_id` + `alternation_order` | Alternanz (leer = keine Gruppe) |
|
| `alternation_group_id` + `alternation_order` | Alternanz (leer = keine Gruppe) |
|
||||||
| status aktiv/inaktiv | Set-Wechsel / Adaptation |
|
| status aktiv/inaktiv | Set-Wechsel / Adaptation |
|
||||||
|
|
||||||
### 4.6 Anti-Patterns
|
### 4.6 Anti-Patterns
|
||||||
|
|
||||||
|
- Flache Liste „Wiederkehrend“ ohne Gate/Strang-Bezug
|
||||||
- Übungen aller Stufen als flache Action-Liste
|
- Übungen aller Stufen als flache Action-Liste
|
||||||
- Verpasste Übung → Duplikat-Task
|
- Verpasste Übung → Duplikat-Task
|
||||||
- Alternanz ohne Gruppen-Semantik
|
- 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 |
|
| Aspekt | Kurz |
|
||||||
|--------|------|
|
|--------|------|
|
||||||
| Dominanz | hybrid |
|
| Dominanz | hybrid |
|
||||||
| Horizon | aktive `maturity_stage` |
|
| Horizon | aktive Gates (multi-strang) — PO-Lock §14 |
|
||||||
| Leading | CadenceInstance; sekundär Stage-Action |
|
| Leading | CadenceInstance; sekundär Stage-Action |
|
||||||
| Next / Ranking | Spec-D D6 |
|
| Next / Ranking | Spec-D D6 |
|
||||||
| Attention / Events | Spec-D D7 / D8 |
|
| Attention / Events | Spec-D D7 / D8 |
|
||||||
|
|
@ -175,12 +182,12 @@ Cadence im Detail am RecurringElement.
|
||||||
|
|
||||||
| Fläche | Inhalt |
|
| Fläche | Inhalt |
|
||||||
|--------|--------|
|
|--------|--------|
|
||||||
| **Plan → Stufen** | Timeline/Liste; aktiv; Kriterien; Verify |
|
| **Plan → Struktur** | Stränge (Projects) + Gate-Graph (parallel, Join) |
|
||||||
| **Plan → Stufe Detail** | Activity Set: Übungen (Cadence, Alternanz), Stage-Actions |
|
| **Plan → Gate-Detail** | Activity Set: Übungen, Strang, Cadence, metric-Kriterien |
|
||||||
| **Plan → Struktur** | Fähigkeits-Projects + Graph |
|
| **Plan → Stufen** | Graph-/Listen-Ansicht aktiver Gates |
|
||||||
| **Plan → Profil** | EFS Fokus/Frequenz/Disziplin |
|
| **Plan → Profil** | EFS Fokus/Frequenz/Disziplin |
|
||||||
| **Ausführen / Today** | fällige Übungen + Alternanz-aktuell + Stage-Actions |
|
| **Ausführen / Today** | fällige Übungen **gruppiert nach Strang/Gate** |
|
||||||
| **Kontrolle** | Stufe, Next + Begründung, Attention, Verify-Fortschritt |
|
| **Kontrolle** | aktive Gates, Join-Status, Kriterien-Fortschritt |
|
||||||
| **Journey** | Stufenwechsel, Set-Modifikationen, erfüllte Übungen |
|
| **Journey** | Stufenwechsel, Set-Modifikationen, erfüllte Übungen |
|
||||||
| **Process** | Stufen → Stufe/Übungen → Ausführen → Kontrolle |
|
| **Process** | Stufen → Stufe/Übungen → Ausführen → Kontrolle |
|
||||||
| **Elements** | `next_action_primary`, `maturity_stage`, `recurring_rhythm` |
|
| **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 →
|
**PO-Validation 2026-07-28:** Detaillierte Findings + Architektur-Urteil →
|
||||||
`docs/sprints/Sprint1_AP2_1_Validation_Findings_Register_v0.1.md` (F-A1-01…10)
|
`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 |
|
| Bereich | Spec | Code/Truth | Lücke |
|
||||||
|---------|------|------------|-------|
|
|---------|------|------------|-------|
|
||||||
| Registry / Kit | A1 + Stufen | weitgehend AP2.0e/Kit | Multi-Übung/Alternanz |
|
| Progressionsmodell | PO-Lock | linear + flat recurring | Multi-Strang + Gate-Activity-Set |
|
||||||
| Methode | `maturity_progression` | vorhanden | Ranking Alternanz Spec-D |
|
| Horizon multi-active | PO-Lock §3 | single-active (widerrufen) | AP-A1-PM-3 |
|
||||||
| Stage Activity Set | verbindlich | Teilmenge | Stage-Bindung + UI Detail |
|
| Stage Activity Set | Gate + Strang | Teilmenge | Gate-Detail UI + `project_id` |
|
||||||
| Alternanz-Felder | verbindlich | fehlt/teilweise | Persistenz + Strategy |
|
| Registry / Kit | Demo Spagat | geliefert | reicht nicht für PO-Kernbild |
|
||||||
| EFS Pflicht | focus/frequency | Seeds prüfen | Seed-AP |
|
| UI Plan Struktur/Gate | Pflicht | fehlt | AP-A1-PM-4 |
|
||||||
| steering_elements | D11 | teilweise | FE/BE Parität |
|
| Alternanz / metric | verbindlich | fehlt/teilweise | AP-A1-PM-5+ |
|
||||||
| UI Stufe-Detail | Pflicht | lückenhaft | UI-AP |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -254,5 +260,25 @@ Fachmodell vollständig. Lücken (Alternanz, Multi-Übung-UI, EFS-Seeds) = Imple
|
||||||
## 13. Nachfolger
|
## 13. Nachfolger
|
||||||
|
|
||||||
- Alternanz-Persistenz + Strategy
|
- Alternanz-Persistenz + Strategy
|
||||||
- UI Stufe-Detail / Activity Set
|
- UI Plan → Struktur + Gate-Detail / Activity Set
|
||||||
- Template Kumite
|
- 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`).
|
||||||
|
|
|
||||||
|
|
@ -19,18 +19,18 @@
|
||||||
|
|
||||||
## 1. Executive Summary
|
## 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):**
|
**Kurzfazit (3–5 Sätze):**
|
||||||
|
|
||||||
Technisch: pytest grün, A1-Happy-Path (Anlage → Verify → Stufenwechsel) durchspielbar.
|
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):**
|
**Blocker (falls No-Go / Bedingt):**
|
||||||
|
|
||||||
- **A1:** CadenceInstance/Tages-„erledigt“, Work=Übung, Gate-Fortschritt, IA — siehe Findings Register §6.1 (F-A1-02…10)
|
- **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 A1 Minimum Slice **oder** PO-Scope-Entscheidung (A1 aus Stufe-A-Abnahfe)
|
- **Gesamt Stufe A:** MVP §4 verlangt A1 — Go erst nach PO-Kernbild (≥2 Stränge + Join) durchspielbar
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -206,13 +206,22 @@ Alternanz, volle Metric-Engine, Activity-Set-Automation → **nach** Slice 1–4
|
||||||
|
|
||||||
## 7. Konsequenz für AP2.1 / MVP Stufe A
|
## 7. Konsequenz für AP2.1 / MVP Stufe A
|
||||||
|
|
||||||
| Option | Bedeutung |
|
| Option | Bedeutung | PO 2026-07-28 |
|
||||||
|--------|-----------|
|
|--------|-----------|---------------|
|
||||||
| **A — A1 nachziehen** | Validation pausieren; **A1 Minimum Slice** (§6.1) liefern; A1 erneut smoke-testen |
|
| **A — A1 nachziehen** | Minimum Slice liefern | ◐ Cadence done; Progressions-IA offen |
|
||||||
| **B — MVP-Definition anpassen** | A1 vorübergehend aus Stufe-A-Abnahfe (PO-Entscheidung + Doku) — **nur wenn A2+B2b als MVP reichen** |
|
| **B — MVP-Definition anpassen** | A1 aus Stufe-A-Abnahfe | nicht gewählt |
|
||||||
| **C — Gesamt No-Go** | AP2.1 stoppt bei Stufe A bis A1 oder Scope-Entscheidung |
|
| **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# AP A1 — Minimum Usability Slice
|
# AP A1 — Minimum Usability Slice
|
||||||
## Auftrag v0.1
|
## 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
|
**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.
|
**Ziel:** A1 **real nutzbar** für Tages-Tracking + Gate-Einschätzung + klare Today-Ansicht — **ohne** Architektur-Neudesign.
|
||||||
|
|
||||||
|
|
|
||||||
55
frontend/src/components/JoinWaitlistPanel.jsx
Normal file
55
frontend/src/components/JoinWaitlistPanel.jsx
Normal file
|
|
@ -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 (
|
||||||
|
<section className="card join-waitlist-panel">
|
||||||
|
<h2>Join-Fortschritt</h2>
|
||||||
|
<EmptyState message="Keine Joins warten auf Eingänge." />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card join-waitlist-panel">
|
||||||
|
<div className="section-header">
|
||||||
|
<div>
|
||||||
|
<h2>Join-Fortschritt</h2>
|
||||||
|
<p className="section-lead muted">
|
||||||
|
Konsolidierte Meilensteine — welche Strang-Gates fehlen noch zum Schalten?
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul className="item-list join-waitlist-panel__list">
|
||||||
|
{joins.map((join) => (
|
||||||
|
<li key={join.join_id} className="list-item card-list-item">
|
||||||
|
<div className="list-item-main">
|
||||||
|
<strong>{join.title}</strong>
|
||||||
|
<p className="muted list-item-sub">
|
||||||
|
Wartet auf:{' '}
|
||||||
|
{(join.pending_inputs || [])
|
||||||
|
.map((input) =>
|
||||||
|
input.strand_title
|
||||||
|
? `${input.strand_title} / ${input.title}`
|
||||||
|
: input.title,
|
||||||
|
)
|
||||||
|
.join(' · ')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -16,7 +16,8 @@ export function MaturityStagePanel({
|
||||||
.filter((item) => item.item_type === 'maturity_stage')
|
.filter((item) => item.item_type === 'maturity_stage')
|
||||||
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
|
.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(() => {
|
useEffect(() => {
|
||||||
if (!initiativeId) return undefined
|
if (!initiativeId) return undefined
|
||||||
|
|
@ -40,15 +41,20 @@ export function MaturityStagePanel({
|
||||||
<EmptyState message="Noch keine Reifegrad-Stufen — unter Plan anlegen oder Starter-Kit nutzen." />
|
<EmptyState message="Noch keine Reifegrad-Stufen — unter Plan anlegen oder Starter-Kit nutzen." />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{activeStage && (
|
{activeStages.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<p className="maturity-stage-active muted">
|
<p className="maturity-stage-active muted">
|
||||||
Aktive Stufe:{' '}
|
Aktive Gate(s):{' '}
|
||||||
<Link to={gatePath(activeStage.id)} className="link-inline">
|
{activeStages.map((stage, index) => (
|
||||||
<strong>{activeStage.title}</strong>
|
<span key={stage.id}>
|
||||||
</Link>
|
{index > 0 ? ' · ' : ''}
|
||||||
|
<Link to={gatePath(stage.id)} className="link-inline">
|
||||||
|
<strong>{stage.title}</strong>
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
</p>
|
</p>
|
||||||
{activeProgress && (
|
{activeStage && activeProgress && (
|
||||||
<p className="maturity-stage-progress muted">
|
<p className="maturity-stage-progress muted">
|
||||||
Gate-Fortschritt:{' '}
|
Gate-Fortschritt:{' '}
|
||||||
<strong>
|
<strong>
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,10 @@ function formatDueAt(iso) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function activeMaturityStage(roadmapItems = []) {
|
function activeMaturityStages(roadmapItems = []) {
|
||||||
return [...roadmapItems]
|
return [...roadmapItems]
|
||||||
.filter((item) => item.item_type === 'maturity_stage' && item.status === 'active')
|
.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) {
|
function practicesForStage(recurringItems = [], stageId) {
|
||||||
|
|
@ -32,20 +32,22 @@ export function RecurringRhythmPanel({
|
||||||
canManage = false,
|
canManage = false,
|
||||||
embedded = true,
|
embedded = true,
|
||||||
}) {
|
}) {
|
||||||
const stage = activeMaturityStage(roadmapItems)
|
const stages = activeMaturityStages(roadmapItems)
|
||||||
const stagePractices = practicesForStage(recurringItems, stage?.id)
|
const stagePractices = stages.flatMap((stage) =>
|
||||||
|
practicesForStage(recurringItems, stage.id),
|
||||||
|
)
|
||||||
const active = stagePractices.filter((item) => item.status === 'active')
|
const active = stagePractices.filter((item) => item.status === 'active')
|
||||||
const paused = stagePractices.filter((item) => item.status === 'paused')
|
const paused = stagePractices.filter((item) => item.status === 'paused')
|
||||||
|
|
||||||
const body =
|
const body =
|
||||||
!stage ? (
|
stages.length === 0 ? (
|
||||||
<EmptyState message="Keine aktive Reifegrad-Stufe — Plan → Stufen prüfen." />
|
<EmptyState message="Keine aktiven Work-Gates — Plan → Struktur prüfen." />
|
||||||
) : stagePractices.length === 0 ? (
|
) : stagePractices.length === 0 ? (
|
||||||
<EmptyState message="Keine Übungen für diese Stufe — Starter-Kit oder Stufenwechsel prüfen." />
|
<EmptyState message="Keine Übungen auf aktiven Gates — Starter-Kit oder Join prüfen." />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="recurring-rhythm-summary muted">
|
<p className="recurring-rhythm-summary muted">
|
||||||
Stufe <strong>{stage.title}</strong> — {active.length} aktiv
|
{stages.length} aktive Gate(s) — {active.length} Übung(en) aktiv
|
||||||
{paused.length > 0 ? `, ${paused.length} pausiert` : ''}.
|
{paused.length > 0 ? `, ${paused.length} pausiert` : ''}.
|
||||||
</p>
|
</p>
|
||||||
<ul className="item-list recurring-rhythm-list">
|
<ul className="item-list recurring-rhythm-list">
|
||||||
|
|
@ -92,9 +94,9 @@ export function RecurringRhythmPanel({
|
||||||
<section className="card recurring-rhythm-panel">
|
<section className="card recurring-rhythm-panel">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>Übungen der Stufe</h2>
|
<h2>Übungen der aktiven Gates</h2>
|
||||||
<p className="section-lead muted">
|
<p className="section-lead muted">
|
||||||
Activity Set der aktiven Reifegrad-Stufe — nicht die Gates selbst (A1).
|
Activity Sets aller aktiven Work-Gates — Join-Knoten haben keine Übungen (A1).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -13,17 +13,22 @@ function formatDue(iso) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function activeMaturityStage(roadmapItems = []) {
|
function activeWorkGates(roadmapItems = []) {
|
||||||
return [...roadmapItems]
|
return [...roadmapItems]
|
||||||
.filter((item) => item.item_type === 'maturity_stage' && item.status === 'active')
|
.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 exercisesForStage(recurringItems = [], stageId) {
|
function exercisesForGate(recurringItems = [], gateId) {
|
||||||
const active = (recurringItems || []).filter((item) => item.status === 'active')
|
return (recurringItems || []).filter(
|
||||||
if (!stageId) return active
|
(item) => item.status === 'active' && item.roadmap_item_id === gateId,
|
||||||
const bound = active.filter((item) => item.roadmap_item_id === stageId)
|
)
|
||||||
return bound.length > 0 ? bound : active
|
}
|
||||||
|
|
||||||
|
function strandLabel(gate, projectsById) {
|
||||||
|
const projectId = gate.strand_project_id
|
||||||
|
if (!projectId) return 'Training'
|
||||||
|
return projectsById.get(projectId)?.title || 'Strang'
|
||||||
}
|
}
|
||||||
|
|
||||||
function ExerciseRow({ exercise, canManage, busy, onCompleted }) {
|
function ExerciseRow({ exercise, canManage, busy, onCompleted }) {
|
||||||
|
|
@ -94,23 +99,33 @@ function ExerciseRow({ exercise, canManage, busy, onCompleted }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A1 — Heutige Übungen der aktiven Stufe (CadenceInstance je Übung).
|
* A1 — Heutige Übungen aller aktiven Work-Gates, gruppiert nach Strang.
|
||||||
*/
|
*/
|
||||||
export function WorkTodayPracticePanel({
|
export function WorkTodayPracticePanel({
|
||||||
initiativeId,
|
initiativeId,
|
||||||
roadmapItems = [],
|
roadmapItems = [],
|
||||||
recurringItems = [],
|
recurringItems = [],
|
||||||
|
projects = [],
|
||||||
canManage = false,
|
canManage = false,
|
||||||
onCompleted,
|
onCompleted,
|
||||||
busy = false,
|
busy = false,
|
||||||
}) {
|
}) {
|
||||||
const stage = useMemo(() => activeMaturityStage(roadmapItems), [roadmapItems])
|
const projectsById = useMemo(
|
||||||
|
() => new Map((projects || []).map((project) => [project.id, project])),
|
||||||
const todayExercises = useMemo(
|
[projects],
|
||||||
() => exercisesForStage(recurringItems, stage?.id),
|
|
||||||
[recurringItems, stage?.id],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const gates = useMemo(() => activeWorkGates(roadmapItems), [roadmapItems])
|
||||||
|
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
return gates.map((gate) => ({
|
||||||
|
gate,
|
||||||
|
strand: strandLabel(gate, projectsById),
|
||||||
|
exercises: exercisesForGate(recurringItems, gate.id),
|
||||||
|
}))
|
||||||
|
}, [gates, recurringItems, projectsById])
|
||||||
|
|
||||||
|
const todayExercises = grouped.flatMap((group) => group.exercises)
|
||||||
const pending = todayExercises.filter((item) => !item.today_completed)
|
const pending = todayExercises.filter((item) => !item.today_completed)
|
||||||
const allDone = todayExercises.length > 0 && pending.length === 0
|
const allDone = todayExercises.length > 0 && pending.length === 0
|
||||||
|
|
||||||
|
|
@ -120,20 +135,15 @@ export function WorkTodayPracticePanel({
|
||||||
<div>
|
<div>
|
||||||
<h2>Heutige Übungen</h2>
|
<h2>Heutige Übungen</h2>
|
||||||
<p className="section-lead muted">
|
<p className="section-lead muted">
|
||||||
{stage ? (
|
{gates.length > 0
|
||||||
<>
|
? `${gates.length} aktive Gate(s) — pro Übung erledigen, optional Messwert notieren.`
|
||||||
Aktive Stufe: <strong>{stage.title}</strong> — pro Übung erledigen, optional Messwert
|
: 'Tages-Tracking für aktive Work-Gates auf allen Strängen.'}
|
||||||
notieren.
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Tages-Tracking für die aktive Reifegrad-Stufe.'
|
|
||||||
)}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{todayExercises.length === 0 ? (
|
{todayExercises.length === 0 ? (
|
||||||
<EmptyState message="Keine Übungen für die aktive Stufe — Starter-Kit oder Stufenwechsel prüfen." />
|
<EmptyState message="Keine Übungen auf aktiven Gates — Starter-Kit oder Join-Fortschritt prüfen." />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{allDone && (
|
{allDone && (
|
||||||
|
|
@ -141,17 +151,26 @@ export function WorkTodayPracticePanel({
|
||||||
Alle Übungen für heute erledigt — nächste Fälligkeit morgen.
|
Alle Übungen für heute erledigt — nächste Fälligkeit morgen.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<ul className="item-list work-today-practice__list">
|
{grouped.map(({ gate, strand, exercises }) =>
|
||||||
{todayExercises.map((exercise) => (
|
exercises.length === 0 ? null : (
|
||||||
<ExerciseRow
|
<div key={gate.id} className="work-today-practice__strand">
|
||||||
key={exercise.id}
|
<h3 className="work-today-practice__strand-title">
|
||||||
exercise={exercise}
|
{strand} — {gate.title}
|
||||||
canManage={canManage}
|
</h3>
|
||||||
busy={busy}
|
<ul className="item-list work-today-practice__list">
|
||||||
onCompleted={onCompleted}
|
{exercises.map((exercise) => (
|
||||||
/>
|
<ExerciseRow
|
||||||
))}
|
key={exercise.id}
|
||||||
</ul>
|
exercise={exercise}
|
||||||
|
canManage={canManage}
|
||||||
|
busy={busy}
|
||||||
|
onCompleted={onCompleted}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,16 @@ export const COMPOSITION_PROVIDERS = [
|
||||||
scopeTypes: ['initiative'],
|
scopeTypes: ['initiative'],
|
||||||
order: 10,
|
order: 10,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'steering.join_waitlist',
|
||||||
|
kind: 'steering_element',
|
||||||
|
steeringElement: 'maturity_stage',
|
||||||
|
slotKeys: ['control.status.steering'],
|
||||||
|
componentKey: 'JoinWaitlistPanel',
|
||||||
|
requiresCapability: 'kairo.initiative.read',
|
||||||
|
scopeTypes: ['initiative'],
|
||||||
|
order: 14,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'steering.maturity_stage',
|
key: 'steering.maturity_stage',
|
||||||
kind: 'steering_element',
|
kind: 'steering_element',
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { Link } from 'react-router-dom'
|
||||||
import { scopedPath } from '../utils/routes.js'
|
import { scopedPath } from '../utils/routes.js'
|
||||||
import { SteeringSnapshotPanel } from '../components/SteeringSnapshotPanel.jsx'
|
import { SteeringSnapshotPanel } from '../components/SteeringSnapshotPanel.jsx'
|
||||||
import { CriticalPathPanel } from '../components/CriticalPathPanel.jsx'
|
import { CriticalPathPanel } from '../components/CriticalPathPanel.jsx'
|
||||||
|
import { JoinWaitlistPanel } from '../components/JoinWaitlistPanel.jsx'
|
||||||
import { MaturityStagePanel } from '../components/MaturityStagePanel.jsx'
|
import { MaturityStagePanel } from '../components/MaturityStagePanel.jsx'
|
||||||
import { RecurringRhythmPanel } from '../components/RecurringRhythmPanel.jsx'
|
import { RecurringRhythmPanel } from '../components/RecurringRhythmPanel.jsx'
|
||||||
import { AgentSlotsPanel } from '../components/AgentSlotsPanel.jsx'
|
import { AgentSlotsPanel } from '../components/AgentSlotsPanel.jsx'
|
||||||
|
|
@ -47,6 +48,7 @@ export const PROVIDER_COMPONENTS = {
|
||||||
WidgetHost,
|
WidgetHost,
|
||||||
WorkActionsPanel,
|
WorkActionsPanel,
|
||||||
WorkTodayPracticePanel,
|
WorkTodayPracticePanel,
|
||||||
|
JoinWaitlistPanel,
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -203,11 +203,16 @@ export function buildProviderProps(provider, input) {
|
||||||
recurringItems: ops.recurringItems || [],
|
recurringItems: ops.recurringItems || [],
|
||||||
canManage: capabilities.has('kairo.recurring.manage'),
|
canManage: capabilities.has('kairo.recurring.manage'),
|
||||||
}
|
}
|
||||||
|
case 'JoinWaitlistPanel':
|
||||||
|
return {
|
||||||
|
progressionState: input.steeringSnapshot?.progression_state ?? null,
|
||||||
|
}
|
||||||
case 'WorkTodayPracticePanel':
|
case 'WorkTodayPracticePanel':
|
||||||
return {
|
return {
|
||||||
initiativeId: ops.initiativeId,
|
initiativeId: ops.initiativeId,
|
||||||
roadmapItems: ops.roadmapItems || [],
|
roadmapItems: ops.roadmapItems || [],
|
||||||
recurringItems: ops.recurringItems || [],
|
recurringItems: ops.recurringItems || [],
|
||||||
|
projects: ops.projects || [],
|
||||||
canManage: capabilities.has('kairo.recurring.manage'),
|
canManage: capabilities.has('kairo.recurring.manage'),
|
||||||
busy: ops.formBusy,
|
busy: ops.formBusy,
|
||||||
onCompleted: async () => {
|
onCompleted: async () => {
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,7 @@ export const ROADMAP_ITEM_TYPE_LABELS = {
|
||||||
milestone: 'Meilenstein / Gate',
|
milestone: 'Meilenstein / Gate',
|
||||||
review_gate: 'Review-Gate',
|
review_gate: 'Review-Gate',
|
||||||
maturity_stage: 'Reifegrad-Stufe',
|
maturity_stage: 'Reifegrad-Stufe',
|
||||||
|
join_gate: 'Join / Schaltknoten',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SEQUENCING_MODE_LABELS = {
|
export const SEQUENCING_MODE_LABELS = {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user