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.signals.snapshot_signals import derive_initiative_signals
|
||||
from services.initiatives import get_initiative
|
||||
from services.progression_graph import compute_progression_state
|
||||
from tenant_context import TenantContext
|
||||
|
||||
OPEN_BLOCKER = ("open", "in_progress")
|
||||
|
|
@ -383,6 +384,9 @@ def get_initiative_steering_snapshot(
|
|||
"actions": actions,
|
||||
"roadmap_items": roadmap_items,
|
||||
"milestones": roadmap_items,
|
||||
"progression_state": compute_progression_state(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
),
|
||||
"initiative_level": {
|
||||
"blockers": unlinked_blockers,
|
||||
"evidence": unlinked_evidence,
|
||||
|
|
|
|||
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):
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
item_type: Literal["milestone", "review_gate", "maturity_stage", "work_cycle"] = "milestone"
|
||||
item_type: Literal["milestone", "review_gate", "maturity_stage", "work_cycle", "join_gate"] = "milestone"
|
||||
goal_description: str = ""
|
||||
definition_of_done: list[Any] = Field(default_factory=list)
|
||||
status: Literal[
|
||||
|
|
@ -27,12 +27,13 @@ class RoadmapItemCreateRequest(BaseModel):
|
|||
sequencing_mode: Literal["sequential", "parallel", "optional"] = "sequential"
|
||||
target_date: Optional[date] = None
|
||||
sort_order: int = 0
|
||||
strand_project_id: Optional[str] = None
|
||||
initial_criterion_title: Optional[str] = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class RoadmapItemUpdateRequest(BaseModel):
|
||||
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
|
||||
item_type: Optional[Literal["milestone", "review_gate", "maturity_stage", "work_cycle"]] = None
|
||||
item_type: Optional[Literal["milestone", "review_gate", "maturity_stage", "work_cycle", "join_gate"]] = None
|
||||
goal_description: Optional[str] = None
|
||||
definition_of_done: Optional[list[Any]] = None
|
||||
status: Optional[
|
||||
|
|
@ -42,6 +43,8 @@ class RoadmapItemUpdateRequest(BaseModel):
|
|||
target_date: Optional[date] = None
|
||||
clear_target_date: bool = False
|
||||
sort_order: Optional[int] = None
|
||||
strand_project_id: Optional[str] = None
|
||||
clear_strand_project: bool = False
|
||||
|
||||
|
||||
class DependencyCreateRequest(BaseModel):
|
||||
|
|
@ -166,6 +169,21 @@ def get_initiative_roadmap_graph_state(
|
|||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@initiative_router.get("/{initiative_id}/roadmap/progression-state")
|
||||
def get_initiative_progression_state(
|
||||
initiative_id: str,
|
||||
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
|
||||
):
|
||||
try:
|
||||
from services.progression_graph import compute_progression_state
|
||||
|
||||
return compute_progression_state(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@initiative_router.get("/{initiative_id}/roadmap/plan-ist-view")
|
||||
def get_initiative_plan_ist_view(
|
||||
initiative_id: str,
|
||||
|
|
@ -228,6 +246,7 @@ def create_initiative_roadmap_item(
|
|||
sequencing_mode=body.sequencing_mode,
|
||||
target_date=body.target_date,
|
||||
sort_order=body.sort_order,
|
||||
strand_project_id=body.strand_project_id,
|
||||
initial_criterion_title=body.initial_criterion_title,
|
||||
)
|
||||
except ValueError as exc:
|
||||
|
|
@ -265,6 +284,8 @@ def update_roadmap_item(
|
|||
target_date=body.target_date,
|
||||
clear_target_date=body.clear_target_date,
|
||||
sort_order=body.sort_order,
|
||||
strand_project_id=body.strand_project_id,
|
||||
clear_strand_project=body.clear_strand_project,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
|
|
|||
|
|
@ -312,6 +312,130 @@ def _apply_a2(
|
|||
}
|
||||
|
||||
|
||||
def apply_a1_progression_demo(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""PO-Kernbild: 2 Stränge → Join → Ausgänge (AP-A1-PM-6). Idempotent wenn Struktur existiert."""
|
||||
from services.maturity_practice import seed_practices_for_stage
|
||||
from services.projects import create_project, list_projects_for_initiative
|
||||
from services.roadmap import add_dependency, create_roadmap_item, list_roadmap_items_for_initiative
|
||||
|
||||
existing = list_roadmap_items_for_initiative(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
if any(item.get("item_type") == "join_gate" for item in existing):
|
||||
return {"applied": False, "reason": "progression_exists"}
|
||||
|
||||
projects = list_projects_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
|
||||
dehnung = _find_by_title(projects, "Dehnung")
|
||||
if not dehnung:
|
||||
dehnung = create_project(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title="Dehnung",
|
||||
description="Strang Dehnung",
|
||||
container_kind="stream",
|
||||
sort_order=0,
|
||||
user_id=user_id,
|
||||
)
|
||||
huft = _find_by_title(projects, "Hüftrotation")
|
||||
if not huft:
|
||||
huft = create_project(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title="Hüftrotation",
|
||||
description="Strang Hüftrotation",
|
||||
container_kind="stream",
|
||||
sort_order=1,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
d1 = create_roadmap_item(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title="Dehnung — Einstieg",
|
||||
item_type="maturity_stage",
|
||||
status="active",
|
||||
sort_order=10,
|
||||
strand_project_id=dehnung["id"],
|
||||
user_id=user_id,
|
||||
)
|
||||
h1 = create_roadmap_item(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title="Hüftrotation — Einstieg",
|
||||
item_type="maturity_stage",
|
||||
status="active",
|
||||
sort_order=20,
|
||||
strand_project_id=huft["id"],
|
||||
user_id=user_id,
|
||||
)
|
||||
join = create_roadmap_item(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title="Mawashi Geri zur Hüfte",
|
||||
item_type="join_gate",
|
||||
status="planned",
|
||||
sort_order=30,
|
||||
user_id=user_id,
|
||||
)
|
||||
d2 = create_roadmap_item(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title="Dehnung — nach Join",
|
||||
item_type="maturity_stage",
|
||||
status="planned",
|
||||
sort_order=40,
|
||||
strand_project_id=dehnung["id"],
|
||||
user_id=user_id,
|
||||
)
|
||||
h2 = create_roadmap_item(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
title="Hüftrotation — nach Join",
|
||||
item_type="maturity_stage",
|
||||
status="planned",
|
||||
sort_order=50,
|
||||
strand_project_id=huft["id"],
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
for from_id, to_id in (
|
||||
(join["id"], d1["id"]),
|
||||
(join["id"], h1["id"]),
|
||||
(d2["id"], join["id"]),
|
||||
(h2["id"], join["id"]),
|
||||
):
|
||||
try:
|
||||
add_dependency(
|
||||
tenant_id=tenant_id,
|
||||
from_item_id=from_id,
|
||||
to_item_id=to_id,
|
||||
dependency_type="requires",
|
||||
user_id=user_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
if "existiert bereits" not in str(exc):
|
||||
raise
|
||||
|
||||
seed_practices_for_stage(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id, stage=d1, user_id=user_id
|
||||
)
|
||||
seed_practices_for_stage(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id, stage=h1, user_id=user_id
|
||||
)
|
||||
|
||||
return {
|
||||
"applied": True,
|
||||
"projects": ["Dehnung", "Hüftrotation"],
|
||||
"join": join["title"],
|
||||
"active_gates": [d1["title"], h1["title"]],
|
||||
}
|
||||
|
||||
|
||||
def _apply_b2b(
|
||||
*,
|
||||
tenant_id: str,
|
||||
|
|
|
|||
|
|
@ -25,12 +25,24 @@ STAGE_PRACTICE_SETS: dict[str, list[tuple[str, str]]] = {
|
|||
"Stufe 3 — Ziel": [
|
||||
("Voller Spagat", "Täglich Zielposition — Messwert dokumentieren"),
|
||||
],
|
||||
"Dehnung — Einstieg": [
|
||||
("Vorbeuge halten", "Täglich 3×30 Sek."),
|
||||
],
|
||||
"Hüftrotation — Einstieg": [
|
||||
("Hüftkreisen im Stand", "Täglich 10 Wiederholungen je Richtung"),
|
||||
],
|
||||
"Dehnung — nach Join": [
|
||||
("Enddehnung Mawashi-Vorbereitung", "Täglich 2×45 Sek."),
|
||||
],
|
||||
"Hüftrotation — nach Join": [
|
||||
("Explosive Hüftrotation", "Täglich 3×8 Wiederholungen"),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def get_active_maturity_stage(
|
||||
def get_active_maturity_stages(
|
||||
*, tenant_id: str, initiative_id: str
|
||||
) -> Optional[dict[str, Any]]:
|
||||
) -> list[dict[str, Any]]:
|
||||
from services.roadmap import list_roadmap_items_for_initiative
|
||||
|
||||
stages = [
|
||||
|
|
@ -40,34 +52,17 @@ def get_active_maturity_stage(
|
|||
)
|
||||
if item.get("item_type") == "maturity_stage" and item.get("status") == "active"
|
||||
]
|
||||
if not stages:
|
||||
return None
|
||||
stages.sort(key=lambda s: (s.get("sort_order", 0), s.get("title", "")))
|
||||
return stages[0]
|
||||
return stages
|
||||
|
||||
|
||||
def enforce_single_active_maturity_stage(
|
||||
cur,
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
keep_active_id: str,
|
||||
) -> None:
|
||||
"""At most one maturity_stage may be active."""
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE roadmap_items ri
|
||||
SET status = 'planned', updated_at = NOW()
|
||||
FROM roadmaps r
|
||||
WHERE ri.roadmap_id = r.id
|
||||
AND ri.tenant_id = %s
|
||||
AND r.initiative_id = %s
|
||||
AND ri.item_type = 'maturity_stage'
|
||||
AND ri.status IN ('active', 'at_risk')
|
||||
AND ri.id != %s
|
||||
""",
|
||||
(tenant_id, initiative_id, keep_active_id),
|
||||
def get_active_maturity_stage(
|
||||
*, tenant_id: str, initiative_id: str
|
||||
) -> Optional[dict[str, Any]]:
|
||||
stages = get_active_maturity_stages(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
return stages[0] if stages else None
|
||||
|
||||
|
||||
def list_practices_for_stage(
|
||||
|
|
@ -107,6 +102,34 @@ def pause_all_practices(
|
|||
return paused
|
||||
|
||||
|
||||
def pause_practices_except_stage(
|
||||
*,
|
||||
tenant_id: str,
|
||||
initiative_id: str,
|
||||
keep_stage_id: str,
|
||||
strand_project_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> list[str]:
|
||||
paused: list[str] = []
|
||||
for item in list_recurring_for_initiative(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id
|
||||
):
|
||||
if item.get("status") != "active":
|
||||
continue
|
||||
if item.get("roadmap_item_id") == keep_stage_id:
|
||||
continue
|
||||
if strand_project_id is not None and item.get("project_id") != strand_project_id:
|
||||
continue
|
||||
update_recurring_element(
|
||||
tenant_id=tenant_id,
|
||||
recurring_id=item["id"],
|
||||
status="paused",
|
||||
user_id=user_id,
|
||||
)
|
||||
paused.append(item["title"])
|
||||
return paused
|
||||
|
||||
|
||||
def seed_practices_for_stage(
|
||||
*,
|
||||
tenant_id: str,
|
||||
|
|
@ -117,6 +140,7 @@ def seed_practices_for_stage(
|
|||
"""Idempotent: create missing exercises for a stage; activate existing."""
|
||||
stage_id = stage["id"]
|
||||
stage_title = stage.get("title") or ""
|
||||
strand_project_id = stage.get("strand_project_id")
|
||||
defs = STAGE_PRACTICE_SETS.get(stage_title)
|
||||
if not defs:
|
||||
defs = [
|
||||
|
|
@ -161,6 +185,7 @@ def seed_practices_for_stage(
|
|||
interval_days=1,
|
||||
next_due_at=now,
|
||||
roadmap_item_id=stage_id,
|
||||
project_id=strand_project_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
created.append(row)
|
||||
|
|
@ -175,9 +200,19 @@ def activate_stage_practice_set(
|
|||
stage: dict[str, Any],
|
||||
user_id: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
paused = pause_all_practices(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id, user_id=user_id
|
||||
)
|
||||
strand_project_id = stage.get("strand_project_id")
|
||||
if strand_project_id:
|
||||
paused = pause_practices_except_stage(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
keep_stage_id=stage["id"],
|
||||
strand_project_id=strand_project_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
else:
|
||||
paused = pause_all_practices(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id, user_id=user_id
|
||||
)
|
||||
exercises = seed_practices_for_stage(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
|
|
|
|||
|
|
@ -1,12 +1,9 @@
|
|||
"""AP2.0e — Recurring transition when a maturity_stage is reached."""
|
||||
"""AP2.0e — maturity stage transition on verify reached (delegates to progression_graph)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from services.maturity_practice import activate_stage_practice_set
|
||||
from services.roadmap import get_roadmap_item, list_roadmap_items_for_initiative
|
||||
|
||||
|
||||
def on_maturity_stage_reached(
|
||||
*,
|
||||
|
|
@ -15,61 +12,14 @@ def on_maturity_stage_reached(
|
|||
reached_item_id: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""Pause old routines, activate next stage, seed exercise set for next stage."""
|
||||
item = get_roadmap_item(tenant_id=tenant_id, item_id=reached_item_id)
|
||||
if not item or item.get("item_type") != "maturity_stage":
|
||||
return None
|
||||
from services.progression_graph import on_work_gate_reached
|
||||
|
||||
stages = [
|
||||
s
|
||||
for s in list_roadmap_items_for_initiative(
|
||||
tenant_id=tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
if s.get("item_type") == "maturity_stage"
|
||||
]
|
||||
stages.sort(key=lambda s: (s.get("sort_order", 0), s.get("title", "")))
|
||||
|
||||
reached_index = next(
|
||||
(index for index, stage in enumerate(stages) if stage["id"] == reached_item_id),
|
||||
None,
|
||||
return on_work_gate_reached(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
reached_item_id=reached_item_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
if reached_index is None:
|
||||
return None
|
||||
|
||||
next_stage = None
|
||||
for stage in stages[reached_index + 1 :]:
|
||||
if stage.get("status") in {"planned", "active", "at_risk"}:
|
||||
next_stage = stage
|
||||
break
|
||||
|
||||
practice_transition = None
|
||||
if next_stage:
|
||||
from services.roadmap import update_roadmap_item
|
||||
|
||||
update_roadmap_item(
|
||||
tenant_id=tenant_id,
|
||||
item_id=next_stage["id"],
|
||||
status="active",
|
||||
user_id=user_id,
|
||||
)
|
||||
next_stage = get_roadmap_item(tenant_id=tenant_id, item_id=next_stage["id"])
|
||||
if next_stage:
|
||||
practice_transition = activate_stage_practice_set(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
stage=next_stage,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
return {
|
||||
"reached_stage_id": reached_item_id,
|
||||
"next_stage_id": next_stage["id"] if next_stage else None,
|
||||
"paused_recurring": (practice_transition or {}).get("paused_practices", []),
|
||||
"new_recurring_id": (
|
||||
(practice_transition or {}).get("practice_ids") or [None]
|
||||
)[0],
|
||||
"active_practices": (practice_transition or {}).get("active_practices", []),
|
||||
}
|
||||
|
||||
|
||||
def on_maturity_stage_reopened(
|
||||
|
|
@ -79,7 +29,10 @@ def on_maturity_stage_reopened(
|
|||
stage_id: str,
|
||||
user_id: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Re-enable exercise set for reopened stage; pause others."""
|
||||
"""Re-enable exercise set for reopened stage; pause others on same strand."""
|
||||
from services.maturity_practice import activate_stage_practice_set
|
||||
from services.roadmap import get_roadmap_item
|
||||
|
||||
stage = get_roadmap_item(tenant_id=tenant_id, item_id=stage_id)
|
||||
if not stage:
|
||||
return {"stage_id": stage_id, "active_practices": []}
|
||||
|
|
|
|||
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 datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
|
@ -16,14 +16,14 @@ RecurringStatus = Literal["active", "paused", "ended"]
|
|||
RECURRING_STATUSES = frozenset({"active", "paused", "ended"})
|
||||
|
||||
_RECURRING_COLUMNS = """
|
||||
id, tenant_id, initiative_id, roadmap_item_id, title, description, status,
|
||||
id, tenant_id, initiative_id, roadmap_item_id, project_id, title, description, status,
|
||||
interval_days, next_due_at, created_at, updated_at
|
||||
"""
|
||||
|
||||
|
||||
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
result = dict(row)
|
||||
for key in ("id", "tenant_id", "initiative_id", "roadmap_item_id"):
|
||||
for key in ("id", "tenant_id", "initiative_id", "roadmap_item_id", "project_id"):
|
||||
if result.get(key):
|
||||
result[key] = str(result[key])
|
||||
for ts_key in ("created_at", "updated_at", "next_due_at"):
|
||||
|
|
@ -47,6 +47,7 @@ def create_recurring_element(
|
|||
interval_days: Optional[int] = None,
|
||||
next_due_at: Optional[datetime] = None,
|
||||
roadmap_item_id: Optional[str] = None,
|
||||
project_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
title = title.strip()
|
||||
|
|
@ -64,16 +65,17 @@ def create_recurring_element(
|
|||
cur.execute(
|
||||
f"""
|
||||
INSERT INTO recurring_elements (
|
||||
tenant_id, initiative_id, roadmap_item_id, title, description, status,
|
||||
tenant_id, initiative_id, roadmap_item_id, project_id, title, description, status,
|
||||
interval_days, next_due_at
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING {_RECURRING_COLUMNS}
|
||||
""",
|
||||
(
|
||||
tenant_id,
|
||||
initiative_id,
|
||||
roadmap_item_id,
|
||||
project_id,
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
|
|
@ -166,6 +168,8 @@ def update_recurring_element(
|
|||
clear_next_due_at: bool = False,
|
||||
roadmap_item_id: Optional[str] = None,
|
||||
clear_roadmap_item_id: bool = False,
|
||||
project_id: Optional[str] = None,
|
||||
clear_project_id: bool = False,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
existing = get_recurring_element(tenant_id=tenant_id, recurring_id=recurring_id)
|
||||
if not existing:
|
||||
|
|
@ -205,6 +209,11 @@ def update_recurring_element(
|
|||
elif roadmap_item_id is not None:
|
||||
updates.append("roadmap_item_id = %s")
|
||||
params.append(roadmap_item_id)
|
||||
if clear_project_id:
|
||||
updates.append("project_id = NULL")
|
||||
elif project_id is not None:
|
||||
updates.append("project_id = %s")
|
||||
params.append(project_id)
|
||||
|
||||
if not updates:
|
||||
return existing
|
||||
|
|
|
|||
|
|
@ -13,14 +13,14 @@ from services.audit import log_audit
|
|||
from services.initiatives import get_initiative
|
||||
from services.roadmap_criteria import create_default_criterion, prepare_criteria_for_verify
|
||||
|
||||
RoadmapItemType = Literal["milestone", "review_gate", "maturity_stage", "work_cycle"]
|
||||
RoadmapItemType = Literal["milestone", "review_gate", "maturity_stage", "work_cycle", "join_gate"]
|
||||
RoadmapItemStatus = Literal[
|
||||
"planned", "active", "at_risk", "reached", "moved", "discarded"
|
||||
]
|
||||
SequencingMode = Literal["sequential", "parallel", "optional"]
|
||||
DependencyType = Literal["requires", "blocks", "related", "parallel_group", "optional_branch"]
|
||||
|
||||
ROADMAP_ITEM_TYPES = frozenset({"milestone", "review_gate", "maturity_stage", "work_cycle"})
|
||||
ROADMAP_ITEM_TYPES = frozenset({"milestone", "review_gate", "maturity_stage", "work_cycle", "join_gate"})
|
||||
ROADMAP_ITEM_STATUSES = frozenset(
|
||||
{"planned", "active", "at_risk", "reached", "moved", "discarded"}
|
||||
)
|
||||
|
|
@ -32,7 +32,7 @@ TERMINAL_STATUSES = frozenset({"reached", "moved", "discarded"})
|
|||
|
||||
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
|
||||
result = dict(row)
|
||||
for key in ("id", "tenant_id", "roadmap_id", "initiative_id", "from_item_id", "to_item_id"):
|
||||
for key in ("id", "tenant_id", "roadmap_id", "initiative_id", "from_item_id", "to_item_id", "strand_project_id"):
|
||||
if result.get(key):
|
||||
result[key] = str(result[key])
|
||||
if result.get("target_date"):
|
||||
|
|
@ -185,6 +185,7 @@ def list_roadmap_items_for_initiative(
|
|||
ri.id, ri.tenant_id, ri.roadmap_id, r.initiative_id,
|
||||
ri.item_type, ri.title, ri.goal_description, ri.definition_of_done,
|
||||
ri.status, ri.sequencing_mode, ri.target_date, ri.sort_order,
|
||||
ri.strand_project_id,
|
||||
ri.created_at, ri.updated_at
|
||||
FROM roadmap_items ri
|
||||
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
|
||||
|
|
@ -208,6 +209,7 @@ def get_roadmap_item(*, tenant_id: str, item_id: str) -> Optional[dict[str, Any]
|
|||
ri.id, ri.tenant_id, ri.roadmap_id, r.initiative_id,
|
||||
ri.item_type, ri.title, ri.goal_description, ri.definition_of_done,
|
||||
ri.status, ri.sequencing_mode, ri.target_date, ri.sort_order,
|
||||
ri.strand_project_id,
|
||||
ri.created_at, ri.updated_at
|
||||
FROM roadmap_items ri
|
||||
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
|
||||
|
|
@ -233,6 +235,7 @@ def create_roadmap_item(
|
|||
sequencing_mode: SequencingMode = "sequential",
|
||||
target_date: Optional[date] = None,
|
||||
sort_order: int = 0,
|
||||
strand_project_id: Optional[str] = None,
|
||||
initial_criterion_title: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
|
|
@ -255,12 +258,13 @@ def create_roadmap_item(
|
|||
"""
|
||||
INSERT INTO roadmap_items (
|
||||
tenant_id, roadmap_id, item_type, title, goal_description,
|
||||
definition_of_done, status, sequencing_mode, target_date, sort_order
|
||||
definition_of_done, status, sequencing_mode, target_date, sort_order,
|
||||
strand_project_id
|
||||
)
|
||||
VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s, %s)
|
||||
VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s, %s, %s)
|
||||
RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description,
|
||||
definition_of_done, status, sequencing_mode, target_date,
|
||||
sort_order, created_at, updated_at
|
||||
sort_order, strand_project_id, created_at, updated_at
|
||||
""",
|
||||
(
|
||||
tenant_id,
|
||||
|
|
@ -273,11 +277,12 @@ def create_roadmap_item(
|
|||
sequencing_mode,
|
||||
target_date,
|
||||
sort_order,
|
||||
strand_project_id,
|
||||
),
|
||||
)
|
||||
row = _serialize_row(dict(cur.fetchone()))
|
||||
row["initiative_id"] = initiative_id
|
||||
if item_type != "work_cycle":
|
||||
if item_type not in ("work_cycle", "join_gate"):
|
||||
create_default_criterion(
|
||||
cur,
|
||||
tenant_id=tenant_id,
|
||||
|
|
@ -324,6 +329,8 @@ def update_roadmap_item(
|
|||
target_date: Optional[date] = None,
|
||||
clear_target_date: bool = False,
|
||||
sort_order: Optional[int] = None,
|
||||
strand_project_id: Optional[str] = None,
|
||||
clear_strand_project: bool = False,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
existing = get_roadmap_item(tenant_id=tenant_id, item_id=item_id)
|
||||
if not existing:
|
||||
|
|
@ -373,6 +380,11 @@ def update_roadmap_item(
|
|||
if sort_order is not None:
|
||||
updates.append("sort_order = %s")
|
||||
params.append(sort_order)
|
||||
if clear_strand_project:
|
||||
updates.append("strand_project_id = NULL")
|
||||
elif strand_project_id is not None:
|
||||
updates.append("strand_project_id = %s")
|
||||
params.append(strand_project_id)
|
||||
|
||||
if not updates:
|
||||
return existing
|
||||
|
|
@ -383,14 +395,22 @@ def update_roadmap_item(
|
|||
conn = get_connection()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
if status == "active" and existing.get("item_type") == "maturity_stage":
|
||||
from services.maturity_practice import enforce_single_active_maturity_stage
|
||||
if (
|
||||
status == "active"
|
||||
and existing.get("item_type") == "maturity_stage"
|
||||
):
|
||||
from services.progression_graph import (
|
||||
enforce_single_active_work_gate_per_strand,
|
||||
)
|
||||
|
||||
enforce_single_active_maturity_stage(
|
||||
enforce_single_active_work_gate_per_strand(
|
||||
cur,
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=str(existing["initiative_id"]),
|
||||
keep_active_id=item_id,
|
||||
strand_project_id=strand_project_id
|
||||
if strand_project_id is not None
|
||||
else existing.get("strand_project_id"),
|
||||
)
|
||||
cur.execute(
|
||||
f"""
|
||||
|
|
@ -399,7 +419,7 @@ def update_roadmap_item(
|
|||
WHERE id = %s AND tenant_id = %s
|
||||
RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description,
|
||||
definition_of_done, status, sequencing_mode, target_date,
|
||||
sort_order, created_at, updated_at
|
||||
sort_order, strand_project_id, created_at, updated_at
|
||||
""",
|
||||
params,
|
||||
)
|
||||
|
|
@ -652,6 +672,10 @@ def verify_reached(
|
|||
raise ValueError("RoadmapItem nicht gefunden")
|
||||
if item["status"] in TERMINAL_STATUSES:
|
||||
raise ValueError(f"RoadmapItem bereits terminal: {item['status']}")
|
||||
if item.get("item_type") == "join_gate":
|
||||
raise ValueError(
|
||||
"Join-Knoten schaltet automatisch, wenn alle Eingänge erreicht sind — kein manuelles Verify"
|
||||
)
|
||||
|
||||
initiative_id = item["initiative_id"]
|
||||
conn = get_connection()
|
||||
|
|
@ -694,7 +718,7 @@ def verify_reached(
|
|||
WHERE id = %s AND tenant_id = %s
|
||||
RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description,
|
||||
definition_of_done, status, sequencing_mode, target_date,
|
||||
sort_order, created_at, updated_at
|
||||
sort_order, strand_project_id, created_at, updated_at
|
||||
""",
|
||||
(item_id, tenant_id),
|
||||
)
|
||||
|
|
@ -718,9 +742,9 @@ def verify_reached(
|
|||
details={"roadmap_item_id": item_id, "verify_reason": verify_reason},
|
||||
)
|
||||
if row.get("item_type") == "maturity_stage":
|
||||
from services.maturity_stage_transition import on_maturity_stage_reached
|
||||
from services.progression_graph import on_work_gate_reached
|
||||
|
||||
transition = on_maturity_stage_reached(
|
||||
transition = on_work_gate_reached(
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=initiative_id,
|
||||
reached_item_id=item_id,
|
||||
|
|
@ -770,13 +794,16 @@ def reopen_roadmap_item(
|
|||
try:
|
||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||
if item.get("item_type") == "maturity_stage":
|
||||
from services.maturity_practice import enforce_single_active_maturity_stage
|
||||
from services.progression_graph import (
|
||||
enforce_single_active_work_gate_per_strand,
|
||||
)
|
||||
|
||||
enforce_single_active_maturity_stage(
|
||||
enforce_single_active_work_gate_per_strand(
|
||||
cur,
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=str(item["initiative_id"]),
|
||||
keep_active_id=item_id,
|
||||
strand_project_id=item.get("strand_project_id"),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
|
|
@ -785,7 +812,7 @@ def reopen_roadmap_item(
|
|||
WHERE id = %s AND tenant_id = %s
|
||||
RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description,
|
||||
definition_of_done, status, sequencing_mode, target_date,
|
||||
sort_order, created_at, updated_at
|
||||
sort_order, strand_project_id, created_at, updated_at
|
||||
""",
|
||||
(item_id, tenant_id),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -74,13 +74,14 @@ def _reopen_item_if_reached(
|
|||
if item["status"] != "reached":
|
||||
return
|
||||
if item.get("item_type") == "maturity_stage":
|
||||
from services.maturity_practice import enforce_single_active_maturity_stage
|
||||
from services.progression_graph import enforce_single_active_work_gate_per_strand
|
||||
|
||||
enforce_single_active_maturity_stage(
|
||||
enforce_single_active_work_gate_per_strand(
|
||||
cur,
|
||||
tenant_id=tenant_id,
|
||||
initiative_id=str(item["initiative_id"]),
|
||||
keep_active_id=item["id"],
|
||||
strand_project_id=item.get("strand_project_id"),
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ from typing import Any
|
|||
from steering.signals import default_rules
|
||||
from steering.strategies.next_action.default_strategy import DefaultNextActionStrategy
|
||||
from steering.strategies.next_action.execution_ready import (
|
||||
first_active_gate_id,
|
||||
list_execution_ready_candidates,
|
||||
merge_candidates,
|
||||
)
|
||||
|
|
@ -43,7 +42,12 @@ class MaturityProgressionStrategy:
|
|||
recurring = recurring_due_candidates(ctx, initiative_id, limit=limit)
|
||||
|
||||
remaining = limit - len(recurring)
|
||||
gate_scope = first_active_gate_id(ctx, initiative_id)
|
||||
from services.progression_graph import list_active_work_gates
|
||||
|
||||
active_gates = list_active_work_gates(
|
||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||
)
|
||||
gate_scope = active_gates[0]["id"] if len(active_gates) == 1 else None
|
||||
ready: list[dict[str, Any]] = []
|
||||
if remaining > 0:
|
||||
ready = list_execution_ready_candidates(
|
||||
|
|
|
|||
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
|
||||
body = verify.json()
|
||||
assert body["status"] == "reached"
|
||||
assert body.get("maturity_transition", {}).get("new_recurring_id")
|
||||
transition = body.get("maturity_transition") or {}
|
||||
legacy = transition.get("legacy_linear") or {}
|
||||
practice_ids = (legacy.get("practice_transition") or {}).get("practice_ids") or []
|
||||
assert practice_ids or transition.get("successors_activated") or transition.get("joins_switched")
|
||||
|
||||
recurring = client.get(
|
||||
f"/api/initiatives/{initiative_id}/recurring",
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@
|
|||
|
||||
**Prozess:** `../M1_Hybrid_Path_and_Depth_D_v0.1.md`
|
||||
**Methodenkern (Freeze):** [`../Kairo_Steering_Method_Kernel_v0.1.md`](../Kairo_Steering_Method_Kernel_v0.1.md) — Methodentypen: Kernel §6
|
||||
**PO 2026-07-26:** Architektur beibehalten; Spec-D-Batch **vorläufig freigegeben** (Implementierungsausreichendheit noch offen)
|
||||
**PO 2026-07-28:** Spec-D `maturity_progression` — **Horizon/Progression PO-Lock** in [`SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](../../product/archetypes/SPEC_A1_Progression_Model_PO_Lock_v0.1.md); Spec-D D3/D6/D8 angepasst.
|
||||
|
||||
| Methode | Typ | Datei | Status |
|
||||
|---------|-----|-------|--------|
|
||||
| `sequential_dependency` | Primary (Ausführung) | SPEC_D_sequential_dependency_v0.1.md | vorläufig freigegeben|
|
||||
| `continuous_product` | Primary (Ausführung) | SPEC_D_continuous_product_v0.1.md | vorläufig freigegeben|
|
||||
| `recurring_control` | Primary (Ausführung) | SPEC_D_recurring_control_v0.1.md | vorläufig freigegeben|
|
||||
| `maturity_progression` | Primary (Ausführung) | SPEC_D_maturity_progression_v0.1.md | vorläufig freigegeben|
|
||||
| `maturity_progression` | Primary (Ausführung) | SPEC_D_maturity_progression_v0.1.md | PO-Lock Horizon (2026-07-28) |
|
||||
| `checklist_flow` | Primary (Ausführung) | SPEC_D_checklist_flow_v0.1.md | vorläufig freigegeben|
|
||||
| `care_navigation` | Primary (Ausführung) | SPEC_D_care_navigation_v0.1.md | vorläufig freigegeben|
|
||||
| `dispute_procedure` | Primary (Ausführung) | SPEC_D_dispute_procedure_v0.1.md | vorläufig freigegeben|
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
## Steuerungsmethode — Spec-Tiefe D v0.1
|
||||
|
||||
**Status:** vorläufig freigegeben (PO 2026-07-26) — mit zugehöriger Archetyp-Vollspec; Implementierungsausreichendheit noch offen
|
||||
**Stand:** 2026-07-26
|
||||
**Stand:** 2026-07-28 (PO-Lock Progressionsmodell — [`SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](../../product/archetypes/SPEC_A1_Progression_Model_PO_Lock_v0.1.md))
|
||||
**Default für:** A1 `initiative.maturity_journey`
|
||||
**Prozess:** `M1_Hybrid_Path_and_Depth_D_v0.1.md`
|
||||
**Quellen:** Decision-Lock A1, Archetyp-Vollspec A1, Kernel Freeze, AP2.0e
|
||||
|
|
@ -17,7 +17,7 @@
|
|||
| `method_key` | `maturity_progression` |
|
||||
| Rolle | primary (**Ausführung** — Kernel §6) |
|
||||
| Label (DE) | Reifegrad-Progression |
|
||||
| Zweck | Fähigkeit über Stufen steuern: Active Stage als Horizont; Next = fällige/alternierende Übung oder Stage-Action; bei Stage `reached` Activity Set wechseln. Weiches Vorhaben-Ende. |
|
||||
| Zweck | Fähigkeit über **parallele Strang-Pfade** (Gate-Graph) steuern; Next = fällige Übungen auf **aktiven Gates**; bei Gate `reached` strangspezifischer Activity-Set-Wechsel. Weiches Vorhaben-Ende. |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -34,20 +34,26 @@
|
|||
|
||||
## D3 Horizon
|
||||
|
||||
**PO-Lock (2026-07-28):** [`SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](../../product/archetypes/SPEC_A1_Progression_Model_PO_Lock_v0.1.md) §3 — ersetzt „eine aktive Stufe initiative-weit“ für Multi-Strang.
|
||||
|
||||
| Horizont | Bedeutung |
|
||||
|----------|-----------|
|
||||
| **Aktive `maturity_stage`** | fokussierte Stufe; nur deren Stage Activity Set liefert Next |
|
||||
| Plan-Nebenblick | nächste Stufe / Graph-Voraussetzungen — nicht Leading für Next |
|
||||
| Ist | CadenceInstances + Stage-Actions der aktiven Stufe |
|
||||
| **Aktive Gates (Menge)** | Alle `maturity_stage`/Gate-Knoten mit Status `active`, die graph-ready sind; **mehrere parallel** (je Strang max. ein aktives Gate auf dem Pfad) |
|
||||
| **Strang** | `Project` — paralleler Fähigkeits-Pfad; Übungen mit `project_id` |
|
||||
| **Join-Gate** | wird `active`, wenn alle `requires`-Vorgänger `reached` |
|
||||
| Plan-Nebenblick | blockierte / geplante Gates — nicht Leading für Next |
|
||||
| Ist | CadenceInstances + Stage-Actions der **aktiven Gates** |
|
||||
|
||||
Keine Next-Kandidaten aus inaktiven Stufen.
|
||||
Keine Next-Kandidaten aus Gates, die nicht `active`/graph-ready sind.
|
||||
|
||||
**Lineares Demo-Kit (1 Strang, Stufe 1–3):** Teilmenge mit effektiv einem aktiven Gate — kein PO-Kernbild.
|
||||
|
||||
---
|
||||
|
||||
## D4 Leading Work Item
|
||||
|
||||
**Primär:** `CadenceInstance` (Übung/Ritual am RecurringElement der aktiven Stufe).
|
||||
**Sekundär:** `Action` (Stage-gebunden: Prüfung, Workshop, Nachweis).
|
||||
**Primär:** `CadenceInstance` (Übung am `RecurringElement` eines **aktiven Gates**, optional Strang via `project_id`).
|
||||
**Sekundär:** `Action` (Gate-gebunden: Prüfung, Workshop — selten bei A1).
|
||||
**Hinweis-Objekt (kein Leading Work):** offenes Stufen-Kriterium / Verify-Fortschritt — erscheint in Next/Attention als Steuerhinweis.
|
||||
|
||||
Pro Recurring: genau **eine** offene Instanz (kein Duplikat bei Verpassen).
|
||||
|
|
@ -77,12 +83,13 @@ Kernel-Schlitznamen: Abbildung auf §4 Kernel (Intake … Closure).
|
|||
|
||||
## D6 Next Action — konkrete Regeln
|
||||
|
||||
### Ready-Menge (aktive Stufe)
|
||||
### Ready-Menge (aktive Gates)
|
||||
|
||||
Ein Work Item ist ready, wenn:
|
||||
|
||||
1. gebunden an die **aktive** `maturity_stage` und Aktivität **aktiv**, und
|
||||
2. eines von:
|
||||
1. gebunden an ein **aktives**, graph-ready **Gate** und Aktivität **aktiv**, und
|
||||
2. (Multi-Strang) `project_id` passt zum Strang des Gates, und
|
||||
3. eines von:
|
||||
- **Cadence ohne Alternanz:** Instanz fällig oder überfällig (heute laut Cadence / overdue), oder
|
||||
- **Cadence in Alternanz-Gruppe:** Instanz ist das **aktuelle** Gruppenmitglied **und** fällig/überfällig (bzw. „dran“ laut Gruppenpolicy, wenn Cadence der Gruppe geteilt ist), oder
|
||||
- **Stage-Action:** Status offen und Deps/Blocker erlauben Arbeit
|
||||
|
|
@ -141,7 +148,7 @@ Unabhängige Cadences am gleichen Stage → mehrere ready am selben Tag möglich
|
|||
| practice done / Cadence erfüllt | Instanz schließen; nächste Instanz laut Cadence; bei Alternanz Rotation |
|
||||
| Stage-Action done | aus Ready; ggf. Kriterien-Fortschritt |
|
||||
| Evidence / Kriterium erfüllt | Validation-Fortschritt |
|
||||
| stage `reached` (Verify ok) | altes Activity Set deaktivieren; nächste Stufe aktiv; neues Set aktiv (AP2.0e erweitert) |
|
||||
| stage `reached` (Verify ok) | Activity Set **dieses Gates** deaktivieren; **pro Strang** nächstes Gate / neues Set (Join: strangspezifisch) |
|
||||
| manuelle Adaptation (Set/Cadence/Alternanz) | Ready-Menge neu; auditiert |
|
||||
| Cadence-Zeitpunkt erreicht | Event-Schub → Ready neu bewerten |
|
||||
|
||||
|
|
@ -175,7 +182,7 @@ Felder `alternation_group_id`, `alternation_order`: Archetyp-Vollspec A1 §10.
|
|||
|-----|------|
|
||||
| next_work_strategy | `maturity_progression` |
|
||||
| attention_rules | Codes D7 |
|
||||
| horizon_resolver | aktive `maturity_stage` |
|
||||
| horizon_resolver | Menge aktiver, graph-ready Gates (Multi-Strang) |
|
||||
| dod_policy | Stage-Kriterien / Verify |
|
||||
| adaptation_hooks | `on_stage_reached` → Set-Wechsel; manuelle Set-Änderung |
|
||||
| steering_elements | `next_action_primary`, `maturity_stage`, `recurring_rhythm` |
|
||||
|
|
@ -191,6 +198,7 @@ Felder `alternation_group_id`, `alternation_order`: Archetyp-Vollspec A1 §10.
|
|||
| Hybrid Dominanz | Cadence-Event + Stage-Plan — Kernel trägt Dual-Auslöser |
|
||||
| Leading polymorph | Cadence + Action — Kernel Q2 |
|
||||
| Alternanz-State | Persistenz am Group-/Recurring-Kontext — Implementierungsdetail, fachlich hier verbindlich |
|
||||
| Multi-Fähigkeit | mehrere Projects ok; kein Abnahme-Zwang für Kern-Kit |
|
||||
| Multi-Fähigkeit | mehrere Projects / Stränge — **Kernbild A1** (PO-Lock); Gate-Graph verbindet Pfade |
|
||||
| Single-active initiative-wide | **widerrufen** für Multi-Strang — nur Demo-Kit |
|
||||
|
||||
**PO-Empfehlung:** A1-Vollspec §4 OM + diese Spec-D = Steuervertrag; UI nur in Archetyp-Vollspec.
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@
|
|||
**UI-Navigation (Archetyp):** [`ARCHETYPE_UI_NAVIGATION_v0.1.md`](ARCHETYPE_UI_NAVIGATION_v0.1.md)
|
||||
**Backlog / Epic / Dual Commit:** [`../../architecture/ADP_Backlog_Hierarchy_and_Dual_Commit_Path_v0.1.md`](../../architecture/ADP_Backlog_Hierarchy_and_Dual_Commit_Path_v0.1.md)
|
||||
|
||||
**PO 2026-07-26:** Specs **vorläufig freigegeben** — Implementierungsausreichendheit noch nicht abschließend bewertet.
|
||||
**PO 2026-07-28:** A1 **PO-Lock Progressionsmodell** — [`SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](SPEC_A1_Progression_Model_PO_Lock_v0.1.md) (Option C nach Validation No-Go).
|
||||
|
||||
| ID | Datei | Status |
|
||||
|----|-------|--------|
|
||||
| C1 | [SPEC_C1_dispute_case_v0.1.md](SPEC_C1_dispute_case_v0.1.md) | vorläufig freigegeben |
|
||||
| A1 | [SPEC_A1_maturity_journey_v0.1.md](SPEC_A1_maturity_journey_v0.1.md) | vorläufig freigegeben |
|
||||
| A1 | [SPEC_A1_maturity_journey_v0.1.md](SPEC_A1_maturity_journey_v0.1.md) | PO-Lock Progression (2026-07-28) |
|
||||
| A2 | [SPEC_A2_linear_project_v0.1.md](SPEC_A2_linear_project_v0.1.md) | vorläufig freigegeben |
|
||||
| A3 | [SPEC_A3_recurring_program_v0.1.md](SPEC_A3_recurring_program_v0.1.md) | vorläufig freigegeben |
|
||||
| B1 | [SPEC_B1_checklists_v0.1.md](SPEC_B1_checklists_v0.1.md) | vorläufig freigegeben |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
**Status:** vorläufig freigegeben (PO 2026-07-26) — Zielbild; Implementierungsausreichendheit noch nicht abschließend bewertet
|
||||
**Stand:** 2026-07-26
|
||||
**MVP-Stufe:** A
|
||||
**Stand:** 2026-07-28 (PO-Lock Progressionsmodell — siehe §14)
|
||||
**MVP-Stufe:** A (PO-Kernbild: Multi-Strang — §14.4)
|
||||
**Decision-Lock:** A1 Reifegrad
|
||||
**Default-Methode:** [`SPEC_D_maturity_progression_v0.1.md`](../../architecture/methods/SPEC_D_maturity_progression_v0.1.md)
|
||||
**Methodenkern:** `Kairo_Steering_Method_Kernel_v0.1.md`
|
||||
|
|
@ -52,7 +52,10 @@ Kontinuierliche Entwicklung einer Fähigkeit über **Phasen/Stufen**. Eine Phase
|
|||
6. Innerhalb Mittelstufe Übung hinzufügen (Adaptation), auditiert.
|
||||
7. Optional zweites Fähigkeits-Project + Graph-Voraussetzung.
|
||||
|
||||
**Leitfrage:** Welche Stufe ist aktiv — und welche Übung/Aktivität ist **jetzt** dran (inkl. Alternanz/Zeitversatz)?
|
||||
**Leitfrage:** Welche **Strang-Gates** sind aktiv — und welche Übung ist **jetzt** auf welchem Pfad dran (inkl. Alternanz/Zeitversatz)?
|
||||
|
||||
**Referenzbeispiel (PO 2026-07-28) — parallele Progression „Mawashi Geri“:**
|
||||
Mehrere Stränge (Projects: Dehnung, Hüftrotation, Technik), je Strang Gate-Kette im Graph, Join-Gate („sicher zur Hüfte“), Activity Set pro Gate, nach Join strangspezifischer Set-Wechsel. Vollständig: [`SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](SPEC_A1_Progression_Model_PO_Lock_v0.1.md) §2.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -67,25 +70,28 @@ evidence, decisions, reviews, steering_methods
|
|||
|
||||
## 4. Plan- / Ist-Vertrag
|
||||
|
||||
### 4.1 Stufen / Phasen
|
||||
### 4.1 Stufen / Phasen / Stränge
|
||||
|
||||
| Element | Rolle |
|
||||
|---------|--------|
|
||||
| `maturity_stage` (RoadmapItem) | Phase/Stufe; eine **aktive** Stufe (Horizon) |
|
||||
| Kriterien / Evidence | Verify vor `reached` |
|
||||
| Graph | Voraussetzungen zwischen Stufen und/oder Fähigkeits-Projects |
|
||||
| Project `project.maturity_journey` | Fähigkeit/Spur; eine oder mehrere |
|
||||
| `maturity_stage` (RoadmapItem) | Gate / Reifegrad-Checkpoint auf einem **Pfad** |
|
||||
| **Strang** | `Project` (`project.maturity_journey`) — z. B. Dehnung, Technik, Hüftrotation |
|
||||
| Kriterien / Evidence | Verify vor `reached`; inkl. `metric` |
|
||||
| Graph | Parallele Pfade, `requires`-Kanten, **Join-Gates** zwischen Strängen |
|
||||
| **Horizon (PO-lock)** | **Mehrere** aktive Gates initiative-weit; **pro Strang** höchstens ein aktives Gate auf dem Pfad — siehe [`SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](SPEC_A1_Progression_Model_PO_Lock_v0.1.md) §3 |
|
||||
|
||||
> **Hinweis:** Lineares Demo-Kit (Stufe 1–3, ein Strang) ist **Teilmenge**, nicht das PO-Kernbild.
|
||||
|
||||
### 4.2 Aktivitäts-Set einer Phase (verbindlich)
|
||||
|
||||
| Aktivitätstyp | Persistenz | Rolle |
|
||||
|---------------|------------|--------|
|
||||
| Übung / Ritual | `RecurringElement`, gebunden an `maturity_stage` | wiederkehrend |
|
||||
| Übung / Ritual | `RecurringElement`, gebunden an **Gate** (`roadmap_item_id`) **und Strang** (`project_id` bei Multi-Strang) | wiederkehrend |
|
||||
| Einmal-/Sonderaktivität | `Action`, gebunden an Stufe | Prüfung, Workshop, Nachweis |
|
||||
|
||||
- Pro Stufe: **beliebig viele** Aktivitäten; **≥1 reicht**.
|
||||
- Pro **Gate**: beliebig viele Übungen; **≥1** pro aktivem Gate auf einem Strang.
|
||||
- Pro Recurring: **eine** offene CadenceInstance (kein Duplikat bei Verpassen).
|
||||
- Inaktive Stufen: kein Next, keine neuen Instanzen; Historie bleibt.
|
||||
- Inaktive / nicht-graph-ready Gates: kein Next, keine neuen Instanzen; Historie bleibt.
|
||||
|
||||
### 4.3 Zeitversatz und Alternanz (gleiche Phase)
|
||||
|
||||
|
|
@ -97,29 +103,30 @@ evidence, decisions, reviews, steering_methods
|
|||
|
||||
Alternanz-Regeln: Rotation nach Erfüllung; Overdue nur auf aktuelles Mitglied; Gruppe an Stufe gebunden.
|
||||
|
||||
### 4.4 Modifikation bei Stage `reached`
|
||||
### 4.4 Modifikation bei Gate `reached`
|
||||
|
||||
1. Altes Activity Set deaktivieren.
|
||||
2. Nächste Stufe aktiv; neues Set aktiv.
|
||||
3. Neues Set darf Übungen/Cadences/Alternanz/Actions ersetzen/erweitern/reduzieren.
|
||||
4. Adaptation innerhalb Stufe (manuell) erlaubt und auditiert.
|
||||
|
||||
Erweitert AP2.0e auf ganze Activity Sets.
|
||||
1. Activity Set **dieses Gates** deaktivieren.
|
||||
2. **Pro Strang:** nächstes Gate auf dem Pfad `active` (Graph-regelkonform); bei **Join-Gates** nach Verify.
|
||||
3. Neues Set **strangspezifisch** — Stränge dürfen divergieren (z. B. Technik ↓, Dehnung ↑).
|
||||
4. Adaptation innerhalb aktiven Gates (manuell) erlaubt und auditiert.
|
||||
|
||||
### 4.5 Datenfelder am Recurring (verbindlich)
|
||||
|
||||
| Feld | Zweck |
|
||||
|------|-------|
|
||||
| Bindung an `maturity_stage` | Stage Activity Set |
|
||||
| `roadmap_item_id` | Gate / Activity Set |
|
||||
| `project_id` | Strang (Pfad); Pflicht bei Multi-Strang |
|
||||
| Cadence | Zeitversatz |
|
||||
| `alternation_group_id` + `alternation_order` | Alternanz (leer = keine Gruppe) |
|
||||
| status aktiv/inaktiv | Set-Wechsel / Adaptation |
|
||||
|
||||
### 4.6 Anti-Patterns
|
||||
|
||||
- Flache Liste „Wiederkehrend“ ohne Gate/Strang-Bezug
|
||||
- Übungen aller Stufen als flache Action-Liste
|
||||
- Verpasste Übung → Duplikat-Task
|
||||
- Alternanz ohne Gruppen-Semantik
|
||||
- Ein uniformes Initiative-Set nach Join (statt strangspezifisch)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -130,7 +137,7 @@ Erweitert AP2.0e auf ganze Activity Sets.
|
|||
| Aspekt | Kurz |
|
||||
|--------|------|
|
||||
| Dominanz | hybrid |
|
||||
| Horizon | aktive `maturity_stage` |
|
||||
| Horizon | aktive Gates (multi-strang) — PO-Lock §14 |
|
||||
| Leading | CadenceInstance; sekundär Stage-Action |
|
||||
| Next / Ranking | Spec-D D6 |
|
||||
| Attention / Events | Spec-D D7 / D8 |
|
||||
|
|
@ -175,12 +182,12 @@ Cadence im Detail am RecurringElement.
|
|||
|
||||
| Fläche | Inhalt |
|
||||
|--------|--------|
|
||||
| **Plan → Stufen** | Timeline/Liste; aktiv; Kriterien; Verify |
|
||||
| **Plan → Stufe Detail** | Activity Set: Übungen (Cadence, Alternanz), Stage-Actions |
|
||||
| **Plan → Struktur** | Fähigkeits-Projects + Graph |
|
||||
| **Plan → Struktur** | Stränge (Projects) + Gate-Graph (parallel, Join) |
|
||||
| **Plan → Gate-Detail** | Activity Set: Übungen, Strang, Cadence, metric-Kriterien |
|
||||
| **Plan → Stufen** | Graph-/Listen-Ansicht aktiver Gates |
|
||||
| **Plan → Profil** | EFS Fokus/Frequenz/Disziplin |
|
||||
| **Ausführen / Today** | fällige Übungen + Alternanz-aktuell + Stage-Actions |
|
||||
| **Kontrolle** | Stufe, Next + Begründung, Attention, Verify-Fortschritt |
|
||||
| **Ausführen / Today** | fällige Übungen **gruppiert nach Strang/Gate** |
|
||||
| **Kontrolle** | aktive Gates, Join-Status, Kriterien-Fortschritt |
|
||||
| **Journey** | Stufenwechsel, Set-Modifikationen, erfüllte Übungen |
|
||||
| **Process** | Stufen → Stufe/Übungen → Ausführen → Kontrolle |
|
||||
| **Elements** | `next_action_primary`, `maturity_stage`, `recurring_rhythm` |
|
||||
|
|
@ -231,17 +238,16 @@ Cadence im Detail am RecurringElement.
|
|||
**PO-Validation 2026-07-28:** Detaillierte Findings + Architektur-Urteil →
|
||||
`docs/sprints/Sprint1_AP2_1_Validation_Findings_Register_v0.1.md` (F-A1-01…10)
|
||||
|
||||
**PO-Produkturteil A1:** **No-Go** — Leitfrage nicht erfüllt; Tages-Tracking, Messgrößen, Gate-Fortschritt und IA fehlen für echten Einsatz. Architektur/Spec **nicht** falsch; **Minimal-Slice** (CadenceInstance, Work=Übung) vor Re-Abnahme nötig.
|
||||
**PO-Produkturteil A1 (2026-07-28):** **No-Go** — Cadence ohne Progressions-IA. **Option C (PO-Lock):** [`SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](SPEC_A1_Progression_Model_PO_Lock_v0.1.md) — Multi-Strang vor Re-Abnahme.
|
||||
|
||||
| Bereich | Spec | Code/Truth | Lücke |
|
||||
|---------|------|------------|-------|
|
||||
| Registry / Kit | A1 + Stufen | weitgehend AP2.0e/Kit | Multi-Übung/Alternanz |
|
||||
| Methode | `maturity_progression` | vorhanden | Ranking Alternanz Spec-D |
|
||||
| Stage Activity Set | verbindlich | Teilmenge | Stage-Bindung + UI Detail |
|
||||
| Alternanz-Felder | verbindlich | fehlt/teilweise | Persistenz + Strategy |
|
||||
| EFS Pflicht | focus/frequency | Seeds prüfen | Seed-AP |
|
||||
| steering_elements | D11 | teilweise | FE/BE Parität |
|
||||
| UI Stufe-Detail | Pflicht | lückenhaft | UI-AP |
|
||||
| Progressionsmodell | PO-Lock | linear + flat recurring | Multi-Strang + Gate-Activity-Set |
|
||||
| Horizon multi-active | PO-Lock §3 | single-active (widerrufen) | AP-A1-PM-3 |
|
||||
| Stage Activity Set | Gate + Strang | Teilmenge | Gate-Detail UI + `project_id` |
|
||||
| Registry / Kit | Demo Spagat | geliefert | reicht nicht für PO-Kernbild |
|
||||
| UI Plan Struktur/Gate | Pflicht | fehlt | AP-A1-PM-4 |
|
||||
| Alternanz / metric | verbindlich | fehlt/teilweise | AP-A1-PM-5+ |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -254,5 +260,25 @@ Fachmodell vollständig. Lücken (Alternanz, Multi-Übung-UI, EFS-Seeds) = Imple
|
|||
## 13. Nachfolger
|
||||
|
||||
- Alternanz-Persistenz + Strategy
|
||||
- UI Stufe-Detail / Activity Set
|
||||
- Template Kumite
|
||||
- UI Plan → Struktur + Gate-Detail / Activity Set
|
||||
- Template Kumite / Mawashi (Multi-Strang)
|
||||
|
||||
---
|
||||
|
||||
## 14. PO-Lock Progressionsmodell (2026-07-28, Option C)
|
||||
|
||||
**Verbindlich** für Horizon, Stränge, Gate-Activity-Sets und MVP-Abgrenzung:
|
||||
|
||||
→ [`SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](SPEC_A1_Progression_Model_PO_Lock_v0.1.md)
|
||||
|
||||
Kurzfassung:
|
||||
|
||||
| Thema | Lock |
|
||||
|-------|------|
|
||||
| Strang | `Project` — parallele Fähigkeits-Pfade |
|
||||
| Gate | `maturity_stage` im Graph; Join-Gates mit `requires` |
|
||||
| Übung | `RecurringElement` = Übung; Rhythmus = Feld; **nicht** losgelöstes Objekt |
|
||||
| Horizon | **Multi-active** Gates; pro Strang max. 1 aktives Gate auf Pfad |
|
||||
| MVP A1 Go | ≥2 Stränge + Join + Gate-Activity-Set + Today — nicht nur lineares Spagat-Demo |
|
||||
|
||||
Implementierungsfolge: PO-Lock §8 (`AP-A1-PM-1` … `PM-6`).
|
||||
|
|
|
|||
|
|
@ -19,18 +19,18 @@
|
|||
|
||||
## 1. Executive Summary
|
||||
|
||||
| Entscheidung | ☐ Go Stufe A · ☐ Bedingt · ☑ No-Go (bis A1 nachgezogen oder Scope-PO) |
|
||||
| Entscheidung | ☐ Go Stufe A · ☐ Bedingt · ☑ No-Go (A1 bis Progressions-IA; Option C PO-Lock) |
|
||||
|--------------|-------------------------------------|
|
||||
|
||||
**Kurzfazit (3–5 Sätze):**
|
||||
|
||||
Technisch: pytest grün, A1-Happy-Path (Anlage → Verify → Stufenwechsel) durchspielbar.
|
||||
Produkt (PO): **A1 No-Go** — Leitfrage nicht beantwortbar; tagesbezogenes Tracking, Messgrößen und Gate-Fortschritt fehlen; extreme Unübersichtlichkeit; **Hürden > Nutzen**. A2/B2b noch offen.
|
||||
Produkt (PO): **A1 No-Go** — Cadence-Plumbing reicht nicht; Nutzerbild = **parallele Stränge + Gate-Graph + Activity Sets**, nicht flache „Wiederkehrend“-Liste. **PO-Entscheid Option C:** Spec schärfen → [`SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](../product/archetypes/SPEC_A1_Progression_Model_PO_Lock_v0.1.md). A2/B2b noch offen.
|
||||
|
||||
**Blocker (falls No-Go / Bedingt):**
|
||||
|
||||
- **A1:** CadenceInstance/Tages-„erledigt“, Work=Übung, Gate-Fortschritt, IA — siehe Findings Register §6.1 (F-A1-02…10)
|
||||
- **Gesamt Stufe A:** MVP §4 verlangt A1 — Go erst nach A1 Minimum Slice **oder** PO-Scope-Entscheidung (A1 aus Stufe-A-Abnahfe)
|
||||
- **A1:** Progressions-IA gemäß PO-Lock §6–8 (`AP-A1-PM-2` … `PM-6`) — Multi-Strang, Gate-Activity-Set, Plan-UI, Today gruppiert
|
||||
- **Gesamt Stufe A:** MVP §4 verlangt A1 — Go erst nach PO-Kernbild (≥2 Stränge + Join) durchspielbar
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -206,13 +206,22 @@ Alternanz, volle Metric-Engine, Activity-Set-Automation → **nach** Slice 1–4
|
|||
|
||||
## 7. Konsequenz für AP2.1 / MVP Stufe A
|
||||
|
||||
| Option | Bedeutung |
|
||||
|--------|-----------|
|
||||
| **A — A1 nachziehen** | Validation pausieren; **A1 Minimum Slice** (§6.1) liefern; A1 erneut smoke-testen |
|
||||
| **B — MVP-Definition anpassen** | A1 vorübergehend aus Stufe-A-Abnahfe (PO-Entscheidung + Doku) — **nur wenn A2+B2b als MVP reichen** |
|
||||
| **C — Gesamt No-Go** | AP2.1 stoppt bei Stufe A bis A1 oder Scope-Entscheidung |
|
||||
| Option | Bedeutung | PO 2026-07-28 |
|
||||
|--------|-----------|---------------|
|
||||
| **A — A1 nachziehen** | Minimum Slice liefern | ◐ Cadence done; Progressions-IA offen |
|
||||
| **B — MVP-Definition anpassen** | A1 aus Stufe-A-Abnahfe | nicht gewählt |
|
||||
| **C — Spec schärfen, dann implementieren** | PO-Lock vor Coding | **✓ gewählt** |
|
||||
|
||||
**Empfehlung:** **Option A** — Architektur trägt; Spec war richtig; Implementierung war zu dünn. A2/B2b-Smoke **parallel** möglich, Gesamt-Go aber nicht ohne A1-Lösung (Option A oder explizite B).
|
||||
**PO-Lock (Option C):** [`docs/product/archetypes/SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](../product/archetypes/SPEC_A1_Progression_Model_PO_Lock_v0.1.md)
|
||||
|
||||
Kernentscheide:
|
||||
|
||||
- **Multi-Strang** (Projects) + **Gate-Graph** + **Join-Gates**
|
||||
- **Horizon:** mehrere aktive Gates (nicht eine Stufe initiative-weit)
|
||||
- **Übung** = `RecurringElement` am Gate + Strang; Rhythmus = Feld
|
||||
- **A1 Go MVP:** ≥2 Stränge + 1 Join + Gate-Activity-Set + Today — nicht nur Spagat-Demo
|
||||
|
||||
**Validation A1:** pausiert bis `AP-A1-PM-4` (Plan Struktur + Gate-Detail). A2/B2b-Smoke **parallel** möglich.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# AP A1 — Minimum Usability Slice
|
||||
## Auftrag v0.1
|
||||
|
||||
**Status:** freigegeben (PO folgt Option A, 2026-07-28) · **Implementierung A1.1–A1.3 abgeschlossen** (Re-Abnahme ausstehend)
|
||||
**Status:** freigegeben (PO folgt Option A, 2026-07-28) · **superseded by Option C PO-Lock** — siehe [`SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](../archetypes/SPEC_A1_Progression_Model_PO_Lock_v0.1.md)
|
||||
**Auslöser:** AP2.1 PO No-Go A1 — [`Sprint1_AP2_1_Validation_Findings_Register_v0.1.md`](Sprint1_AP2_1_Validation_Findings_Register_v0.1.md) §6.1
|
||||
**Ziel:** A1 **real nutzbar** für Tages-Tracking + Gate-Einschätzung + klare Today-Ansicht — **ohne** Architektur-Neudesign.
|
||||
|
||||
|
|
|
|||
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')
|
||||
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
|
||||
|
||||
const activeStage = stages.find((s) => s.status === 'active')
|
||||
const activeStages = stages.filter((s) => s.status === 'active')
|
||||
const activeStage = activeStages[0]
|
||||
|
||||
useEffect(() => {
|
||||
if (!initiativeId) return undefined
|
||||
|
|
@ -40,15 +41,20 @@ export function MaturityStagePanel({
|
|||
<EmptyState message="Noch keine Reifegrad-Stufen — unter Plan anlegen oder Starter-Kit nutzen." />
|
||||
) : (
|
||||
<>
|
||||
{activeStage && (
|
||||
{activeStages.length > 0 && (
|
||||
<>
|
||||
<p className="maturity-stage-active muted">
|
||||
Aktive Stufe:{' '}
|
||||
<Link to={gatePath(activeStage.id)} className="link-inline">
|
||||
<strong>{activeStage.title}</strong>
|
||||
</Link>
|
||||
Aktive Gate(s):{' '}
|
||||
{activeStages.map((stage, index) => (
|
||||
<span key={stage.id}>
|
||||
{index > 0 ? ' · ' : ''}
|
||||
<Link to={gatePath(stage.id)} className="link-inline">
|
||||
<strong>{stage.title}</strong>
|
||||
</Link>
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
{activeProgress && (
|
||||
{activeStage && activeProgress && (
|
||||
<p className="maturity-stage-progress muted">
|
||||
Gate-Fortschritt:{' '}
|
||||
<strong>
|
||||
|
|
|
|||
|
|
@ -12,10 +12,10 @@ function formatDueAt(iso) {
|
|||
}
|
||||
}
|
||||
|
||||
function activeMaturityStage(roadmapItems = []) {
|
||||
function activeMaturityStages(roadmapItems = []) {
|
||||
return [...roadmapItems]
|
||||
.filter((item) => item.item_type === 'maturity_stage' && item.status === 'active')
|
||||
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))[0]
|
||||
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
|
||||
}
|
||||
|
||||
function practicesForStage(recurringItems = [], stageId) {
|
||||
|
|
@ -32,20 +32,22 @@ export function RecurringRhythmPanel({
|
|||
canManage = false,
|
||||
embedded = true,
|
||||
}) {
|
||||
const stage = activeMaturityStage(roadmapItems)
|
||||
const stagePractices = practicesForStage(recurringItems, stage?.id)
|
||||
const stages = activeMaturityStages(roadmapItems)
|
||||
const stagePractices = stages.flatMap((stage) =>
|
||||
practicesForStage(recurringItems, stage.id),
|
||||
)
|
||||
const active = stagePractices.filter((item) => item.status === 'active')
|
||||
const paused = stagePractices.filter((item) => item.status === 'paused')
|
||||
|
||||
const body =
|
||||
!stage ? (
|
||||
<EmptyState message="Keine aktive Reifegrad-Stufe — Plan → Stufen prüfen." />
|
||||
stages.length === 0 ? (
|
||||
<EmptyState message="Keine aktiven Work-Gates — Plan → Struktur prüfen." />
|
||||
) : 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">
|
||||
Stufe <strong>{stage.title}</strong> — {active.length} aktiv
|
||||
{stages.length} aktive Gate(s) — {active.length} Übung(en) aktiv
|
||||
{paused.length > 0 ? `, ${paused.length} pausiert` : ''}.
|
||||
</p>
|
||||
<ul className="item-list recurring-rhythm-list">
|
||||
|
|
@ -92,9 +94,9 @@ export function RecurringRhythmPanel({
|
|||
<section className="card recurring-rhythm-panel">
|
||||
<div className="section-header">
|
||||
<div>
|
||||
<h2>Übungen der Stufe</h2>
|
||||
<h2>Übungen der aktiven Gates</h2>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,17 +13,22 @@ function formatDue(iso) {
|
|||
}
|
||||
}
|
||||
|
||||
function activeMaturityStage(roadmapItems = []) {
|
||||
function activeWorkGates(roadmapItems = []) {
|
||||
return [...roadmapItems]
|
||||
.filter((item) => item.item_type === 'maturity_stage' && item.status === 'active')
|
||||
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))[0]
|
||||
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
|
||||
}
|
||||
|
||||
function exercisesForStage(recurringItems = [], stageId) {
|
||||
const active = (recurringItems || []).filter((item) => item.status === 'active')
|
||||
if (!stageId) return active
|
||||
const bound = active.filter((item) => item.roadmap_item_id === stageId)
|
||||
return bound.length > 0 ? bound : active
|
||||
function exercisesForGate(recurringItems = [], gateId) {
|
||||
return (recurringItems || []).filter(
|
||||
(item) => item.status === 'active' && item.roadmap_item_id === gateId,
|
||||
)
|
||||
}
|
||||
|
||||
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 }) {
|
||||
|
|
@ -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({
|
||||
initiativeId,
|
||||
roadmapItems = [],
|
||||
recurringItems = [],
|
||||
projects = [],
|
||||
canManage = false,
|
||||
onCompleted,
|
||||
busy = false,
|
||||
}) {
|
||||
const stage = useMemo(() => activeMaturityStage(roadmapItems), [roadmapItems])
|
||||
|
||||
const todayExercises = useMemo(
|
||||
() => exercisesForStage(recurringItems, stage?.id),
|
||||
[recurringItems, stage?.id],
|
||||
const projectsById = useMemo(
|
||||
() => new Map((projects || []).map((project) => [project.id, project])),
|
||||
[projects],
|
||||
)
|
||||
|
||||
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 allDone = todayExercises.length > 0 && pending.length === 0
|
||||
|
||||
|
|
@ -120,20 +135,15 @@ export function WorkTodayPracticePanel({
|
|||
<div>
|
||||
<h2>Heutige Übungen</h2>
|
||||
<p className="section-lead muted">
|
||||
{stage ? (
|
||||
<>
|
||||
Aktive Stufe: <strong>{stage.title}</strong> — pro Übung erledigen, optional Messwert
|
||||
notieren.
|
||||
</>
|
||||
) : (
|
||||
'Tages-Tracking für die aktive Reifegrad-Stufe.'
|
||||
)}
|
||||
{gates.length > 0
|
||||
? `${gates.length} aktive Gate(s) — pro Übung erledigen, optional Messwert notieren.`
|
||||
: 'Tages-Tracking für aktive Work-Gates auf allen Strängen.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{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 && (
|
||||
|
|
@ -141,17 +151,26 @@ export function WorkTodayPracticePanel({
|
|||
Alle Übungen für heute erledigt — nächste Fälligkeit morgen.
|
||||
</p>
|
||||
)}
|
||||
<ul className="item-list work-today-practice__list">
|
||||
{todayExercises.map((exercise) => (
|
||||
<ExerciseRow
|
||||
key={exercise.id}
|
||||
exercise={exercise}
|
||||
canManage={canManage}
|
||||
busy={busy}
|
||||
onCompleted={onCompleted}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
{grouped.map(({ gate, strand, exercises }) =>
|
||||
exercises.length === 0 ? null : (
|
||||
<div key={gate.id} className="work-today-practice__strand">
|
||||
<h3 className="work-today-practice__strand-title">
|
||||
{strand} — {gate.title}
|
||||
</h3>
|
||||
<ul className="item-list work-today-practice__list">
|
||||
{exercises.map((exercise) => (
|
||||
<ExerciseRow
|
||||
key={exercise.id}
|
||||
exercise={exercise}
|
||||
canManage={canManage}
|
||||
busy={busy}
|
||||
onCompleted={onCompleted}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,16 @@ export const COMPOSITION_PROVIDERS = [
|
|||
scopeTypes: ['initiative'],
|
||||
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',
|
||||
kind: 'steering_element',
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Link } from 'react-router-dom'
|
|||
import { scopedPath } from '../utils/routes.js'
|
||||
import { SteeringSnapshotPanel } from '../components/SteeringSnapshotPanel.jsx'
|
||||
import { CriticalPathPanel } from '../components/CriticalPathPanel.jsx'
|
||||
import { JoinWaitlistPanel } from '../components/JoinWaitlistPanel.jsx'
|
||||
import { MaturityStagePanel } from '../components/MaturityStagePanel.jsx'
|
||||
import { RecurringRhythmPanel } from '../components/RecurringRhythmPanel.jsx'
|
||||
import { AgentSlotsPanel } from '../components/AgentSlotsPanel.jsx'
|
||||
|
|
@ -47,6 +48,7 @@ export const PROVIDER_COMPONENTS = {
|
|||
WidgetHost,
|
||||
WorkActionsPanel,
|
||||
WorkTodayPracticePanel,
|
||||
JoinWaitlistPanel,
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -203,11 +203,16 @@ export function buildProviderProps(provider, input) {
|
|||
recurringItems: ops.recurringItems || [],
|
||||
canManage: capabilities.has('kairo.recurring.manage'),
|
||||
}
|
||||
case 'JoinWaitlistPanel':
|
||||
return {
|
||||
progressionState: input.steeringSnapshot?.progression_state ?? null,
|
||||
}
|
||||
case 'WorkTodayPracticePanel':
|
||||
return {
|
||||
initiativeId: ops.initiativeId,
|
||||
roadmapItems: ops.roadmapItems || [],
|
||||
recurringItems: ops.recurringItems || [],
|
||||
projects: ops.projects || [],
|
||||
canManage: capabilities.has('kairo.recurring.manage'),
|
||||
busy: ops.formBusy,
|
||||
onCompleted: async () => {
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ export const ROADMAP_ITEM_TYPE_LABELS = {
|
|||
milestone: 'Meilenstein / Gate',
|
||||
review_gate: 'Review-Gate',
|
||||
maturity_stage: 'Reifegrad-Stufe',
|
||||
join_gate: 'Join / Schaltknoten',
|
||||
}
|
||||
|
||||
export const SEQUENCING_MODE_LABELS = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user