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

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:
Lars 2026-07-28 16:28:08 +02:00
parent 192a65a798
commit 3a0ef3a734
29 changed files with 1457 additions and 230 deletions

View File

@ -16,6 +16,7 @@ from steering.context import get_steering_context_dto
from steering.kernel import evaluate_steering from steering.kernel import evaluate_steering
from steering.signals.snapshot_signals import derive_initiative_signals from steering.signals.snapshot_signals import derive_initiative_signals
from services.initiatives import get_initiative from services.initiatives import get_initiative
from services.progression_graph import compute_progression_state
from tenant_context import TenantContext from tenant_context import TenantContext
OPEN_BLOCKER = ("open", "in_progress") OPEN_BLOCKER = ("open", "in_progress")
@ -383,6 +384,9 @@ def get_initiative_steering_snapshot(
"actions": actions, "actions": actions,
"roadmap_items": roadmap_items, "roadmap_items": roadmap_items,
"milestones": roadmap_items, "milestones": roadmap_items,
"progression_state": compute_progression_state(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
),
"initiative_level": { "initiative_level": {
"blockers": unlinked_blockers, "blockers": unlinked_blockers,
"evidence": unlinked_evidence, "evidence": unlinked_evidence,

View 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;

View File

@ -18,7 +18,7 @@ router = APIRouter(tags=["roadmap"])
class RoadmapItemCreateRequest(BaseModel): class RoadmapItemCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255) title: str = Field(min_length=1, max_length=255)
item_type: Literal["milestone", "review_gate", "maturity_stage", "work_cycle"] = "milestone" item_type: Literal["milestone", "review_gate", "maturity_stage", "work_cycle", "join_gate"] = "milestone"
goal_description: str = "" goal_description: str = ""
definition_of_done: list[Any] = Field(default_factory=list) definition_of_done: list[Any] = Field(default_factory=list)
status: Literal[ status: Literal[
@ -27,12 +27,13 @@ class RoadmapItemCreateRequest(BaseModel):
sequencing_mode: Literal["sequential", "parallel", "optional"] = "sequential" sequencing_mode: Literal["sequential", "parallel", "optional"] = "sequential"
target_date: Optional[date] = None target_date: Optional[date] = None
sort_order: int = 0 sort_order: int = 0
strand_project_id: Optional[str] = None
initial_criterion_title: Optional[str] = Field(default=None, max_length=500) initial_criterion_title: Optional[str] = Field(default=None, max_length=500)
class RoadmapItemUpdateRequest(BaseModel): class RoadmapItemUpdateRequest(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=255) title: Optional[str] = Field(default=None, min_length=1, max_length=255)
item_type: Optional[Literal["milestone", "review_gate", "maturity_stage", "work_cycle"]] = None item_type: Optional[Literal["milestone", "review_gate", "maturity_stage", "work_cycle", "join_gate"]] = None
goal_description: Optional[str] = None goal_description: Optional[str] = None
definition_of_done: Optional[list[Any]] = None definition_of_done: Optional[list[Any]] = None
status: Optional[ status: Optional[
@ -42,6 +43,8 @@ class RoadmapItemUpdateRequest(BaseModel):
target_date: Optional[date] = None target_date: Optional[date] = None
clear_target_date: bool = False clear_target_date: bool = False
sort_order: Optional[int] = None sort_order: Optional[int] = None
strand_project_id: Optional[str] = None
clear_strand_project: bool = False
class DependencyCreateRequest(BaseModel): class DependencyCreateRequest(BaseModel):
@ -166,6 +169,21 @@ def get_initiative_roadmap_graph_state(
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
@initiative_router.get("/{initiative_id}/roadmap/progression-state")
def get_initiative_progression_state(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
):
try:
from services.progression_graph import compute_progression_state
return compute_progression_state(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@initiative_router.get("/{initiative_id}/roadmap/plan-ist-view") @initiative_router.get("/{initiative_id}/roadmap/plan-ist-view")
def get_initiative_plan_ist_view( def get_initiative_plan_ist_view(
initiative_id: str, initiative_id: str,
@ -228,6 +246,7 @@ def create_initiative_roadmap_item(
sequencing_mode=body.sequencing_mode, sequencing_mode=body.sequencing_mode,
target_date=body.target_date, target_date=body.target_date,
sort_order=body.sort_order, sort_order=body.sort_order,
strand_project_id=body.strand_project_id,
initial_criterion_title=body.initial_criterion_title, initial_criterion_title=body.initial_criterion_title,
) )
except ValueError as exc: except ValueError as exc:
@ -265,6 +284,8 @@ def update_roadmap_item(
target_date=body.target_date, target_date=body.target_date,
clear_target_date=body.clear_target_date, clear_target_date=body.clear_target_date,
sort_order=body.sort_order, sort_order=body.sort_order,
strand_project_id=body.strand_project_id,
clear_strand_project=body.clear_strand_project,
) )
except ValueError as exc: except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc

View File

@ -312,6 +312,130 @@ def _apply_a2(
} }
def apply_a1_progression_demo(
*,
tenant_id: str,
initiative_id: str,
user_id: Optional[str] = None,
) -> dict[str, Any]:
"""PO-Kernbild: 2 Stränge → Join → Ausgänge (AP-A1-PM-6). Idempotent wenn Struktur existiert."""
from services.maturity_practice import seed_practices_for_stage
from services.projects import create_project, list_projects_for_initiative
from services.roadmap import add_dependency, create_roadmap_item, list_roadmap_items_for_initiative
existing = list_roadmap_items_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id
)
if any(item.get("item_type") == "join_gate" for item in existing):
return {"applied": False, "reason": "progression_exists"}
projects = list_projects_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
dehnung = _find_by_title(projects, "Dehnung")
if not dehnung:
dehnung = create_project(
tenant_id=tenant_id,
initiative_id=initiative_id,
title="Dehnung",
description="Strang Dehnung",
container_kind="stream",
sort_order=0,
user_id=user_id,
)
huft = _find_by_title(projects, "Hüftrotation")
if not huft:
huft = create_project(
tenant_id=tenant_id,
initiative_id=initiative_id,
title="Hüftrotation",
description="Strang Hüftrotation",
container_kind="stream",
sort_order=1,
user_id=user_id,
)
d1 = create_roadmap_item(
tenant_id=tenant_id,
initiative_id=initiative_id,
title="Dehnung — Einstieg",
item_type="maturity_stage",
status="active",
sort_order=10,
strand_project_id=dehnung["id"],
user_id=user_id,
)
h1 = create_roadmap_item(
tenant_id=tenant_id,
initiative_id=initiative_id,
title="Hüftrotation — Einstieg",
item_type="maturity_stage",
status="active",
sort_order=20,
strand_project_id=huft["id"],
user_id=user_id,
)
join = create_roadmap_item(
tenant_id=tenant_id,
initiative_id=initiative_id,
title="Mawashi Geri zur Hüfte",
item_type="join_gate",
status="planned",
sort_order=30,
user_id=user_id,
)
d2 = create_roadmap_item(
tenant_id=tenant_id,
initiative_id=initiative_id,
title="Dehnung — nach Join",
item_type="maturity_stage",
status="planned",
sort_order=40,
strand_project_id=dehnung["id"],
user_id=user_id,
)
h2 = create_roadmap_item(
tenant_id=tenant_id,
initiative_id=initiative_id,
title="Hüftrotation — nach Join",
item_type="maturity_stage",
status="planned",
sort_order=50,
strand_project_id=huft["id"],
user_id=user_id,
)
for from_id, to_id in (
(join["id"], d1["id"]),
(join["id"], h1["id"]),
(d2["id"], join["id"]),
(h2["id"], join["id"]),
):
try:
add_dependency(
tenant_id=tenant_id,
from_item_id=from_id,
to_item_id=to_id,
dependency_type="requires",
user_id=user_id,
)
except ValueError as exc:
if "existiert bereits" not in str(exc):
raise
seed_practices_for_stage(
tenant_id=tenant_id, initiative_id=initiative_id, stage=d1, user_id=user_id
)
seed_practices_for_stage(
tenant_id=tenant_id, initiative_id=initiative_id, stage=h1, user_id=user_id
)
return {
"applied": True,
"projects": ["Dehnung", "Hüftrotation"],
"join": join["title"],
"active_gates": [d1["title"], h1["title"]],
}
def _apply_b2b( def _apply_b2b(
*, *,
tenant_id: str, tenant_id: str,

View File

@ -25,12 +25,24 @@ STAGE_PRACTICE_SETS: dict[str, list[tuple[str, str]]] = {
"Stufe 3 — Ziel": [ "Stufe 3 — Ziel": [
("Voller Spagat", "Täglich Zielposition — Messwert dokumentieren"), ("Voller Spagat", "Täglich Zielposition — Messwert dokumentieren"),
], ],
"Dehnung — Einstieg": [
("Vorbeuge halten", "Täglich 3×30 Sek."),
],
"Hüftrotation — Einstieg": [
("Hüftkreisen im Stand", "Täglich 10 Wiederholungen je Richtung"),
],
"Dehnung — nach Join": [
("Enddehnung Mawashi-Vorbereitung", "Täglich 2×45 Sek."),
],
"Hüftrotation — nach Join": [
("Explosive Hüftrotation", "Täglich 3×8 Wiederholungen"),
],
} }
def get_active_maturity_stage( def get_active_maturity_stages(
*, tenant_id: str, initiative_id: str *, tenant_id: str, initiative_id: str
) -> Optional[dict[str, Any]]: ) -> list[dict[str, Any]]:
from services.roadmap import list_roadmap_items_for_initiative from services.roadmap import list_roadmap_items_for_initiative
stages = [ stages = [
@ -40,34 +52,17 @@ def get_active_maturity_stage(
) )
if item.get("item_type") == "maturity_stage" and item.get("status") == "active" if item.get("item_type") == "maturity_stage" and item.get("status") == "active"
] ]
if not stages:
return None
stages.sort(key=lambda s: (s.get("sort_order", 0), s.get("title", ""))) stages.sort(key=lambda s: (s.get("sort_order", 0), s.get("title", "")))
return stages[0] return stages
def enforce_single_active_maturity_stage( def get_active_maturity_stage(
cur, *, tenant_id: str, initiative_id: str
*, ) -> Optional[dict[str, Any]]:
tenant_id: str, stages = get_active_maturity_stages(
initiative_id: str, tenant_id=tenant_id, initiative_id=initiative_id
keep_active_id: str,
) -> None:
"""At most one maturity_stage may be active."""
cur.execute(
"""
UPDATE roadmap_items ri
SET status = 'planned', updated_at = NOW()
FROM roadmaps r
WHERE ri.roadmap_id = r.id
AND ri.tenant_id = %s
AND r.initiative_id = %s
AND ri.item_type = 'maturity_stage'
AND ri.status IN ('active', 'at_risk')
AND ri.id != %s
""",
(tenant_id, initiative_id, keep_active_id),
) )
return stages[0] if stages else None
def list_practices_for_stage( def list_practices_for_stage(
@ -107,6 +102,34 @@ def pause_all_practices(
return paused return paused
def pause_practices_except_stage(
*,
tenant_id: str,
initiative_id: str,
keep_stage_id: str,
strand_project_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> list[str]:
paused: list[str] = []
for item in list_recurring_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id
):
if item.get("status") != "active":
continue
if item.get("roadmap_item_id") == keep_stage_id:
continue
if strand_project_id is not None and item.get("project_id") != strand_project_id:
continue
update_recurring_element(
tenant_id=tenant_id,
recurring_id=item["id"],
status="paused",
user_id=user_id,
)
paused.append(item["title"])
return paused
def seed_practices_for_stage( def seed_practices_for_stage(
*, *,
tenant_id: str, tenant_id: str,
@ -117,6 +140,7 @@ def seed_practices_for_stage(
"""Idempotent: create missing exercises for a stage; activate existing.""" """Idempotent: create missing exercises for a stage; activate existing."""
stage_id = stage["id"] stage_id = stage["id"]
stage_title = stage.get("title") or "" stage_title = stage.get("title") or ""
strand_project_id = stage.get("strand_project_id")
defs = STAGE_PRACTICE_SETS.get(stage_title) defs = STAGE_PRACTICE_SETS.get(stage_title)
if not defs: if not defs:
defs = [ defs = [
@ -161,6 +185,7 @@ def seed_practices_for_stage(
interval_days=1, interval_days=1,
next_due_at=now, next_due_at=now,
roadmap_item_id=stage_id, roadmap_item_id=stage_id,
project_id=strand_project_id,
user_id=user_id, user_id=user_id,
) )
created.append(row) created.append(row)
@ -175,6 +200,16 @@ def activate_stage_practice_set(
stage: dict[str, Any], stage: dict[str, Any],
user_id: Optional[str] = None, user_id: Optional[str] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
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( paused = pause_all_practices(
tenant_id=tenant_id, initiative_id=initiative_id, user_id=user_id tenant_id=tenant_id, initiative_id=initiative_id, user_id=user_id
) )

View File

@ -1,12 +1,9 @@
"""AP2.0e — Recurring transition when a maturity_stage is reached.""" """AP2.0e — maturity stage transition on verify reached (delegates to progression_graph)."""
from __future__ import annotations from __future__ import annotations
from typing import Any, Optional from typing import Any, Optional
from services.maturity_practice import activate_stage_practice_set
from services.roadmap import get_roadmap_item, list_roadmap_items_for_initiative
def on_maturity_stage_reached( def on_maturity_stage_reached(
*, *,
@ -15,62 +12,15 @@ def on_maturity_stage_reached(
reached_item_id: str, reached_item_id: str,
user_id: Optional[str] = None, user_id: Optional[str] = None,
) -> Optional[dict[str, Any]]: ) -> Optional[dict[str, Any]]:
"""Pause old routines, activate next stage, seed exercise set for next stage.""" from services.progression_graph import on_work_gate_reached
item = get_roadmap_item(tenant_id=tenant_id, item_id=reached_item_id)
if not item or item.get("item_type") != "maturity_stage":
return None
stages = [ return on_work_gate_reached(
s
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,
)
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, tenant_id=tenant_id,
initiative_id=initiative_id, initiative_id=initiative_id,
stage=next_stage, reached_item_id=reached_item_id,
user_id=user_id, user_id=user_id,
) )
return {
"reached_stage_id": reached_item_id,
"next_stage_id": next_stage["id"] if next_stage else None,
"paused_recurring": (practice_transition or {}).get("paused_practices", []),
"new_recurring_id": (
(practice_transition or {}).get("practice_ids") or [None]
)[0],
"active_practices": (practice_transition or {}).get("active_practices", []),
}
def on_maturity_stage_reopened( def on_maturity_stage_reopened(
*, *,
@ -79,7 +29,10 @@ def on_maturity_stage_reopened(
stage_id: str, stage_id: str,
user_id: Optional[str] = None, user_id: Optional[str] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Re-enable exercise set for reopened stage; pause others.""" """Re-enable exercise set for reopened stage; pause others on same strand."""
from services.maturity_practice import activate_stage_practice_set
from services.roadmap import get_roadmap_item
stage = get_roadmap_item(tenant_id=tenant_id, item_id=stage_id) stage = get_roadmap_item(tenant_id=tenant_id, item_id=stage_id)
if not stage: if not stage:
return {"stage_id": stage_id, "active_practices": []} return {"stage_id": stage_id, "active_practices": []}

View 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,
}

View File

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime from datetime import datetime, timezone
from typing import Any, Literal, Optional from typing import Any, Literal, Optional
from psycopg2.extras import RealDictCursor from psycopg2.extras import RealDictCursor
@ -16,14 +16,14 @@ RecurringStatus = Literal["active", "paused", "ended"]
RECURRING_STATUSES = frozenset({"active", "paused", "ended"}) RECURRING_STATUSES = frozenset({"active", "paused", "ended"})
_RECURRING_COLUMNS = """ _RECURRING_COLUMNS = """
id, tenant_id, initiative_id, roadmap_item_id, title, description, status, id, tenant_id, initiative_id, roadmap_item_id, project_id, title, description, status,
interval_days, next_due_at, created_at, updated_at interval_days, next_due_at, created_at, updated_at
""" """
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
result = dict(row) result = dict(row)
for key in ("id", "tenant_id", "initiative_id", "roadmap_item_id"): for key in ("id", "tenant_id", "initiative_id", "roadmap_item_id", "project_id"):
if result.get(key): if result.get(key):
result[key] = str(result[key]) result[key] = str(result[key])
for ts_key in ("created_at", "updated_at", "next_due_at"): for ts_key in ("created_at", "updated_at", "next_due_at"):
@ -47,6 +47,7 @@ def create_recurring_element(
interval_days: Optional[int] = None, interval_days: Optional[int] = None,
next_due_at: Optional[datetime] = None, next_due_at: Optional[datetime] = None,
roadmap_item_id: Optional[str] = None, roadmap_item_id: Optional[str] = None,
project_id: Optional[str] = None,
user_id: Optional[str] = None, user_id: Optional[str] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
title = title.strip() title = title.strip()
@ -64,16 +65,17 @@ def create_recurring_element(
cur.execute( cur.execute(
f""" f"""
INSERT INTO recurring_elements ( INSERT INTO recurring_elements (
tenant_id, initiative_id, roadmap_item_id, title, description, status, tenant_id, initiative_id, roadmap_item_id, project_id, title, description, status,
interval_days, next_due_at interval_days, next_due_at
) )
VALUES (%s, %s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING {_RECURRING_COLUMNS} RETURNING {_RECURRING_COLUMNS}
""", """,
( (
tenant_id, tenant_id,
initiative_id, initiative_id,
roadmap_item_id, roadmap_item_id,
project_id,
title, title,
description, description,
status, status,
@ -166,6 +168,8 @@ def update_recurring_element(
clear_next_due_at: bool = False, clear_next_due_at: bool = False,
roadmap_item_id: Optional[str] = None, roadmap_item_id: Optional[str] = None,
clear_roadmap_item_id: bool = False, clear_roadmap_item_id: bool = False,
project_id: Optional[str] = None,
clear_project_id: bool = False,
) -> Optional[dict[str, Any]]: ) -> Optional[dict[str, Any]]:
existing = get_recurring_element(tenant_id=tenant_id, recurring_id=recurring_id) existing = get_recurring_element(tenant_id=tenant_id, recurring_id=recurring_id)
if not existing: if not existing:
@ -205,6 +209,11 @@ def update_recurring_element(
elif roadmap_item_id is not None: elif roadmap_item_id is not None:
updates.append("roadmap_item_id = %s") updates.append("roadmap_item_id = %s")
params.append(roadmap_item_id) params.append(roadmap_item_id)
if clear_project_id:
updates.append("project_id = NULL")
elif project_id is not None:
updates.append("project_id = %s")
params.append(project_id)
if not updates: if not updates:
return existing return existing

View File

@ -13,14 +13,14 @@ from services.audit import log_audit
from services.initiatives import get_initiative from services.initiatives import get_initiative
from services.roadmap_criteria import create_default_criterion, prepare_criteria_for_verify from services.roadmap_criteria import create_default_criterion, prepare_criteria_for_verify
RoadmapItemType = Literal["milestone", "review_gate", "maturity_stage", "work_cycle"] RoadmapItemType = Literal["milestone", "review_gate", "maturity_stage", "work_cycle", "join_gate"]
RoadmapItemStatus = Literal[ RoadmapItemStatus = Literal[
"planned", "active", "at_risk", "reached", "moved", "discarded" "planned", "active", "at_risk", "reached", "moved", "discarded"
] ]
SequencingMode = Literal["sequential", "parallel", "optional"] SequencingMode = Literal["sequential", "parallel", "optional"]
DependencyType = Literal["requires", "blocks", "related", "parallel_group", "optional_branch"] DependencyType = Literal["requires", "blocks", "related", "parallel_group", "optional_branch"]
ROADMAP_ITEM_TYPES = frozenset({"milestone", "review_gate", "maturity_stage", "work_cycle"}) ROADMAP_ITEM_TYPES = frozenset({"milestone", "review_gate", "maturity_stage", "work_cycle", "join_gate"})
ROADMAP_ITEM_STATUSES = frozenset( ROADMAP_ITEM_STATUSES = frozenset(
{"planned", "active", "at_risk", "reached", "moved", "discarded"} {"planned", "active", "at_risk", "reached", "moved", "discarded"}
) )
@ -32,7 +32,7 @@ TERMINAL_STATUSES = frozenset({"reached", "moved", "discarded"})
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
result = dict(row) result = dict(row)
for key in ("id", "tenant_id", "roadmap_id", "initiative_id", "from_item_id", "to_item_id"): for key in ("id", "tenant_id", "roadmap_id", "initiative_id", "from_item_id", "to_item_id", "strand_project_id"):
if result.get(key): if result.get(key):
result[key] = str(result[key]) result[key] = str(result[key])
if result.get("target_date"): if result.get("target_date"):
@ -185,6 +185,7 @@ def list_roadmap_items_for_initiative(
ri.id, ri.tenant_id, ri.roadmap_id, r.initiative_id, ri.id, ri.tenant_id, ri.roadmap_id, r.initiative_id,
ri.item_type, ri.title, ri.goal_description, ri.definition_of_done, ri.item_type, ri.title, ri.goal_description, ri.definition_of_done,
ri.status, ri.sequencing_mode, ri.target_date, ri.sort_order, ri.status, ri.sequencing_mode, ri.target_date, ri.sort_order,
ri.strand_project_id,
ri.created_at, ri.updated_at ri.created_at, ri.updated_at
FROM roadmap_items ri FROM roadmap_items ri
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
@ -208,6 +209,7 @@ def get_roadmap_item(*, tenant_id: str, item_id: str) -> Optional[dict[str, Any]
ri.id, ri.tenant_id, ri.roadmap_id, r.initiative_id, ri.id, ri.tenant_id, ri.roadmap_id, r.initiative_id,
ri.item_type, ri.title, ri.goal_description, ri.definition_of_done, ri.item_type, ri.title, ri.goal_description, ri.definition_of_done,
ri.status, ri.sequencing_mode, ri.target_date, ri.sort_order, ri.status, ri.sequencing_mode, ri.target_date, ri.sort_order,
ri.strand_project_id,
ri.created_at, ri.updated_at ri.created_at, ri.updated_at
FROM roadmap_items ri FROM roadmap_items ri
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
@ -233,6 +235,7 @@ def create_roadmap_item(
sequencing_mode: SequencingMode = "sequential", sequencing_mode: SequencingMode = "sequential",
target_date: Optional[date] = None, target_date: Optional[date] = None,
sort_order: int = 0, sort_order: int = 0,
strand_project_id: Optional[str] = None,
initial_criterion_title: Optional[str] = None, initial_criterion_title: Optional[str] = None,
user_id: Optional[str] = None, user_id: Optional[str] = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
@ -255,12 +258,13 @@ def create_roadmap_item(
""" """
INSERT INTO roadmap_items ( INSERT INTO roadmap_items (
tenant_id, roadmap_id, item_type, title, goal_description, tenant_id, roadmap_id, item_type, title, goal_description,
definition_of_done, status, sequencing_mode, target_date, sort_order definition_of_done, status, sequencing_mode, target_date, sort_order,
strand_project_id
) )
VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s, %s, %s, %s, %s)
RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description, RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description,
definition_of_done, status, sequencing_mode, target_date, definition_of_done, status, sequencing_mode, target_date,
sort_order, created_at, updated_at sort_order, strand_project_id, created_at, updated_at
""", """,
( (
tenant_id, tenant_id,
@ -273,11 +277,12 @@ def create_roadmap_item(
sequencing_mode, sequencing_mode,
target_date, target_date,
sort_order, sort_order,
strand_project_id,
), ),
) )
row = _serialize_row(dict(cur.fetchone())) row = _serialize_row(dict(cur.fetchone()))
row["initiative_id"] = initiative_id row["initiative_id"] = initiative_id
if item_type != "work_cycle": if item_type not in ("work_cycle", "join_gate"):
create_default_criterion( create_default_criterion(
cur, cur,
tenant_id=tenant_id, tenant_id=tenant_id,
@ -324,6 +329,8 @@ def update_roadmap_item(
target_date: Optional[date] = None, target_date: Optional[date] = None,
clear_target_date: bool = False, clear_target_date: bool = False,
sort_order: Optional[int] = None, sort_order: Optional[int] = None,
strand_project_id: Optional[str] = None,
clear_strand_project: bool = False,
) -> Optional[dict[str, Any]]: ) -> Optional[dict[str, Any]]:
existing = get_roadmap_item(tenant_id=tenant_id, item_id=item_id) existing = get_roadmap_item(tenant_id=tenant_id, item_id=item_id)
if not existing: if not existing:
@ -373,6 +380,11 @@ def update_roadmap_item(
if sort_order is not None: if sort_order is not None:
updates.append("sort_order = %s") updates.append("sort_order = %s")
params.append(sort_order) params.append(sort_order)
if clear_strand_project:
updates.append("strand_project_id = NULL")
elif strand_project_id is not None:
updates.append("strand_project_id = %s")
params.append(strand_project_id)
if not updates: if not updates:
return existing return existing
@ -383,14 +395,22 @@ def update_roadmap_item(
conn = get_connection() conn = get_connection()
try: try:
with conn.cursor(cursor_factory=RealDictCursor) as cur: with conn.cursor(cursor_factory=RealDictCursor) as cur:
if status == "active" and existing.get("item_type") == "maturity_stage": if (
from services.maturity_practice import enforce_single_active_maturity_stage status == "active"
and existing.get("item_type") == "maturity_stage"
):
from services.progression_graph import (
enforce_single_active_work_gate_per_strand,
)
enforce_single_active_maturity_stage( enforce_single_active_work_gate_per_strand(
cur, cur,
tenant_id=tenant_id, tenant_id=tenant_id,
initiative_id=str(existing["initiative_id"]), initiative_id=str(existing["initiative_id"]),
keep_active_id=item_id, keep_active_id=item_id,
strand_project_id=strand_project_id
if strand_project_id is not None
else existing.get("strand_project_id"),
) )
cur.execute( cur.execute(
f""" f"""
@ -399,7 +419,7 @@ def update_roadmap_item(
WHERE id = %s AND tenant_id = %s WHERE id = %s AND tenant_id = %s
RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description, RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description,
definition_of_done, status, sequencing_mode, target_date, definition_of_done, status, sequencing_mode, target_date,
sort_order, created_at, updated_at sort_order, strand_project_id, created_at, updated_at
""", """,
params, params,
) )
@ -652,6 +672,10 @@ def verify_reached(
raise ValueError("RoadmapItem nicht gefunden") raise ValueError("RoadmapItem nicht gefunden")
if item["status"] in TERMINAL_STATUSES: if item["status"] in TERMINAL_STATUSES:
raise ValueError(f"RoadmapItem bereits terminal: {item['status']}") raise ValueError(f"RoadmapItem bereits terminal: {item['status']}")
if item.get("item_type") == "join_gate":
raise ValueError(
"Join-Knoten schaltet automatisch, wenn alle Eingänge erreicht sind — kein manuelles Verify"
)
initiative_id = item["initiative_id"] initiative_id = item["initiative_id"]
conn = get_connection() conn = get_connection()
@ -694,7 +718,7 @@ def verify_reached(
WHERE id = %s AND tenant_id = %s WHERE id = %s AND tenant_id = %s
RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description, RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description,
definition_of_done, status, sequencing_mode, target_date, definition_of_done, status, sequencing_mode, target_date,
sort_order, created_at, updated_at sort_order, strand_project_id, created_at, updated_at
""", """,
(item_id, tenant_id), (item_id, tenant_id),
) )
@ -718,9 +742,9 @@ def verify_reached(
details={"roadmap_item_id": item_id, "verify_reason": verify_reason}, details={"roadmap_item_id": item_id, "verify_reason": verify_reason},
) )
if row.get("item_type") == "maturity_stage": if row.get("item_type") == "maturity_stage":
from services.maturity_stage_transition import on_maturity_stage_reached from services.progression_graph import on_work_gate_reached
transition = on_maturity_stage_reached( transition = on_work_gate_reached(
tenant_id=tenant_id, tenant_id=tenant_id,
initiative_id=initiative_id, initiative_id=initiative_id,
reached_item_id=item_id, reached_item_id=item_id,
@ -770,13 +794,16 @@ def reopen_roadmap_item(
try: try:
with conn.cursor(cursor_factory=RealDictCursor) as cur: with conn.cursor(cursor_factory=RealDictCursor) as cur:
if item.get("item_type") == "maturity_stage": if item.get("item_type") == "maturity_stage":
from services.maturity_practice import enforce_single_active_maturity_stage from services.progression_graph import (
enforce_single_active_work_gate_per_strand,
)
enforce_single_active_maturity_stage( enforce_single_active_work_gate_per_strand(
cur, cur,
tenant_id=tenant_id, tenant_id=tenant_id,
initiative_id=str(item["initiative_id"]), initiative_id=str(item["initiative_id"]),
keep_active_id=item_id, keep_active_id=item_id,
strand_project_id=item.get("strand_project_id"),
) )
cur.execute( cur.execute(
""" """
@ -785,7 +812,7 @@ def reopen_roadmap_item(
WHERE id = %s AND tenant_id = %s WHERE id = %s AND tenant_id = %s
RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description, RETURNING id, tenant_id, roadmap_id, item_type, title, goal_description,
definition_of_done, status, sequencing_mode, target_date, definition_of_done, status, sequencing_mode, target_date,
sort_order, created_at, updated_at sort_order, strand_project_id, created_at, updated_at
""", """,
(item_id, tenant_id), (item_id, tenant_id),
) )

View File

@ -74,13 +74,14 @@ def _reopen_item_if_reached(
if item["status"] != "reached": if item["status"] != "reached":
return return
if item.get("item_type") == "maturity_stage": if item.get("item_type") == "maturity_stage":
from services.maturity_practice import enforce_single_active_maturity_stage from services.progression_graph import enforce_single_active_work_gate_per_strand
enforce_single_active_maturity_stage( enforce_single_active_work_gate_per_strand(
cur, cur,
tenant_id=tenant_id, tenant_id=tenant_id,
initiative_id=str(item["initiative_id"]), initiative_id=str(item["initiative_id"]),
keep_active_id=item["id"], keep_active_id=item["id"],
strand_project_id=item.get("strand_project_id"),
) )
cur.execute( cur.execute(
""" """

View File

@ -7,7 +7,6 @@ from typing import Any
from steering.signals import default_rules from steering.signals import default_rules
from steering.strategies.next_action.default_strategy import DefaultNextActionStrategy from steering.strategies.next_action.default_strategy import DefaultNextActionStrategy
from steering.strategies.next_action.execution_ready import ( from steering.strategies.next_action.execution_ready import (
first_active_gate_id,
list_execution_ready_candidates, list_execution_ready_candidates,
merge_candidates, merge_candidates,
) )
@ -43,7 +42,12 @@ class MaturityProgressionStrategy:
recurring = recurring_due_candidates(ctx, initiative_id, limit=limit) recurring = recurring_due_candidates(ctx, initiative_id, limit=limit)
remaining = limit - len(recurring) remaining = limit - len(recurring)
gate_scope = first_active_gate_id(ctx, initiative_id) from services.progression_graph import list_active_work_gates
active_gates = list_active_work_gates(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
gate_scope = active_gates[0]["id"] if len(active_gates) == 1 else None
ready: list[dict[str, Any]] = [] ready: list[dict[str, Any]] = []
if remaining > 0: if remaining > 0:
ready = list_execution_ready_candidates( ready = list_execution_ready_candidates(

View 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

View File

@ -44,7 +44,10 @@ def test_maturity_stage_reached_rotates_recurring(client):
assert verify.status_code == 200 assert verify.status_code == 200
body = verify.json() body = verify.json()
assert body["status"] == "reached" assert body["status"] == "reached"
assert body.get("maturity_transition", {}).get("new_recurring_id") transition = body.get("maturity_transition") or {}
legacy = transition.get("legacy_linear") or {}
practice_ids = (legacy.get("practice_transition") or {}).get("practice_ids") or []
assert practice_ids or transition.get("successors_activated") or transition.get("joins_switched")
recurring = client.get( recurring = client.get(
f"/api/initiatives/{initiative_id}/recurring", f"/api/initiatives/{initiative_id}/recurring",

View File

@ -2,14 +2,14 @@
**Prozess:** `../M1_Hybrid_Path_and_Depth_D_v0.1.md` **Prozess:** `../M1_Hybrid_Path_and_Depth_D_v0.1.md`
**Methodenkern (Freeze):** [`../Kairo_Steering_Method_Kernel_v0.1.md`](../Kairo_Steering_Method_Kernel_v0.1.md) — Methodentypen: Kernel §6 **Methodenkern (Freeze):** [`../Kairo_Steering_Method_Kernel_v0.1.md`](../Kairo_Steering_Method_Kernel_v0.1.md) — Methodentypen: Kernel §6
**PO 2026-07-26:** Architektur beibehalten; Spec-D-Batch **vorläufig freigegeben** (Implementierungsausreichendheit noch offen) **PO 2026-07-28:** Spec-D `maturity_progression`**Horizon/Progression PO-Lock** in [`SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](../../product/archetypes/SPEC_A1_Progression_Model_PO_Lock_v0.1.md); Spec-D D3/D6/D8 angepasst.
| Methode | Typ | Datei | Status | | Methode | Typ | Datei | Status |
|---------|-----|-------|--------| |---------|-----|-------|--------|
| `sequential_dependency` | Primary (Ausführung) | SPEC_D_sequential_dependency_v0.1.md | vorläufig freigegeben| | `sequential_dependency` | Primary (Ausführung) | SPEC_D_sequential_dependency_v0.1.md | vorläufig freigegeben|
| `continuous_product` | Primary (Ausführung) | SPEC_D_continuous_product_v0.1.md | vorläufig freigegeben| | `continuous_product` | Primary (Ausführung) | SPEC_D_continuous_product_v0.1.md | vorläufig freigegeben|
| `recurring_control` | Primary (Ausführung) | SPEC_D_recurring_control_v0.1.md | vorläufig freigegeben| | `recurring_control` | Primary (Ausführung) | SPEC_D_recurring_control_v0.1.md | vorläufig freigegeben|
| `maturity_progression` | Primary (Ausführung) | SPEC_D_maturity_progression_v0.1.md | vorläufig freigegeben| | `maturity_progression` | Primary (Ausführung) | SPEC_D_maturity_progression_v0.1.md | PO-Lock Horizon (2026-07-28) |
| `checklist_flow` | Primary (Ausführung) | SPEC_D_checklist_flow_v0.1.md | vorläufig freigegeben| | `checklist_flow` | Primary (Ausführung) | SPEC_D_checklist_flow_v0.1.md | vorläufig freigegeben|
| `care_navigation` | Primary (Ausführung) | SPEC_D_care_navigation_v0.1.md | vorläufig freigegeben| | `care_navigation` | Primary (Ausführung) | SPEC_D_care_navigation_v0.1.md | vorläufig freigegeben|
| `dispute_procedure` | Primary (Ausführung) | SPEC_D_dispute_procedure_v0.1.md | vorläufig freigegeben| | `dispute_procedure` | Primary (Ausführung) | SPEC_D_dispute_procedure_v0.1.md | vorläufig freigegeben|

View File

@ -2,7 +2,7 @@
## Steuerungsmethode — Spec-Tiefe D v0.1 ## Steuerungsmethode — Spec-Tiefe D v0.1
**Status:** vorläufig freigegeben (PO 2026-07-26) — mit zugehöriger Archetyp-Vollspec; Implementierungsausreichendheit noch offen **Status:** vorläufig freigegeben (PO 2026-07-26) — mit zugehöriger Archetyp-Vollspec; Implementierungsausreichendheit noch offen
**Stand:** 2026-07-26 **Stand:** 2026-07-28 (PO-Lock Progressionsmodell — [`SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](../../product/archetypes/SPEC_A1_Progression_Model_PO_Lock_v0.1.md))
**Default für:** A1 `initiative.maturity_journey` **Default für:** A1 `initiative.maturity_journey`
**Prozess:** `M1_Hybrid_Path_and_Depth_D_v0.1.md` **Prozess:** `M1_Hybrid_Path_and_Depth_D_v0.1.md`
**Quellen:** Decision-Lock A1, Archetyp-Vollspec A1, Kernel Freeze, AP2.0e **Quellen:** Decision-Lock A1, Archetyp-Vollspec A1, Kernel Freeze, AP2.0e
@ -17,7 +17,7 @@
| `method_key` | `maturity_progression` | | `method_key` | `maturity_progression` |
| Rolle | primary (**Ausführung** — Kernel §6) | | Rolle | primary (**Ausführung** — Kernel §6) |
| Label (DE) | Reifegrad-Progression | | Label (DE) | Reifegrad-Progression |
| Zweck | Fähigkeit über Stufen steuern: Active Stage als Horizont; Next = fällige/alternierende Übung oder Stage-Action; bei Stage `reached` Activity Set wechseln. Weiches Vorhaben-Ende. | | Zweck | Fähigkeit über **parallele Strang-Pfade** (Gate-Graph) steuern; Next = fällige Übungen auf **aktiven Gates**; bei Gate `reached` strangspezifischer Activity-Set-Wechsel. Weiches Vorhaben-Ende. |
--- ---
@ -34,20 +34,26 @@
## D3 Horizon ## D3 Horizon
**PO-Lock (2026-07-28):** [`SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](../../product/archetypes/SPEC_A1_Progression_Model_PO_Lock_v0.1.md) §3 — ersetzt „eine aktive Stufe initiative-weit“ für Multi-Strang.
| Horizont | Bedeutung | | Horizont | Bedeutung |
|----------|-----------| |----------|-----------|
| **Aktive `maturity_stage`** | fokussierte Stufe; nur deren Stage Activity Set liefert Next | | **Aktive Gates (Menge)** | Alle `maturity_stage`/Gate-Knoten mit Status `active`, die graph-ready sind; **mehrere parallel** (je Strang max. ein aktives Gate auf dem Pfad) |
| Plan-Nebenblick | nächste Stufe / Graph-Voraussetzungen — nicht Leading für Next | | **Strang** | `Project` — paralleler Fähigkeits-Pfad; Übungen mit `project_id` |
| Ist | CadenceInstances + Stage-Actions der aktiven Stufe | | **Join-Gate** | wird `active`, wenn alle `requires`-Vorgänger `reached` |
| Plan-Nebenblick | blockierte / geplante Gates — nicht Leading für Next |
| Ist | CadenceInstances + Stage-Actions der **aktiven Gates** |
Keine Next-Kandidaten aus inaktiven Stufen. Keine Next-Kandidaten aus Gates, die nicht `active`/graph-ready sind.
**Lineares Demo-Kit (1 Strang, Stufe 13):** Teilmenge mit effektiv einem aktiven Gate — kein PO-Kernbild.
--- ---
## D4 Leading Work Item ## D4 Leading Work Item
**Primär:** `CadenceInstance` (Übung/Ritual am RecurringElement der aktiven Stufe). **Primär:** `CadenceInstance` (Übung am `RecurringElement` eines **aktiven Gates**, optional Strang via `project_id`).
**Sekundär:** `Action` (Stage-gebunden: Prüfung, Workshop, Nachweis). **Sekundär:** `Action` (Gate-gebunden: Prüfung, Workshop — selten bei A1).
**Hinweis-Objekt (kein Leading Work):** offenes Stufen-Kriterium / Verify-Fortschritt — erscheint in Next/Attention als Steuerhinweis. **Hinweis-Objekt (kein Leading Work):** offenes Stufen-Kriterium / Verify-Fortschritt — erscheint in Next/Attention als Steuerhinweis.
Pro Recurring: genau **eine** offene Instanz (kein Duplikat bei Verpassen). Pro Recurring: genau **eine** offene Instanz (kein Duplikat bei Verpassen).
@ -77,12 +83,13 @@ Kernel-Schlitznamen: Abbildung auf §4 Kernel (Intake … Closure).
## D6 Next Action — konkrete Regeln ## D6 Next Action — konkrete Regeln
### Ready-Menge (aktive Stufe) ### Ready-Menge (aktive Gates)
Ein Work Item ist ready, wenn: Ein Work Item ist ready, wenn:
1. gebunden an die **aktive** `maturity_stage` und Aktivität **aktiv**, und 1. gebunden an ein **aktives**, graph-ready **Gate** und Aktivität **aktiv**, und
2. eines von: 2. (Multi-Strang) `project_id` passt zum Strang des Gates, und
3. eines von:
- **Cadence ohne Alternanz:** Instanz fällig oder überfällig (heute laut Cadence / overdue), oder - **Cadence ohne Alternanz:** Instanz fällig oder überfällig (heute laut Cadence / overdue), oder
- **Cadence in Alternanz-Gruppe:** Instanz ist das **aktuelle** Gruppenmitglied **und** fällig/überfällig (bzw. „dran“ laut Gruppenpolicy, wenn Cadence der Gruppe geteilt ist), oder - **Cadence in Alternanz-Gruppe:** Instanz ist das **aktuelle** Gruppenmitglied **und** fällig/überfällig (bzw. „dran“ laut Gruppenpolicy, wenn Cadence der Gruppe geteilt ist), oder
- **Stage-Action:** Status offen und Deps/Blocker erlauben Arbeit - **Stage-Action:** Status offen und Deps/Blocker erlauben Arbeit
@ -141,7 +148,7 @@ Unabhängige Cadences am gleichen Stage → mehrere ready am selben Tag möglich
| practice done / Cadence erfüllt | Instanz schließen; nächste Instanz laut Cadence; bei Alternanz Rotation | | practice done / Cadence erfüllt | Instanz schließen; nächste Instanz laut Cadence; bei Alternanz Rotation |
| Stage-Action done | aus Ready; ggf. Kriterien-Fortschritt | | Stage-Action done | aus Ready; ggf. Kriterien-Fortschritt |
| Evidence / Kriterium erfüllt | Validation-Fortschritt | | Evidence / Kriterium erfüllt | Validation-Fortschritt |
| stage `reached` (Verify ok) | altes Activity Set deaktivieren; nächste Stufe aktiv; neues Set aktiv (AP2.0e erweitert) | | stage `reached` (Verify ok) | Activity Set **dieses Gates** deaktivieren; **pro Strang** nächstes Gate / neues Set (Join: strangspezifisch) |
| manuelle Adaptation (Set/Cadence/Alternanz) | Ready-Menge neu; auditiert | | manuelle Adaptation (Set/Cadence/Alternanz) | Ready-Menge neu; auditiert |
| Cadence-Zeitpunkt erreicht | Event-Schub → Ready neu bewerten | | Cadence-Zeitpunkt erreicht | Event-Schub → Ready neu bewerten |
@ -175,7 +182,7 @@ Felder `alternation_group_id`, `alternation_order`: Archetyp-Vollspec A1 §10.
|-----|------| |-----|------|
| next_work_strategy | `maturity_progression` | | next_work_strategy | `maturity_progression` |
| attention_rules | Codes D7 | | attention_rules | Codes D7 |
| horizon_resolver | aktive `maturity_stage` | | horizon_resolver | Menge aktiver, graph-ready Gates (Multi-Strang) |
| dod_policy | Stage-Kriterien / Verify | | dod_policy | Stage-Kriterien / Verify |
| adaptation_hooks | `on_stage_reached` → Set-Wechsel; manuelle Set-Änderung | | adaptation_hooks | `on_stage_reached` → Set-Wechsel; manuelle Set-Änderung |
| steering_elements | `next_action_primary`, `maturity_stage`, `recurring_rhythm` | | steering_elements | `next_action_primary`, `maturity_stage`, `recurring_rhythm` |
@ -191,6 +198,7 @@ Felder `alternation_group_id`, `alternation_order`: Archetyp-Vollspec A1 §10.
| Hybrid Dominanz | Cadence-Event + Stage-Plan — Kernel trägt Dual-Auslöser | | Hybrid Dominanz | Cadence-Event + Stage-Plan — Kernel trägt Dual-Auslöser |
| Leading polymorph | Cadence + Action — Kernel Q2 | | Leading polymorph | Cadence + Action — Kernel Q2 |
| Alternanz-State | Persistenz am Group-/Recurring-Kontext — Implementierungsdetail, fachlich hier verbindlich | | Alternanz-State | Persistenz am Group-/Recurring-Kontext — Implementierungsdetail, fachlich hier verbindlich |
| Multi-Fähigkeit | mehrere Projects ok; kein Abnahme-Zwang für Kern-Kit | | Multi-Fähigkeit | mehrere Projects / Stränge — **Kernbild A1** (PO-Lock); Gate-Graph verbindet Pfade |
| Single-active initiative-wide | **widerrufen** für Multi-Strang — nur Demo-Kit |
**PO-Empfehlung:** A1-Vollspec §4 OM + diese Spec-D = Steuervertrag; UI nur in Archetyp-Vollspec. **PO-Empfehlung:** A1-Vollspec §4 OM + diese Spec-D = Steuervertrag; UI nur in Archetyp-Vollspec.

View File

@ -7,12 +7,12 @@
**UI-Navigation (Archetyp):** [`ARCHETYPE_UI_NAVIGATION_v0.1.md`](ARCHETYPE_UI_NAVIGATION_v0.1.md) **UI-Navigation (Archetyp):** [`ARCHETYPE_UI_NAVIGATION_v0.1.md`](ARCHETYPE_UI_NAVIGATION_v0.1.md)
**Backlog / Epic / Dual Commit:** [`../../architecture/ADP_Backlog_Hierarchy_and_Dual_Commit_Path_v0.1.md`](../../architecture/ADP_Backlog_Hierarchy_and_Dual_Commit_Path_v0.1.md) **Backlog / Epic / Dual Commit:** [`../../architecture/ADP_Backlog_Hierarchy_and_Dual_Commit_Path_v0.1.md`](../../architecture/ADP_Backlog_Hierarchy_and_Dual_Commit_Path_v0.1.md)
**PO 2026-07-26:** Specs **vorläufig freigegeben** — Implementierungsausreichendheit noch nicht abschließend bewertet. **PO 2026-07-28:** A1 **PO-Lock Progressionsmodell** — [`SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](SPEC_A1_Progression_Model_PO_Lock_v0.1.md) (Option C nach Validation No-Go).
| ID | Datei | Status | | ID | Datei | Status |
|----|-------|--------| |----|-------|--------|
| C1 | [SPEC_C1_dispute_case_v0.1.md](SPEC_C1_dispute_case_v0.1.md) | vorläufig freigegeben | | C1 | [SPEC_C1_dispute_case_v0.1.md](SPEC_C1_dispute_case_v0.1.md) | vorläufig freigegeben |
| A1 | [SPEC_A1_maturity_journey_v0.1.md](SPEC_A1_maturity_journey_v0.1.md) | vorläufig freigegeben | | A1 | [SPEC_A1_maturity_journey_v0.1.md](SPEC_A1_maturity_journey_v0.1.md) | PO-Lock Progression (2026-07-28) |
| A2 | [SPEC_A2_linear_project_v0.1.md](SPEC_A2_linear_project_v0.1.md) | vorläufig freigegeben | | A2 | [SPEC_A2_linear_project_v0.1.md](SPEC_A2_linear_project_v0.1.md) | vorläufig freigegeben |
| A3 | [SPEC_A3_recurring_program_v0.1.md](SPEC_A3_recurring_program_v0.1.md) | vorläufig freigegeben | | A3 | [SPEC_A3_recurring_program_v0.1.md](SPEC_A3_recurring_program_v0.1.md) | vorläufig freigegeben |
| B1 | [SPEC_B1_checklists_v0.1.md](SPEC_B1_checklists_v0.1.md) | vorläufig freigegeben | | B1 | [SPEC_B1_checklists_v0.1.md](SPEC_B1_checklists_v0.1.md) | vorläufig freigegeben |

View File

@ -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 123“).
### 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.*

View File

@ -2,8 +2,8 @@
## Archetyp-Vollspec (implementierbar) v0.1 ## Archetyp-Vollspec (implementierbar) v0.1
**Status:** vorläufig freigegeben (PO 2026-07-26) — Zielbild; Implementierungsausreichendheit noch nicht abschließend bewertet **Status:** vorläufig freigegeben (PO 2026-07-26) — Zielbild; Implementierungsausreichendheit noch nicht abschließend bewertet
**Stand:** 2026-07-26 **Stand:** 2026-07-28 (PO-Lock Progressionsmodell — siehe §14)
**MVP-Stufe:** A **MVP-Stufe:** A (PO-Kernbild: Multi-Strang — §14.4)
**Decision-Lock:** A1 Reifegrad **Decision-Lock:** A1 Reifegrad
**Default-Methode:** [`SPEC_D_maturity_progression_v0.1.md`](../../architecture/methods/SPEC_D_maturity_progression_v0.1.md) **Default-Methode:** [`SPEC_D_maturity_progression_v0.1.md`](../../architecture/methods/SPEC_D_maturity_progression_v0.1.md)
**Methodenkern:** `Kairo_Steering_Method_Kernel_v0.1.md` **Methodenkern:** `Kairo_Steering_Method_Kernel_v0.1.md`
@ -52,7 +52,10 @@ Kontinuierliche Entwicklung einer Fähigkeit über **Phasen/Stufen**. Eine Phase
6. Innerhalb Mittelstufe Übung hinzufügen (Adaptation), auditiert. 6. Innerhalb Mittelstufe Übung hinzufügen (Adaptation), auditiert.
7. Optional zweites Fähigkeits-Project + Graph-Voraussetzung. 7. Optional zweites Fähigkeits-Project + Graph-Voraussetzung.
**Leitfrage:** Welche Stufe ist aktiv — und welche Übung/Aktivität ist **jetzt** dran (inkl. Alternanz/Zeitversatz)? **Leitfrage:** Welche **Strang-Gates** sind aktiv — und welche Übung ist **jetzt** auf welchem Pfad dran (inkl. Alternanz/Zeitversatz)?
**Referenzbeispiel (PO 2026-07-28) — parallele Progression „Mawashi Geri“:**
Mehrere Stränge (Projects: Dehnung, Hüftrotation, Technik), je Strang Gate-Kette im Graph, Join-Gate („sicher zur Hüfte“), Activity Set pro Gate, nach Join strangspezifischer Set-Wechsel. Vollständig: [`SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](SPEC_A1_Progression_Model_PO_Lock_v0.1.md) §2.
--- ---
@ -67,25 +70,28 @@ evidence, decisions, reviews, steering_methods
## 4. Plan- / Ist-Vertrag ## 4. Plan- / Ist-Vertrag
### 4.1 Stufen / Phasen ### 4.1 Stufen / Phasen / Stränge
| Element | Rolle | | Element | Rolle |
|---------|--------| |---------|--------|
| `maturity_stage` (RoadmapItem) | Phase/Stufe; eine **aktive** Stufe (Horizon) | | `maturity_stage` (RoadmapItem) | Gate / Reifegrad-Checkpoint auf einem **Pfad** |
| Kriterien / Evidence | Verify vor `reached` | | **Strang** | `Project` (`project.maturity_journey`) — z. B. Dehnung, Technik, Hüftrotation |
| Graph | Voraussetzungen zwischen Stufen und/oder Fähigkeits-Projects | | Kriterien / Evidence | Verify vor `reached`; inkl. `metric` |
| Project `project.maturity_journey` | Fähigkeit/Spur; eine oder mehrere | | Graph | Parallele Pfade, `requires`-Kanten, **Join-Gates** zwischen Strängen |
| **Horizon (PO-lock)** | **Mehrere** aktive Gates initiative-weit; **pro Strang** höchstens ein aktives Gate auf dem Pfad — siehe [`SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](SPEC_A1_Progression_Model_PO_Lock_v0.1.md) §3 |
> **Hinweis:** Lineares Demo-Kit (Stufe 13, ein Strang) ist **Teilmenge**, nicht das PO-Kernbild.
### 4.2 Aktivitäts-Set einer Phase (verbindlich) ### 4.2 Aktivitäts-Set einer Phase (verbindlich)
| Aktivitätstyp | Persistenz | Rolle | | Aktivitätstyp | Persistenz | Rolle |
|---------------|------------|--------| |---------------|------------|--------|
| Übung / Ritual | `RecurringElement`, gebunden an `maturity_stage` | wiederkehrend | | Übung / Ritual | `RecurringElement`, gebunden an **Gate** (`roadmap_item_id`) **und Strang** (`project_id` bei Multi-Strang) | wiederkehrend |
| Einmal-/Sonderaktivität | `Action`, gebunden an Stufe | Prüfung, Workshop, Nachweis | | Einmal-/Sonderaktivität | `Action`, gebunden an Stufe | Prüfung, Workshop, Nachweis |
- Pro Stufe: **beliebig viele** Aktivitäten; **≥1 reicht**. - Pro **Gate**: beliebig viele Übungen; **≥1** pro aktivem Gate auf einem Strang.
- Pro Recurring: **eine** offene CadenceInstance (kein Duplikat bei Verpassen). - Pro Recurring: **eine** offene CadenceInstance (kein Duplikat bei Verpassen).
- Inaktive Stufen: kein Next, keine neuen Instanzen; Historie bleibt. - Inaktive / nicht-graph-ready Gates: kein Next, keine neuen Instanzen; Historie bleibt.
### 4.3 Zeitversatz und Alternanz (gleiche Phase) ### 4.3 Zeitversatz und Alternanz (gleiche Phase)
@ -97,29 +103,30 @@ evidence, decisions, reviews, steering_methods
Alternanz-Regeln: Rotation nach Erfüllung; Overdue nur auf aktuelles Mitglied; Gruppe an Stufe gebunden. Alternanz-Regeln: Rotation nach Erfüllung; Overdue nur auf aktuelles Mitglied; Gruppe an Stufe gebunden.
### 4.4 Modifikation bei Stage `reached` ### 4.4 Modifikation bei Gate `reached`
1. Altes Activity Set deaktivieren. 1. Activity Set **dieses Gates** deaktivieren.
2. Nächste Stufe aktiv; neues Set aktiv. 2. **Pro Strang:** nächstes Gate auf dem Pfad `active` (Graph-regelkonform); bei **Join-Gates** nach Verify.
3. Neues Set darf Übungen/Cadences/Alternanz/Actions ersetzen/erweitern/reduzieren. 3. Neues Set **strangspezifisch** — Stränge dürfen divergieren (z. B. Technik ↓, Dehnung ↑).
4. Adaptation innerhalb Stufe (manuell) erlaubt und auditiert. 4. Adaptation innerhalb aktiven Gates (manuell) erlaubt und auditiert.
Erweitert AP2.0e auf ganze Activity Sets.
### 4.5 Datenfelder am Recurring (verbindlich) ### 4.5 Datenfelder am Recurring (verbindlich)
| Feld | Zweck | | Feld | Zweck |
|------|-------| |------|-------|
| Bindung an `maturity_stage` | Stage Activity Set | | `roadmap_item_id` | Gate / Activity Set |
| `project_id` | Strang (Pfad); Pflicht bei Multi-Strang |
| Cadence | Zeitversatz | | Cadence | Zeitversatz |
| `alternation_group_id` + `alternation_order` | Alternanz (leer = keine Gruppe) | | `alternation_group_id` + `alternation_order` | Alternanz (leer = keine Gruppe) |
| status aktiv/inaktiv | Set-Wechsel / Adaptation | | status aktiv/inaktiv | Set-Wechsel / Adaptation |
### 4.6 Anti-Patterns ### 4.6 Anti-Patterns
- Flache Liste „Wiederkehrend“ ohne Gate/Strang-Bezug
- Übungen aller Stufen als flache Action-Liste - Übungen aller Stufen als flache Action-Liste
- Verpasste Übung → Duplikat-Task - Verpasste Übung → Duplikat-Task
- Alternanz ohne Gruppen-Semantik - Alternanz ohne Gruppen-Semantik
- Ein uniformes Initiative-Set nach Join (statt strangspezifisch)
--- ---
@ -130,7 +137,7 @@ Erweitert AP2.0e auf ganze Activity Sets.
| Aspekt | Kurz | | Aspekt | Kurz |
|--------|------| |--------|------|
| Dominanz | hybrid | | Dominanz | hybrid |
| Horizon | aktive `maturity_stage` | | Horizon | aktive Gates (multi-strang) — PO-Lock §14 |
| Leading | CadenceInstance; sekundär Stage-Action | | Leading | CadenceInstance; sekundär Stage-Action |
| Next / Ranking | Spec-D D6 | | Next / Ranking | Spec-D D6 |
| Attention / Events | Spec-D D7 / D8 | | Attention / Events | Spec-D D7 / D8 |
@ -175,12 +182,12 @@ Cadence im Detail am RecurringElement.
| Fläche | Inhalt | | Fläche | Inhalt |
|--------|--------| |--------|--------|
| **Plan → Stufen** | Timeline/Liste; aktiv; Kriterien; Verify | | **Plan → Struktur** | Stränge (Projects) + Gate-Graph (parallel, Join) |
| **Plan → Stufe Detail** | Activity Set: Übungen (Cadence, Alternanz), Stage-Actions | | **Plan → Gate-Detail** | Activity Set: Übungen, Strang, Cadence, metric-Kriterien |
| **Plan → Struktur** | Fähigkeits-Projects + Graph | | **Plan → Stufen** | Graph-/Listen-Ansicht aktiver Gates |
| **Plan → Profil** | EFS Fokus/Frequenz/Disziplin | | **Plan → Profil** | EFS Fokus/Frequenz/Disziplin |
| **Ausführen / Today** | fällige Übungen + Alternanz-aktuell + Stage-Actions | | **Ausführen / Today** | fällige Übungen **gruppiert nach Strang/Gate** |
| **Kontrolle** | Stufe, Next + Begründung, Attention, Verify-Fortschritt | | **Kontrolle** | aktive Gates, Join-Status, Kriterien-Fortschritt |
| **Journey** | Stufenwechsel, Set-Modifikationen, erfüllte Übungen | | **Journey** | Stufenwechsel, Set-Modifikationen, erfüllte Übungen |
| **Process** | Stufen → Stufe/Übungen → Ausführen → Kontrolle | | **Process** | Stufen → Stufe/Übungen → Ausführen → Kontrolle |
| **Elements** | `next_action_primary`, `maturity_stage`, `recurring_rhythm` | | **Elements** | `next_action_primary`, `maturity_stage`, `recurring_rhythm` |
@ -231,17 +238,16 @@ Cadence im Detail am RecurringElement.
**PO-Validation 2026-07-28:** Detaillierte Findings + Architektur-Urteil → **PO-Validation 2026-07-28:** Detaillierte Findings + Architektur-Urteil →
`docs/sprints/Sprint1_AP2_1_Validation_Findings_Register_v0.1.md` (F-A1-01…10) `docs/sprints/Sprint1_AP2_1_Validation_Findings_Register_v0.1.md` (F-A1-01…10)
**PO-Produkturteil A1:** **No-Go** — Leitfrage nicht erfüllt; Tages-Tracking, Messgrößen, Gate-Fortschritt und IA fehlen für echten Einsatz. Architektur/Spec **nicht** falsch; **Minimal-Slice** (CadenceInstance, Work=Übung) vor Re-Abnahme nötig. **PO-Produkturteil A1 (2026-07-28):** **No-Go** — Cadence ohne Progressions-IA. **Option C (PO-Lock):** [`SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](SPEC_A1_Progression_Model_PO_Lock_v0.1.md) — Multi-Strang vor Re-Abnahme.
| Bereich | Spec | Code/Truth | Lücke | | Bereich | Spec | Code/Truth | Lücke |
|---------|------|------------|-------| |---------|------|------------|-------|
| Registry / Kit | A1 + Stufen | weitgehend AP2.0e/Kit | Multi-Übung/Alternanz | | Progressionsmodell | PO-Lock | linear + flat recurring | Multi-Strang + Gate-Activity-Set |
| Methode | `maturity_progression` | vorhanden | Ranking Alternanz Spec-D | | Horizon multi-active | PO-Lock §3 | single-active (widerrufen) | AP-A1-PM-3 |
| Stage Activity Set | verbindlich | Teilmenge | Stage-Bindung + UI Detail | | Stage Activity Set | Gate + Strang | Teilmenge | Gate-Detail UI + `project_id` |
| Alternanz-Felder | verbindlich | fehlt/teilweise | Persistenz + Strategy | | Registry / Kit | Demo Spagat | geliefert | reicht nicht für PO-Kernbild |
| EFS Pflicht | focus/frequency | Seeds prüfen | Seed-AP | | UI Plan Struktur/Gate | Pflicht | fehlt | AP-A1-PM-4 |
| steering_elements | D11 | teilweise | FE/BE Parität | | Alternanz / metric | verbindlich | fehlt/teilweise | AP-A1-PM-5+ |
| UI Stufe-Detail | Pflicht | lückenhaft | UI-AP |
--- ---
@ -254,5 +260,25 @@ Fachmodell vollständig. Lücken (Alternanz, Multi-Übung-UI, EFS-Seeds) = Imple
## 13. Nachfolger ## 13. Nachfolger
- Alternanz-Persistenz + Strategy - Alternanz-Persistenz + Strategy
- UI Stufe-Detail / Activity Set - UI Plan → Struktur + Gate-Detail / Activity Set
- Template Kumite - Template Kumite / Mawashi (Multi-Strang)
---
## 14. PO-Lock Progressionsmodell (2026-07-28, Option C)
**Verbindlich** für Horizon, Stränge, Gate-Activity-Sets und MVP-Abgrenzung:
→ [`SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](SPEC_A1_Progression_Model_PO_Lock_v0.1.md)
Kurzfassung:
| Thema | Lock |
|-------|------|
| Strang | `Project` — parallele Fähigkeits-Pfade |
| Gate | `maturity_stage` im Graph; Join-Gates mit `requires` |
| Übung | `RecurringElement` = Übung; Rhythmus = Feld; **nicht** losgelöstes Objekt |
| Horizon | **Multi-active** Gates; pro Strang max. 1 aktives Gate auf Pfad |
| MVP A1 Go | ≥2 Stränge + Join + Gate-Activity-Set + Today — nicht nur lineares Spagat-Demo |
Implementierungsfolge: PO-Lock §8 (`AP-A1-PM-1` … `PM-6`).

View File

@ -19,18 +19,18 @@
## 1. Executive Summary ## 1. Executive Summary
| Entscheidung | ☐ Go Stufe A · ☐ Bedingt · ☑ No-Go (bis A1 nachgezogen oder Scope-PO) | | Entscheidung | ☐ Go Stufe A · ☐ Bedingt · ☑ No-Go (A1 bis Progressions-IA; Option C PO-Lock) |
|--------------|-------------------------------------| |--------------|-------------------------------------|
**Kurzfazit (35 Sätze):** **Kurzfazit (35 Sätze):**
Technisch: pytest grün, A1-Happy-Path (Anlage → Verify → Stufenwechsel) durchspielbar. Technisch: pytest grün, A1-Happy-Path (Anlage → Verify → Stufenwechsel) durchspielbar.
Produkt (PO): **A1 No-Go**Leitfrage nicht beantwortbar; tagesbezogenes Tracking, Messgrößen und Gate-Fortschritt fehlen; extreme Unübersichtlichkeit; **Hürden > Nutzen**. A2/B2b noch offen. Produkt (PO): **A1 No-Go**Cadence-Plumbing reicht nicht; Nutzerbild = **parallele Stränge + Gate-Graph + Activity Sets**, nicht flache „Wiederkehrend“-Liste. **PO-Entscheid Option C:** Spec schärfen → [`SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](../product/archetypes/SPEC_A1_Progression_Model_PO_Lock_v0.1.md). A2/B2b noch offen.
**Blocker (falls No-Go / Bedingt):** **Blocker (falls No-Go / Bedingt):**
- **A1:** CadenceInstance/Tages-„erledigt“, Work=Übung, Gate-Fortschritt, IA — siehe Findings Register §6.1 (F-A1-02…10) - **A1:** Progressions-IA gemäß PO-Lock §68 (`AP-A1-PM-2` … `PM-6`) — Multi-Strang, Gate-Activity-Set, Plan-UI, Today gruppiert
- **Gesamt Stufe A:** MVP §4 verlangt A1 — Go erst nach A1 Minimum Slice **oder** PO-Scope-Entscheidung (A1 aus Stufe-A-Abnahfe) - **Gesamt Stufe A:** MVP §4 verlangt A1 — Go erst nach PO-Kernbild (≥2 Stränge + Join) durchspielbar
--- ---

View File

@ -206,13 +206,22 @@ Alternanz, volle Metric-Engine, Activity-Set-Automation → **nach** Slice 14
## 7. Konsequenz für AP2.1 / MVP Stufe A ## 7. Konsequenz für AP2.1 / MVP Stufe A
| Option | Bedeutung | | Option | Bedeutung | PO 2026-07-28 |
|--------|-----------| |--------|-----------|---------------|
| **A — A1 nachziehen** | Validation pausieren; **A1 Minimum Slice** (§6.1) liefern; A1 erneut smoke-testen | | **A — A1 nachziehen** | Minimum Slice liefern | ◐ Cadence done; Progressions-IA offen |
| **B — MVP-Definition anpassen** | A1 vorübergehend aus Stufe-A-Abnahfe (PO-Entscheidung + Doku) — **nur wenn A2+B2b als MVP reichen** | | **B — MVP-Definition anpassen** | A1 aus Stufe-A-Abnahfe | nicht gewählt |
| **C — Gesamt No-Go** | AP2.1 stoppt bei Stufe A bis A1 oder Scope-Entscheidung | | **C — Spec schärfen, dann implementieren** | PO-Lock vor Coding | **✓ gewählt** |
**Empfehlung:** **Option A** — Architektur trägt; Spec war richtig; Implementierung war zu dünn. A2/B2b-Smoke **parallel** möglich, Gesamt-Go aber nicht ohne A1-Lösung (Option A oder explizite B). **PO-Lock (Option C):** [`docs/product/archetypes/SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](../product/archetypes/SPEC_A1_Progression_Model_PO_Lock_v0.1.md)
Kernentscheide:
- **Multi-Strang** (Projects) + **Gate-Graph** + **Join-Gates**
- **Horizon:** mehrere aktive Gates (nicht eine Stufe initiative-weit)
- **Übung** = `RecurringElement` am Gate + Strang; Rhythmus = Feld
- **A1 Go MVP:** ≥2 Stränge + 1 Join + Gate-Activity-Set + Today — nicht nur Spagat-Demo
**Validation A1:** pausiert bis `AP-A1-PM-4` (Plan Struktur + Gate-Detail). A2/B2b-Smoke **parallel** möglich.
--- ---

View File

@ -1,7 +1,7 @@
# AP A1 — Minimum Usability Slice # AP A1 — Minimum Usability Slice
## Auftrag v0.1 ## Auftrag v0.1
**Status:** freigegeben (PO folgt Option A, 2026-07-28) · **Implementierung A1.1A1.3 abgeschlossen** (Re-Abnahme ausstehend) **Status:** freigegeben (PO folgt Option A, 2026-07-28) · **superseded by Option C PO-Lock** — siehe [`SPEC_A1_Progression_Model_PO_Lock_v0.1.md`](../archetypes/SPEC_A1_Progression_Model_PO_Lock_v0.1.md)
**Auslöser:** AP2.1 PO No-Go A1 — [`Sprint1_AP2_1_Validation_Findings_Register_v0.1.md`](Sprint1_AP2_1_Validation_Findings_Register_v0.1.md) §6.1 **Auslöser:** AP2.1 PO No-Go A1 — [`Sprint1_AP2_1_Validation_Findings_Register_v0.1.md`](Sprint1_AP2_1_Validation_Findings_Register_v0.1.md) §6.1
**Ziel:** A1 **real nutzbar** für Tages-Tracking + Gate-Einschätzung + klare Today-Ansicht — **ohne** Architektur-Neudesign. **Ziel:** A1 **real nutzbar** für Tages-Tracking + Gate-Einschätzung + klare Today-Ansicht — **ohne** Architektur-Neudesign.

View 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>
)
}

View File

@ -16,7 +16,8 @@ export function MaturityStagePanel({
.filter((item) => item.item_type === 'maturity_stage') .filter((item) => item.item_type === 'maturity_stage')
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)) .sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
const activeStage = stages.find((s) => s.status === 'active') const activeStages = stages.filter((s) => s.status === 'active')
const activeStage = activeStages[0]
useEffect(() => { useEffect(() => {
if (!initiativeId) return undefined if (!initiativeId) return undefined
@ -40,15 +41,20 @@ export function MaturityStagePanel({
<EmptyState message="Noch keine Reifegrad-Stufen — unter Plan anlegen oder Starter-Kit nutzen." /> <EmptyState message="Noch keine Reifegrad-Stufen — unter Plan anlegen oder Starter-Kit nutzen." />
) : ( ) : (
<> <>
{activeStage && ( {activeStages.length > 0 && (
<> <>
<p className="maturity-stage-active muted"> <p className="maturity-stage-active muted">
Aktive Stufe:{' '} Aktive Gate(s):{' '}
<Link to={gatePath(activeStage.id)} className="link-inline"> {activeStages.map((stage, index) => (
<strong>{activeStage.title}</strong> <span key={stage.id}>
{index > 0 ? ' · ' : ''}
<Link to={gatePath(stage.id)} className="link-inline">
<strong>{stage.title}</strong>
</Link> </Link>
</span>
))}
</p> </p>
{activeProgress && ( {activeStage && activeProgress && (
<p className="maturity-stage-progress muted"> <p className="maturity-stage-progress muted">
Gate-Fortschritt:{' '} Gate-Fortschritt:{' '}
<strong> <strong>

View File

@ -12,10 +12,10 @@ function formatDueAt(iso) {
} }
} }
function activeMaturityStage(roadmapItems = []) { function activeMaturityStages(roadmapItems = []) {
return [...roadmapItems] return [...roadmapItems]
.filter((item) => item.item_type === 'maturity_stage' && item.status === 'active') .filter((item) => item.item_type === 'maturity_stage' && item.status === 'active')
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))[0] .sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
} }
function practicesForStage(recurringItems = [], stageId) { function practicesForStage(recurringItems = [], stageId) {
@ -32,20 +32,22 @@ export function RecurringRhythmPanel({
canManage = false, canManage = false,
embedded = true, embedded = true,
}) { }) {
const stage = activeMaturityStage(roadmapItems) const stages = activeMaturityStages(roadmapItems)
const stagePractices = practicesForStage(recurringItems, stage?.id) const stagePractices = stages.flatMap((stage) =>
practicesForStage(recurringItems, stage.id),
)
const active = stagePractices.filter((item) => item.status === 'active') const active = stagePractices.filter((item) => item.status === 'active')
const paused = stagePractices.filter((item) => item.status === 'paused') const paused = stagePractices.filter((item) => item.status === 'paused')
const body = const body =
!stage ? ( stages.length === 0 ? (
<EmptyState message="Keine aktive Reifegrad-Stufe — Plan → Stufen prüfen." /> <EmptyState message="Keine aktiven Work-Gates — Plan → Struktur prüfen." />
) : stagePractices.length === 0 ? ( ) : stagePractices.length === 0 ? (
<EmptyState message="Keine Übungen für diese Stufe — Starter-Kit oder Stufenwechsel prüfen." /> <EmptyState message="Keine Übungen auf aktiven Gates — Starter-Kit oder Join prüfen." />
) : ( ) : (
<> <>
<p className="recurring-rhythm-summary muted"> <p className="recurring-rhythm-summary muted">
Stufe <strong>{stage.title}</strong> {active.length} aktiv {stages.length} aktive Gate(s) {active.length} Übung(en) aktiv
{paused.length > 0 ? `, ${paused.length} pausiert` : ''}. {paused.length > 0 ? `, ${paused.length} pausiert` : ''}.
</p> </p>
<ul className="item-list recurring-rhythm-list"> <ul className="item-list recurring-rhythm-list">
@ -92,9 +94,9 @@ export function RecurringRhythmPanel({
<section className="card recurring-rhythm-panel"> <section className="card recurring-rhythm-panel">
<div className="section-header"> <div className="section-header">
<div> <div>
<h2>Übungen der Stufe</h2> <h2>Übungen der aktiven Gates</h2>
<p className="section-lead muted"> <p className="section-lead muted">
Activity Set der aktiven Reifegrad-Stufe nicht die Gates selbst (A1). Activity Sets aller aktiven Work-Gates Join-Knoten haben keine Übungen (A1).
</p> </p>
</div> </div>
</div> </div>

View File

@ -13,17 +13,22 @@ function formatDue(iso) {
} }
} }
function activeMaturityStage(roadmapItems = []) { function activeWorkGates(roadmapItems = []) {
return [...roadmapItems] return [...roadmapItems]
.filter((item) => item.item_type === 'maturity_stage' && item.status === 'active') .filter((item) => item.item_type === 'maturity_stage' && item.status === 'active')
.sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))[0] .sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
} }
function exercisesForStage(recurringItems = [], stageId) { function exercisesForGate(recurringItems = [], gateId) {
const active = (recurringItems || []).filter((item) => item.status === 'active') return (recurringItems || []).filter(
if (!stageId) return active (item) => item.status === 'active' && item.roadmap_item_id === gateId,
const bound = active.filter((item) => item.roadmap_item_id === stageId) )
return bound.length > 0 ? bound : active }
function strandLabel(gate, projectsById) {
const projectId = gate.strand_project_id
if (!projectId) return 'Training'
return projectsById.get(projectId)?.title || 'Strang'
} }
function ExerciseRow({ exercise, canManage, busy, onCompleted }) { function ExerciseRow({ exercise, canManage, busy, onCompleted }) {
@ -94,23 +99,33 @@ function ExerciseRow({ exercise, canManage, busy, onCompleted }) {
} }
/** /**
* A1 Heutige Übungen der aktiven Stufe (CadenceInstance je Übung). * A1 Heutige Übungen aller aktiven Work-Gates, gruppiert nach Strang.
*/ */
export function WorkTodayPracticePanel({ export function WorkTodayPracticePanel({
initiativeId, initiativeId,
roadmapItems = [], roadmapItems = [],
recurringItems = [], recurringItems = [],
projects = [],
canManage = false, canManage = false,
onCompleted, onCompleted,
busy = false, busy = false,
}) { }) {
const stage = useMemo(() => activeMaturityStage(roadmapItems), [roadmapItems]) const projectsById = useMemo(
() => new Map((projects || []).map((project) => [project.id, project])),
const todayExercises = useMemo( [projects],
() => exercisesForStage(recurringItems, stage?.id),
[recurringItems, stage?.id],
) )
const gates = useMemo(() => activeWorkGates(roadmapItems), [roadmapItems])
const grouped = useMemo(() => {
return gates.map((gate) => ({
gate,
strand: strandLabel(gate, projectsById),
exercises: exercisesForGate(recurringItems, gate.id),
}))
}, [gates, recurringItems, projectsById])
const todayExercises = grouped.flatMap((group) => group.exercises)
const pending = todayExercises.filter((item) => !item.today_completed) const pending = todayExercises.filter((item) => !item.today_completed)
const allDone = todayExercises.length > 0 && pending.length === 0 const allDone = todayExercises.length > 0 && pending.length === 0
@ -120,20 +135,15 @@ export function WorkTodayPracticePanel({
<div> <div>
<h2>Heutige Übungen</h2> <h2>Heutige Übungen</h2>
<p className="section-lead muted"> <p className="section-lead muted">
{stage ? ( {gates.length > 0
<> ? `${gates.length} aktive Gate(s) — pro Übung erledigen, optional Messwert notieren.`
Aktive Stufe: <strong>{stage.title}</strong> pro Übung erledigen, optional Messwert : 'Tages-Tracking für aktive Work-Gates auf allen Strängen.'}
notieren.
</>
) : (
'Tages-Tracking für die aktive Reifegrad-Stufe.'
)}
</p> </p>
</div> </div>
</div> </div>
{todayExercises.length === 0 ? ( {todayExercises.length === 0 ? (
<EmptyState message="Keine Übungen für die aktive Stufe — Starter-Kit oder Stufenwechsel prüfen." /> <EmptyState message="Keine Übungen auf aktiven Gates — Starter-Kit oder Join-Fortschritt prüfen." />
) : ( ) : (
<> <>
{allDone && ( {allDone && (
@ -141,8 +151,14 @@ export function WorkTodayPracticePanel({
Alle Übungen für heute erledigt nächste Fälligkeit morgen. Alle Übungen für heute erledigt nächste Fälligkeit morgen.
</p> </p>
)} )}
{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"> <ul className="item-list work-today-practice__list">
{todayExercises.map((exercise) => ( {exercises.map((exercise) => (
<ExerciseRow <ExerciseRow
key={exercise.id} key={exercise.id}
exercise={exercise} exercise={exercise}
@ -152,6 +168,9 @@ export function WorkTodayPracticePanel({
/> />
))} ))}
</ul> </ul>
</div>
),
)}
</> </>
)} )}

View File

@ -58,6 +58,16 @@ export const COMPOSITION_PROVIDERS = [
scopeTypes: ['initiative'], scopeTypes: ['initiative'],
order: 10, order: 10,
}, },
{
key: 'steering.join_waitlist',
kind: 'steering_element',
steeringElement: 'maturity_stage',
slotKeys: ['control.status.steering'],
componentKey: 'JoinWaitlistPanel',
requiresCapability: 'kairo.initiative.read',
scopeTypes: ['initiative'],
order: 14,
},
{ {
key: 'steering.maturity_stage', key: 'steering.maturity_stage',
kind: 'steering_element', kind: 'steering_element',

View File

@ -2,6 +2,7 @@ import { Link } from 'react-router-dom'
import { scopedPath } from '../utils/routes.js' import { scopedPath } from '../utils/routes.js'
import { SteeringSnapshotPanel } from '../components/SteeringSnapshotPanel.jsx' import { SteeringSnapshotPanel } from '../components/SteeringSnapshotPanel.jsx'
import { CriticalPathPanel } from '../components/CriticalPathPanel.jsx' import { CriticalPathPanel } from '../components/CriticalPathPanel.jsx'
import { JoinWaitlistPanel } from '../components/JoinWaitlistPanel.jsx'
import { MaturityStagePanel } from '../components/MaturityStagePanel.jsx' import { MaturityStagePanel } from '../components/MaturityStagePanel.jsx'
import { RecurringRhythmPanel } from '../components/RecurringRhythmPanel.jsx' import { RecurringRhythmPanel } from '../components/RecurringRhythmPanel.jsx'
import { AgentSlotsPanel } from '../components/AgentSlotsPanel.jsx' import { AgentSlotsPanel } from '../components/AgentSlotsPanel.jsx'
@ -47,6 +48,7 @@ export const PROVIDER_COMPONENTS = {
WidgetHost, WidgetHost,
WorkActionsPanel, WorkActionsPanel,
WorkTodayPracticePanel, WorkTodayPracticePanel,
JoinWaitlistPanel,
} }
/** /**

View File

@ -203,11 +203,16 @@ export function buildProviderProps(provider, input) {
recurringItems: ops.recurringItems || [], recurringItems: ops.recurringItems || [],
canManage: capabilities.has('kairo.recurring.manage'), canManage: capabilities.has('kairo.recurring.manage'),
} }
case 'JoinWaitlistPanel':
return {
progressionState: input.steeringSnapshot?.progression_state ?? null,
}
case 'WorkTodayPracticePanel': case 'WorkTodayPracticePanel':
return { return {
initiativeId: ops.initiativeId, initiativeId: ops.initiativeId,
roadmapItems: ops.roadmapItems || [], roadmapItems: ops.roadmapItems || [],
recurringItems: ops.recurringItems || [], recurringItems: ops.recurringItems || [],
projects: ops.projects || [],
canManage: capabilities.has('kairo.recurring.manage'), canManage: capabilities.has('kairo.recurring.manage'),
busy: ops.formBusy, busy: ops.formBusy,
onCompleted: async () => { onCompleted: async () => {

View File

@ -92,6 +92,7 @@ export const ROADMAP_ITEM_TYPE_LABELS = {
milestone: 'Meilenstein / Gate', milestone: 'Meilenstein / Gate',
review_gate: 'Review-Gate', review_gate: 'Review-Gate',
maturity_stage: 'Reifegrad-Stufe', maturity_stage: 'Reifegrad-Stufe',
join_gate: 'Join / Schaltknoten',
} }
export const SEQUENCING_MODE_LABELS = { export const SEQUENCING_MODE_LABELS = {