diff --git a/backend/entity_archetypes/ui_profiles.py b/backend/entity_archetypes/ui_profiles.py index b89dcbf..b807145 100644 --- a/backend/entity_archetypes/ui_profiles.py +++ b/backend/entity_archetypes/ui_profiles.py @@ -73,7 +73,6 @@ GENERIC_UI_PROFILE: dict[str, Any] = { *_COMMON_OM_SLICES[1:], ], "enabledModes": ["plan", "work", "control"], - "uiFeatures": {}, } INITIATIVE_UI_PROFILES: dict[str, dict[str, Any]] = { @@ -99,10 +98,6 @@ INITIATIVE_UI_PROFILES: dict[str, dict[str, Any]] = { "steering_methods", ], "enabledModes": ["plan", "work", "control"], - "uiFeatures": { - "continuousProductWorkMode": True, - "steeringSnapshotOnWorkSprint": True, - }, }, "initiative.linear_project": { "processSteps": list(_LINEAR_PROCESS), @@ -123,9 +118,6 @@ INITIATIVE_UI_PROFILES: dict[str, dict[str, Any]] = { "steering_methods", ], "enabledModes": ["plan", "work", "control"], - "uiFeatures": { - "criticalPathControl": True, - }, }, "initiative.maturity_journey": { "processSteps": list(_MATURITY_PROCESS), @@ -145,7 +137,6 @@ INITIATIVE_UI_PROFILES: dict[str, dict[str, Any]] = { "steering_methods", ], "enabledModes": ["plan", "work", "control"], - "uiFeatures": {}, }, "initiative.program": { "processSteps": list(_PROGRAM_PROCESS), @@ -167,7 +158,6 @@ INITIATIVE_UI_PROFILES: dict[str, dict[str, Any]] = { "steering_methods", ], "enabledModes": ["plan", "work", "control"], - "uiFeatures": {}, }, "initiative.support_queue": { "processSteps": list(_SUPPORT_QUEUE_PROCESS), @@ -183,7 +173,6 @@ INITIATIVE_UI_PROFILES: dict[str, dict[str, Any]] = { "steering_methods", ], "enabledModes": ["plan", "work", "control"], - "uiFeatures": {}, }, "initiative.recurring_program": { "processSteps": list(_MATURITY_PROCESS), @@ -200,7 +189,6 @@ INITIATIVE_UI_PROFILES: dict[str, dict[str, Any]] = { "steering_methods", ], "enabledModes": ["plan", "work", "control"], - "uiFeatures": {}, }, "initiative.content_project": { "processSteps": list(_CONTENT_PROCESS), @@ -219,7 +207,6 @@ INITIATIVE_UI_PROFILES: dict[str, dict[str, Any]] = { "steering_methods", ], "enabledModes": ["plan", "work", "control"], - "uiFeatures": {}, }, "initiative.dispute_case": { "processSteps": list(_DISPUTE_PROCESS), @@ -237,7 +224,6 @@ INITIATIVE_UI_PROFILES: dict[str, dict[str, Any]] = { "steering_methods", ], "enabledModes": ["plan", "work", "control"], - "uiFeatures": {}, }, } @@ -245,6 +231,8 @@ INITIATIVE_UI_PROFILES: dict[str, dict[str, Any]] = { def resolve_ui_profile(archetype_key: str) -> dict[str, Any]: """UI-Profil aus Code-Seed; unbekannte Archetypen → Generic-Fallback.""" profile = INITIATIVE_UI_PROFILES.get(archetype_key) - if profile: - return dict(profile) - return dict(GENERIC_UI_PROFILE) + resolved = dict(profile) if profile else dict(GENERIC_UI_PROFILE) + slices = resolved.get("dataSlices") or [] + resolved["omCapabilities"] = list(resolved.get("omCapabilities") or slices) + resolved.pop("uiFeatures", None) + return resolved diff --git a/backend/routers/steering.py b/backend/routers/steering.py index 128b73c..ae7efda 100644 --- a/backend/routers/steering.py +++ b/backend/routers/steering.py @@ -1,4 +1,4 @@ -"""Steering API — methods, profiles and context (AP1.1 / AP2.0).""" +"""Steering API — methods, profiles and context (AP1.1 / AP2.0 / AP2.4).""" from __future__ import annotations @@ -7,11 +7,16 @@ from typing import Optional from capabilities import require_capability from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, Field +from entity_archetypes.registry import resolve_default_method_key, resolve_ui_profile from method_profiles.registry import list_method_profiles from services import initiatives as initiative_service from services import steering_context as sc_service from steering.context import get_steering_context_dto -from steering.methods.registry import list_methods +from steering.methods.registry import ( + list_compatible_methods, + list_methods, + method_to_dict, +) from tenant_context import TenantContext router = APIRouter(prefix="/api/steering", tags=["steering"]) @@ -23,20 +28,34 @@ class SteeringContextUpdateRequest(BaseModel): clear_method_profile: bool = False +def _om_capabilities_for_archetype(archetype_key: str | None) -> frozenset[str] | None: + if not archetype_key: + return None + profile = resolve_ui_profile(archetype_key) + caps = profile.get("omCapabilities") or profile.get("dataSlices") or [] + return frozenset(caps) + + @router.get("/methods") def list_steering_methods( + archetype_key: Optional[str] = Query(default=None), ctx: TenantContext = Depends(require_capability("kairo.initiative.read")), ): _ = ctx + om_caps = _om_capabilities_for_archetype(archetype_key) + if archetype_key: + methods = list_compatible_methods(archetype_key, om_capabilities=om_caps) + default_key = resolve_default_method_key(archetype_key) + else: + methods = list_methods() + default_key = None + return [ { - "key": m.key, - "version": m.version, - "label": m.label, - "description": m.description, - "next_action_strategy_key": m.next_action_strategy_key, + **method_to_dict(m), + "is_default": m.key == default_key if default_key else False, } - for m in list_methods() + for m in methods ] diff --git a/backend/services/operating_context.py b/backend/services/operating_context.py index fcca3b7..425365c 100644 --- a/backend/services/operating_context.py +++ b/backend/services/operating_context.py @@ -1,4 +1,4 @@ -"""Operating Context read model — AP2.3a.""" +"""Operating Context read model — AP2.3a / AP2.4.""" from __future__ import annotations @@ -7,10 +7,20 @@ from typing import Any, Optional from psycopg2.extras import RealDictCursor from db import get_connection -from entity_archetypes.registry import get_ui_profile_json, resolve_ui_profile +from entity_archetypes.registry import ( + get_ui_profile_json, + resolve_default_method_key, + resolve_ui_profile, +) from services.initiatives import get_initiative from services.steering_context import ensure_steering_context, get_steering_context -from steering.methods.registry import get_method +from steering.graph.profiles import LIGHT, graph_profile_as_dict +from steering.methods.registry import ( + get_method, + list_compatible_methods, + method_to_dict, + ui_features_as_dict, +) def _load_ui_profile_from_db(archetype_key: str) -> Optional[dict[str, Any]]: @@ -32,8 +42,6 @@ def _load_ui_profile_from_db(archetype_key: str) -> Optional[dict[str, Any]]: if isinstance(profile, dict) and profile: return dict(profile) return None - except Exception: - return None finally: conn.close() @@ -48,18 +56,22 @@ def _resolve_ui_profile(archetype_key: str) -> dict[str, Any]: return resolve_ui_profile(archetype_key) +def _resolve_om_capabilities(ui_profile: dict[str, Any]) -> frozenset[str]: + caps = ui_profile.get("omCapabilities") or ui_profile.get("dataSlices") or [] + return frozenset(caps) + + def _resolve_data_slices( *, - ui_profile: dict[str, Any], + om_capabilities: frozenset[str], method_key: str, ) -> list[str]: - """Schnittmenge Archetyp-Default ∩ Method-Slices (Phase E erweitert).""" - archetype_slices = ui_profile.get("dataSlices") or [] + """Schnittmenge Archetyp-OM ∩ Method-Slices.""" method = get_method(method_key) - if method and getattr(method, "data_slices", None): + if method and method.data_slices: method_slices = set(method.data_slices) - return [s for s in archetype_slices if s in method_slices] - return list(archetype_slices) + return [s for s in om_capabilities if s in method_slices] + return list(om_capabilities) def _method_capabilities(method_key: str) -> dict[str, Any]: @@ -75,6 +87,41 @@ def _method_capabilities(method_key: str) -> dict[str, Any]: } +def _resolve_method_contract(method_key: str) -> dict[str, Any]: + method = get_method(method_key) + if not method: + return { + "steering_elements": [], + "ui_features": {}, + "graph_profile": graph_profile_as_dict(LIGHT), + } + return { + "steering_elements": sorted(method.steering_elements), + "ui_features": ui_features_as_dict(method.ui_features), + "graph_profile": graph_profile_as_dict(method.graph_profile), + } + + +def _compatible_methods_payload( + archetype_key: str, + *, + om_capabilities: frozenset[str], + active_method_key: str, +) -> list[dict[str, Any]]: + default_key = resolve_default_method_key(archetype_key) + methods = list_compatible_methods( + archetype_key, om_capabilities=om_capabilities + ) + return [ + { + **method_to_dict(m, include_compatibility=False), + "is_default": m.key == default_key, + "is_active": m.key == active_method_key, + } + for m in methods + ] + + def get_operating_context( *, tenant_id: str, initiative_id: str ) -> Optional[dict[str, Any]]: @@ -90,12 +137,17 @@ def get_operating_context( archetype_key = initiative["archetype_key"] ui_profile = _resolve_ui_profile(archetype_key) + om_capabilities = _resolve_om_capabilities(ui_profile) method_key = steering["method_key"] metadata = steering.get("lifecycle_metadata") or {} if isinstance(metadata, str): metadata = {} method_profile_key = metadata.get("method_profile_key") - data_slices = _resolve_data_slices(ui_profile=ui_profile, method_key=method_key) + data_slices = _resolve_data_slices( + om_capabilities=om_capabilities, + method_key=method_key, + ) + method_contract = _resolve_method_contract(method_key) return { "initiative_id": initiative_id, @@ -103,6 +155,15 @@ def get_operating_context( "method_key": method_key, "method_profile_key": method_profile_key, "ui_profile": ui_profile, + "om_capabilities": sorted(om_capabilities), "data_slices": data_slices, "method_capabilities": _method_capabilities(method_key), + "steering_elements": method_contract["steering_elements"], + "ui_features": method_contract["ui_features"], + "graph_profile": method_contract["graph_profile"], + "compatible_methods": _compatible_methods_payload( + archetype_key, + om_capabilities=om_capabilities, + active_method_key=method_key, + ), } diff --git a/backend/services/steering_context.py b/backend/services/steering_context.py index 58ee88a..9f953b2 100644 --- a/backend/services/steering_context.py +++ b/backend/services/steering_context.py @@ -16,6 +16,14 @@ DEFAULT_METHOD_KEY = "generic_operating" DEFAULT_METHOD_VERSION = "0.1.0" +def _resolve_om_capabilities_for_initiative(initiative: dict[str, Any]) -> frozenset[str]: + from entity_archetypes.registry import resolve_ui_profile + + profile = resolve_ui_profile(initiative["archetype_key"]) + caps = profile.get("omCapabilities") or profile.get("dataSlices") or [] + return frozenset(caps) + + def _serialize_row(row: dict[str, Any]) -> dict[str, Any]: result = dict(row) for key in ("id", "tenant_id", "initiative_id"): @@ -229,7 +237,11 @@ def update_method_key( initiative = get_initiative(tenant_id=tenant_id, initiative_id=initiative_id) if not initiative: raise ValueError("Vorhaben nicht gefunden") - if not method_compatible_with_archetype(method, initiative["archetype_key"]): + if not method_compatible_with_archetype( + method, + initiative["archetype_key"], + om_capabilities=_resolve_om_capabilities_for_initiative(initiative), + ): raise ValueError( f"Methode {method_key} ist nicht kompatibel mit Archetyp " f"{initiative['archetype_key']}" diff --git a/backend/steering/elements/__init__.py b/backend/steering/elements/__init__.py new file mode 100644 index 0000000..476b61b --- /dev/null +++ b/backend/steering/elements/__init__.py @@ -0,0 +1,15 @@ +"""Steering element catalog — AP2.4.""" + +from steering.elements.registry import ( + STEERING_ELEMENTS, + get_steering_element, + list_steering_elements, + validate_steering_element_keys, +) + +__all__ = [ + "STEERING_ELEMENTS", + "get_steering_element", + "list_steering_elements", + "validate_steering_element_keys", +] diff --git a/backend/steering/elements/registry.py b/backend/steering/elements/registry.py new file mode 100644 index 0000000..58a0ab1 --- /dev/null +++ b/backend/steering/elements/registry.py @@ -0,0 +1,90 @@ +"""Steering element catalog — AP2.4.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + +ModeKey = Literal["plan", "work", "control"] + + +@dataclass(frozen=True) +class SteeringElementDefinition: + key: str + label: str + description: str + modes: tuple[ModeKey, ...] + + +STEERING_ELEMENTS: dict[str, SteeringElementDefinition] = { + "next_action_primary": SteeringElementDefinition( + key="next_action_primary", + label="Nächster Schritt", + description="Next-Action-Widget als primäre Steuerungsansicht", + modes=("control", "work"), + ), + "critical_path": SteeringElementDefinition( + key="critical_path", + label="Kritischer Pfad", + description="Execution-Graph und Pfad-Priorisierung", + modes=("control",), + ), + "gate_fulfillment": SteeringElementDefinition( + key="gate_fulfillment", + label="Gate-Erfüllung", + description="Zielzustände, Verify und Fortschritt", + modes=("control", "plan"), + ), + "work_cycle_scope": SteeringElementDefinition( + key="work_cycle_scope", + label="Sprint-Zeitbox", + description="Aktive work_cycle und Sprint-Backlog", + modes=("work", "plan"), + ), + "recurring_rhythm": SteeringElementDefinition( + key="recurring_rhythm", + label="Rhythmen", + description="RecurringElement und Journey-Fokus", + modes=("control",), + ), + "queue_inbox": SteeringElementDefinition( + key="queue_inbox", + label="Queue / Eingang", + description="Pull-Steuerung aus Backlog/Queue", + modes=("plan", "work"), + ), + "maturity_stage": SteeringElementDefinition( + key="maturity_stage", + label="Reifegrad-Stufen", + description="Stufen und Routinen in Plan/Kontrolle", + modes=("plan", "control"), + ), + "chapter_progression": SteeringElementDefinition( + key="chapter_progression", + label="Kapitel-Fortschritt", + description="Kapitel-basierte Progression", + modes=("plan", "control"), + ), + "dispute_timeline": SteeringElementDefinition( + key="dispute_timeline", + label="Verfahren / Fristen", + description="Fristen, Entscheidungen, Verfahrensstrang", + modes=("control",), + ), +} + + +def get_steering_element(key: str) -> SteeringElementDefinition | None: + return STEERING_ELEMENTS.get(key) + + +def list_steering_elements() -> tuple[SteeringElementDefinition, ...]: + return tuple(STEERING_ELEMENTS.values()) + + +def validate_steering_element_keys(keys: frozenset[str]) -> None: + unknown = [k for k in keys if k not in STEERING_ELEMENTS] + if unknown: + raise ValueError( + f"Unbekannte steering_elements: {', '.join(sorted(unknown))}" + ) diff --git a/backend/steering/graph/profiles.py b/backend/steering/graph/profiles.py index 67289f7..66915a5 100644 --- a/backend/steering/graph/profiles.py +++ b/backend/steering/graph/profiles.py @@ -1,4 +1,4 @@ -"""Method graph profiles — AP1.4e.""" +"""Method graph profiles — AP1.4e / AP2.4 (via MethodDefinition).""" from __future__ import annotations @@ -19,25 +19,20 @@ _STRICT = GraphMethodProfile(enforce_gate_blocking=True, emphasize_fulfillment=F _FULFILLMENT = GraphMethodProfile(enforce_gate_blocking=False, emphasize_fulfillment=True) _LIGHT = GraphMethodProfile(enforce_gate_blocking=False, emphasize_fulfillment=False) -METHOD_GRAPH_PROFILES: dict[str, GraphMethodProfile] = { - "generic_operating": _LIGHT, - "queue_pull": _LIGHT, - "dispute_procedure": _LIGHT, - "continuous_product": _FULFILLMENT, - "maturity_progression": _FULFILLMENT, - "chapter_based_progression": _FULFILLMENT, - "product_milestone_driven": _STRICT, - "program_delivery": _STRICT, - "sequential_dependency": _STRICT, - "agile_iteration": _STRICT, - "recurring_control": _FULFILLMENT, -} +STRICT = _STRICT +FULFILLMENT = _FULFILLMENT +LIGHT = _LIGHT def get_graph_profile(method_key: str | None) -> GraphMethodProfile: if not method_key: - return METHOD_GRAPH_PROFILES[DEFAULT_METHOD_KEY] - return METHOD_GRAPH_PROFILES.get(method_key, _STRICT) + return LIGHT + from steering.methods.registry import get_method + + method = get_method(method_key) + if method: + return method.graph_profile + return _STRICT def enforce_gate_blocking_for_method(method_key: str | None) -> bool: diff --git a/backend/steering/methods/registrations/_helpers.py b/backend/steering/methods/registrations/_helpers.py index e49dbc0..ea421ab 100644 --- a/backend/steering/methods/registrations/_helpers.py +++ b/backend/steering/methods/registrations/_helpers.py @@ -1,9 +1,15 @@ -"""Shared registration helper for AP2.0 method stubs — AP2.3e data_slices.""" +"""Shared registration helper for AP2.0 method stubs — AP2.3e / AP2.4.""" from __future__ import annotations from typing import Literal +from steering.graph.profiles import ( + FULFILLMENT, + GraphMethodProfile, + LIGHT, + STRICT, +) from steering.lifecycle.states import STANDARD_LIFECYCLE_STEPS from steering.methods.registry import MethodDefinition, get_method, register_method @@ -74,6 +80,8 @@ SLICES_GENERIC = SLICES_OM_STANDARD SLICES_AGILE = SLICES_PRODUCT +ELEM_NEXT = frozenset({"next_action_primary"}) + def register_stub_method( *, @@ -84,6 +92,11 @@ def register_stub_method( lifecycle_steps: tuple[str, ...] | None = None, data_slices: frozenset[str] | None = None, compatible_archetype_keys: frozenset[str] | Literal["*"] = "*", + steering_elements: frozenset[str] | None = None, + ui_features: frozenset[str] | None = None, + graph_profile: GraphMethodProfile = LIGHT, + method_role: Literal["primary", "modifier"] = "primary", + composes_with: frozenset[str] | None = None, ) -> None: if get_method(key): return @@ -97,6 +110,11 @@ def register_stub_method( next_action_strategy_key=next_action_strategy_key, data_slices=data_slices or SLICES_GENERIC, compatible_archetype_keys=compatible_archetype_keys, + steering_elements=steering_elements or ELEM_NEXT, + ui_features=ui_features or frozenset(), + graph_profile=graph_profile, + method_role=method_role, + composes_with=composes_with or frozenset(), ) ) @@ -109,6 +127,9 @@ def register_product_like_method( next_action_strategy_key: str, data_slices: frozenset[str] | None = None, compatible_archetype_keys: frozenset[str] | Literal["*"] | None = None, + steering_elements: frozenset[str] | None = None, + ui_features: frozenset[str] | None = None, + graph_profile: GraphMethodProfile = STRICT, ) -> None: register_stub_method( key=key, @@ -120,4 +141,8 @@ def register_product_like_method( compatible_archetype_keys=compatible_archetype_keys if compatible_archetype_keys is not None else frozenset({"initiative.product"}), + steering_elements=steering_elements + or (ELEM_NEXT | frozenset({"gate_fulfillment", "work_cycle_scope"})), + ui_features=ui_features or frozenset(), + graph_profile=graph_profile, ) diff --git a/backend/steering/methods/registrations/ap20_method_stubs.py b/backend/steering/methods/registrations/ap20_method_stubs.py index 1632449..284d051 100644 --- a/backend/steering/methods/registrations/ap20_method_stubs.py +++ b/backend/steering/methods/registrations/ap20_method_stubs.py @@ -1,13 +1,15 @@ -"""AP2.0a — remaining method stubs.""" +"""AP2.0a — remaining method stubs — AP2.4 steering elements.""" from __future__ import annotations +from steering.graph.profiles import FULFILLMENT, LIGHT, STRICT from steering.methods.registrations._helpers import ( + ELEM_NEXT, + SLICES_AGILE, SLICES_MATURITY, + SLICES_OM_STANDARD, SLICES_QUEUE, SLICES_RECURRING, - SLICES_AGILE, - SLICES_OM_STANDARD, register_stub_method, ) @@ -20,6 +22,9 @@ def register() -> None: next_action_strategy_key="maturity_progression", data_slices=SLICES_MATURITY, compatible_archetype_keys=frozenset({"initiative.maturity_journey"}), + steering_elements=ELEM_NEXT + | frozenset({"maturity_stage", "recurring_rhythm"}), + graph_profile=FULFILLMENT, ) register_stub_method( key="sequential_dependency", @@ -28,6 +33,10 @@ def register() -> None: next_action_strategy_key="sequential_dependency", data_slices=SLICES_OM_STANDARD, compatible_archetype_keys="*", + steering_elements=ELEM_NEXT + | frozenset({"critical_path", "gate_fulfillment"}), + ui_features=frozenset({"criticalPathControl"}), + graph_profile=STRICT, ) register_stub_method( key="recurring_control", @@ -36,6 +45,8 @@ def register() -> None: next_action_strategy_key="recurring_control", data_slices=SLICES_RECURRING, compatible_archetype_keys=frozenset({"initiative.recurring_program"}), + steering_elements=ELEM_NEXT | frozenset({"recurring_rhythm"}), + graph_profile=FULFILLMENT, ) register_stub_method( key="queue_pull", @@ -44,6 +55,8 @@ def register() -> None: next_action_strategy_key="queue_pull", data_slices=SLICES_QUEUE, compatible_archetype_keys="*", + steering_elements=ELEM_NEXT | frozenset({"queue_inbox"}), + graph_profile=LIGHT, ) register_stub_method( key="agile_iteration", @@ -52,6 +65,11 @@ def register() -> None: next_action_strategy_key="agile_iteration", data_slices=SLICES_AGILE, compatible_archetype_keys="*", + steering_elements=ELEM_NEXT | frozenset({"work_cycle_scope"}), + ui_features=frozenset({"steeringSnapshotOnWorkSprint"}), + graph_profile=STRICT, + method_role="modifier", + composes_with=frozenset({"continuous_product", "program_delivery"}), ) register_stub_method( key="dispute_procedure", @@ -69,6 +87,8 @@ def register() -> None: } ), compatible_archetype_keys=frozenset({"initiative.dispute_case"}), + steering_elements=ELEM_NEXT | frozenset({"dispute_timeline"}), + graph_profile=LIGHT, ) register_stub_method( key="chapter_based_progression", @@ -87,4 +107,7 @@ def register() -> None: } ), compatible_archetype_keys=frozenset({"initiative.content_project"}), + steering_elements=ELEM_NEXT + | frozenset({"chapter_progression", "gate_fulfillment"}), + graph_profile=FULFILLMENT, ) diff --git a/backend/steering/methods/registrations/continuous_product.py b/backend/steering/methods/registrations/continuous_product.py index 11a27c5..491ff49 100644 --- a/backend/steering/methods/registrations/continuous_product.py +++ b/backend/steering/methods/registrations/continuous_product.py @@ -1,8 +1,12 @@ -"""Built-in method: continuous_product — AP2.0a.""" +"""Built-in method: continuous_product — AP2.0a / AP2.4.""" from __future__ import annotations -from steering.methods.registrations._helpers import register_product_like_method +from steering.graph.profiles import FULFILLMENT +from steering.methods.registrations._helpers import ( + ELEM_NEXT, + register_product_like_method, +) def register() -> None: @@ -11,4 +15,10 @@ def register() -> None: label="Produkt (kontinuierlich)", description="Kontinuierlicher Betrieb — Ist zuerst, Plan als Orientierung", next_action_strategy_key="continuous_product", + graph_profile=FULFILLMENT, + ui_features=frozenset( + {"continuousProductWorkMode", "steeringSnapshotOnWorkSprint"} + ), + steering_elements=ELEM_NEXT + | frozenset({"gate_fulfillment", "work_cycle_scope"}), ) diff --git a/backend/steering/methods/registrations/generic_operating.py b/backend/steering/methods/registrations/generic_operating.py index e5b1c10..df6f498 100644 --- a/backend/steering/methods/registrations/generic_operating.py +++ b/backend/steering/methods/registrations/generic_operating.py @@ -1,10 +1,11 @@ -"""Built-in method: generic_operating.""" +"""Built-in method: generic_operating — AP2.4.""" from __future__ import annotations from steering.lifecycle.states import STANDARD_LIFECYCLE_STEPS +from steering.graph.profiles import LIGHT +from steering.methods.registrations._helpers import ELEM_NEXT, SLICES_GENERIC from steering.methods.registry import MethodDefinition, get_method, register_method -from steering.methods.registrations._helpers import SLICES_GENERIC def register() -> None: @@ -20,5 +21,7 @@ def register() -> None: next_action_strategy_key="default", data_slices=SLICES_GENERIC, compatible_archetype_keys="*", + steering_elements=ELEM_NEXT, + graph_profile=LIGHT, ) ) diff --git a/backend/steering/methods/registrations/product_milestone_driven.py b/backend/steering/methods/registrations/product_milestone_driven.py index cdf7150..f9356de 100644 --- a/backend/steering/methods/registrations/product_milestone_driven.py +++ b/backend/steering/methods/registrations/product_milestone_driven.py @@ -1,10 +1,10 @@ -"""Built-in method: product_milestone_driven — AP1.1.""" +"""Built-in method: product_milestone_driven — AP1.1 / AP2.4.""" from __future__ import annotations -from steering.lifecycle.states import STANDARD_LIFECYCLE_STEPS +from steering.graph.profiles import STRICT +from steering.methods.registrations._helpers import ELEM_NEXT, SLICES_PRODUCT from steering.methods.registry import MethodDefinition, get_method, register_method -from steering.methods.registrations._helpers import SLICES_PRODUCT _PRODUCT_STEPS = ( "intake", @@ -42,5 +42,8 @@ def register() -> None: "initiative.program", } ), + steering_elements=ELEM_NEXT + | frozenset({"gate_fulfillment", "work_cycle_scope"}), + graph_profile=STRICT, ) ) diff --git a/backend/steering/methods/registrations/program_delivery.py b/backend/steering/methods/registrations/program_delivery.py index b45a7e7..fbe1298 100644 --- a/backend/steering/methods/registrations/program_delivery.py +++ b/backend/steering/methods/registrations/program_delivery.py @@ -1,8 +1,9 @@ -"""Built-in method: program_delivery — AP2.0a.""" +"""Built-in method: program_delivery — AP2.0a / AP2.4.""" from __future__ import annotations from steering.methods.registrations._helpers import ( + ELEM_NEXT, SLICES_PROGRAM, register_product_like_method, ) @@ -16,4 +17,6 @@ def register() -> None: next_action_strategy_key="program_delivery", data_slices=SLICES_PROGRAM, compatible_archetype_keys=frozenset({"initiative.program"}), + steering_elements=ELEM_NEXT + | frozenset({"gate_fulfillment", "work_cycle_scope"}), ) diff --git a/backend/steering/methods/registry.py b/backend/steering/methods/registry.py index c421cbb..5afc22a 100644 --- a/backend/steering/methods/registry.py +++ b/backend/steering/methods/registry.py @@ -1,9 +1,12 @@ -"""Method Registry — built-in steering methods.""" +"""Method Registry — built-in steering methods — AP2.3e / AP2.4.""" from __future__ import annotations from dataclasses import dataclass -from typing import Literal +from typing import Any, Literal + +from steering.elements.registry import validate_steering_element_keys +from steering.graph.profiles import GraphMethodProfile, LIGHT _METHODS: dict[str, "MethodDefinition"] = {} @@ -18,20 +21,51 @@ class MethodDefinition: next_action_strategy_key: str = "default" data_slices: frozenset[str] = frozenset() compatible_archetype_keys: frozenset[str] | Literal["*"] = "*" + steering_elements: frozenset[str] = frozenset() + ui_features: frozenset[str] = frozenset() + graph_profile: GraphMethodProfile = LIGHT + method_role: Literal["primary", "modifier"] = "primary" + composes_with: frozenset[str] = frozenset() + + +def ui_features_as_dict(features: frozenset[str]) -> dict[str, bool]: + return {key: True for key in sorted(features)} def method_compatible_with_archetype( - method: MethodDefinition, archetype_key: str + method: MethodDefinition, + archetype_key: str, + *, + om_capabilities: frozenset[str] | None = None, ) -> bool: compat = method.compatible_archetype_keys - if compat == "*": - return True - return archetype_key in compat + if compat != "*" and archetype_key not in compat: + return False + if om_capabilities is not None and method.data_slices: + if not method.data_slices.issubset(om_capabilities): + return False + return True + + +def list_compatible_methods( + archetype_key: str, + *, + om_capabilities: frozenset[str] | None = None, +) -> tuple[MethodDefinition, ...]: + return tuple( + m + for m in _METHODS.values() + if method_compatible_with_archetype( + m, archetype_key, om_capabilities=om_capabilities + ) + ) def register_method(defn: MethodDefinition) -> None: if defn.key in _METHODS: raise ValueError(f"Method already registered: {defn.key}") + if defn.steering_elements: + validate_steering_element_keys(defn.steering_elements) _METHODS[defn.key] = defn @@ -43,5 +77,34 @@ def list_methods() -> tuple[MethodDefinition, ...]: return tuple(_METHODS.values()) +def method_to_dict( + method: MethodDefinition, + *, + include_compatibility: bool = True, +) -> dict[str, Any]: + payload: dict[str, Any] = { + "key": method.key, + "version": method.version, + "label": method.label, + "description": method.description, + "next_action_strategy_key": method.next_action_strategy_key, + "steering_elements": sorted(method.steering_elements), + "ui_features": ui_features_as_dict(method.ui_features), + "graph_profile": { + "enforce_gate_blocking": method.graph_profile.enforce_gate_blocking, + "emphasize_fulfillment": method.graph_profile.emphasize_fulfillment, + }, + "method_role": method.method_role, + "composes_with": sorted(method.composes_with), + "data_slices": sorted(method.data_slices), + } + if include_compatibility: + compat = method.compatible_archetype_keys + payload["compatible_archetype_keys"] = ( + "*" if compat == "*" else sorted(compat) + ) + return payload + + def clear_methods_for_tests() -> None: _METHODS.clear() diff --git a/backend/tests/test_ap23a_operating_context.py b/backend/tests/test_ap23a_operating_context.py index 7b1d79f..7c9bc94 100644 --- a/backend/tests/test_ap23a_operating_context.py +++ b/backend/tests/test_ap23a_operating_context.py @@ -55,6 +55,7 @@ def test_operating_context_linear(client): assert res.status_code == 200 body = res.json() assert body["method_key"] == "sequential_dependency" + assert "critical_path" in body["steering_elements"] assert body["ui_profile"]["planDefaultRoute"] == "/plan/gates" assert "work_cycles" not in body["data_slices"] diff --git a/backend/tests/test_ap24_steering_elements.py b/backend/tests/test_ap24_steering_elements.py new file mode 100644 index 0000000..4688455 --- /dev/null +++ b/backend/tests/test_ap24_steering_elements.py @@ -0,0 +1,124 @@ +"""AP2.4 — Steering elements and method contract.""" + +from __future__ import annotations + +from steering.elements.registry import validate_steering_element_keys +from steering.methods.registry import get_method, list_compatible_methods, ui_features_as_dict +from steering.graph.profiles import get_graph_profile + + +def test_all_methods_have_valid_steering_elements(): + from steering.methods.registry import list_methods + + for method in list_methods(): + validate_steering_element_keys(method.steering_elements) + + +def test_sequential_dependency_contract(): + method = get_method("sequential_dependency") + assert method is not None + assert "critical_path" in method.steering_elements + assert "criticalPathControl" in method.ui_features + assert get_graph_profile("sequential_dependency").enforce_gate_blocking is True + + +def test_continuous_product_ui_features_from_method(): + method = get_method("continuous_product") + assert method is not None + features = ui_features_as_dict(method.ui_features) + assert features["continuousProductWorkMode"] is True + assert features["steeringSnapshotOnWorkSprint"] is True + + +def test_list_compatible_methods_linear_includes_queue_pull(): + methods = list_compatible_methods("initiative.linear_project") + keys = {m.key for m in methods} + assert "sequential_dependency" in keys + assert "queue_pull" in keys + + +def test_list_compatible_methods_product_excludes_maturity(): + methods = list_compatible_methods("initiative.product") + keys = {m.key for m in methods} + assert "continuous_product" in keys + assert "maturity_progression" not in keys + + +def test_operating_context_includes_method_contract(client): + from tests.factories import provision_user_in_tenant + from tests.test_initiatives_actions import _auth, _create_initiative, _login + + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + + created = _create_initiative( + client, + token, + title="Linear Contract", + archetype_key="initiative.linear_project", + ) + initiative_id = created.json()["id"] + + res = client.get( + f"/api/initiatives/{initiative_id}/operating-context", + headers=_auth(token), + ) + assert res.status_code == 200 + body = res.json() + assert "critical_path" in body["steering_elements"] + assert body["ui_features"].get("criticalPathControl") is True + assert "graph_profile" in body + assert body["compatible_methods"] + assert any(m["key"] == "sequential_dependency" for m in body["compatible_methods"]) + + +def test_steering_methods_filtered_by_archetype(client): + from tests.factories import provision_user_in_tenant + from tests.test_initiatives_actions import _auth, _login + + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + + res = client.get( + "/api/steering/methods", + params={"archetype_key": "initiative.product"}, + headers=_auth(token), + ) + assert res.status_code == 200 + keys = {m["key"] for m in res.json()} + assert "continuous_product" in keys + assert "maturity_progression" not in keys + product = next(m for m in res.json() if m["key"] == "continuous_product") + assert "steering_elements" in product + assert product["is_default"] is True + + +def test_queue_pull_on_linear_changes_steering_elements(client): + from tests.factories import provision_user_in_tenant + from tests.test_initiatives_actions import _auth, _create_initiative, _login + + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + + created = _create_initiative( + client, + token, + title="Linear Queue", + archetype_key="initiative.linear_project", + ) + initiative_id = created.json()["id"] + + client.patch( + f"/api/steering/initiatives/{initiative_id}/context", + json={"method_key": "queue_pull"}, + headers=_auth(token), + ) + + ctx = client.get( + f"/api/initiatives/{initiative_id}/operating-context", + headers=_auth(token), + ) + body = ctx.json() + assert "queue_inbox" in body["steering_elements"] + assert "critical_path" not in body["steering_elements"] + assert body["ui_features"].get("criticalPathControl") is not True diff --git a/docs/architecture/ADP_AP2_4_Steering_Elements_and_Method_Contract_v0.1.md b/docs/architecture/ADP_AP2_4_Steering_Elements_and_Method_Contract_v0.1.md new file mode 100644 index 0000000..3ea448d --- /dev/null +++ b/docs/architecture/ADP_AP2_4_Steering_Elements_and_Method_Contract_v0.1.md @@ -0,0 +1,228 @@ +# ADP — Steuerungselemente & Methoden-Vertrag v0.1 (AP2.4) + +**Status:** vorgeschlagen — Implementierung AP2.3g/AP2.4 +**Stand:** 2026-07-25 +**Bezug:** `ADP_Archetype_Method_Plugin_Architecture_v0.1.md` (AP2.3), `ADP_Archetype_and_Method_Catalog_v0.2.md`, `Kairo_Method_Design_Principles_v0.1.md` +**Ersetzt nicht:** AP2.3 A–F; **vertieft** Schicht 1 (Methode) und schließt Restschuld AP2.3g + +--- + +## 1. Problem + +AP2.3 hat Archetyp (Schicht 2) und Methode (Schicht 1) technisch entkoppelt: + +- `GET /operating-context` liefert `ui_profile` + `data_slices` +- `MethodDefinition` trägt `data_slices` + `compatible_archetype_keys` + +**Offene Lücke:** Steuerungsverhalten und Control-UI hängen noch an verstreuten Stellen: + +| Verhalten | Heute | Soll | +|-----------|-------|------| +| Kritischer Pfad | FE-Hack `METHOD_UI_FEATURES` + Archetyp-If | Methode `sequential_dependency` | +| Product ohne Sprint | Archetyp `uiFeatures` | Methode `continuous_product` | +| Graph-Strenge | `graph/profiles.py` separat | Methoden-Vertrag | +| Control-Panels | Page-Ifs | Steuerungselement-Registry | +| Methodenwahl UI | alle Methoden | nur kompatible pro Archetyp | + +**PO-Anforderung:** Jeder Archetyp kann mit **unterschiedlichen Steuerungsmethoden** geführt werden. Methoden bringen **eigene Steuerungselemente** mit und müssen **explizit** mit Archetypen kompatibel sein — ohne React-Ifs und ohne Duplikat-Registry. + +--- + +## 2. Leitentscheidung + +### 2.1 Drei Registries, eine Auflösung + +```text +Archetyp-Registry (Schicht 2) → IA-Hülle, OM-Fähigkeiten, Default-Methode +Methoden-Registry (Schicht 1) → Motor: Strategie, Slices, Elemente, Graph +Steuerungselement-Registry → Katalog benannter Control-/Work-Bausteine + +GET /initiatives/:id/operating-context + → resolve(archetype, method, profile?) +``` + +### 2.2 Auflösungsregeln (verbindlich) + +| Feld | Quelle | Regel | +|------|--------|-------| +| `ui_profile` (Nav, Routen, Outline) | **Archetyp** | unverändert | +| `data_slices` | **Schnittmenge** | `archetype.om_capabilities ∩ method.data_slices` | +| `steering_elements` | **Methode** | nur aktive Methode | +| `ui_features` | **Methode** | Verhaltensflags Work/Control | +| `graph_profile` | **Methode** | Gate-Blocking, Fulfillment | +| `method_capabilities` | **Methode** | Strategie, Lifecycle | +| `compatible_methods` | **Registry** | gefiltert nach Archetyp | + +**Archetyp ≠ Methode:** Default-Methode ist **Empfehlung**, nicht Lock. Nutzer mit `kairo.initiative.manage` darf jede **kompatible** Methode wählen. + +### 2.3 Kompatibilität + +```text +compatible(archetype, method) := + archetype_key ∈ method.compatible_archetype_keys (oder "*") + AND method.data_slices ⊆ archetype.om_capabilities +``` + +`om_capabilities` im Archetyp-Profil entspricht fachlich den erlaubten OM-Slices (Fallback: `dataSlices` aus `ui_profile_json`). + +### 2.4 Methoden-Rollen (Komposition) + +| Rolle | Bedeutung | Beispiel | +|-------|-----------|----------| +| `primary` | Einziger Motor am `steering_context.method_key` | `continuous_product` | +| `modifier` | Ergänzt Primärmethode über Profil/Policy | `agile_iteration` (Sprint auf Product) | + +**MVP:** ein `method_key` pro Vorhaben; `agile_iteration` als Modifier über `method_profile_key` / `work_cycle`-Policy — kein zweiter SteeringContext. + +--- + +## 3. Steuerungselemente (Schicht 1b) + +Ein **Steuerungselement** ist ein stabiler Key, den eine Methode aktiviert. Backend und Frontend registrieren sich darauf. + +| Key | Bedeutung | Typische Methode(n) | +|-----|-----------|---------------------| +| `next_action_primary` | Next-Action-Widget als Steuerungsdefault | alle | +| `critical_path` | Kritischer Pfad / Execution-Graph | `sequential_dependency` | +| `gate_fulfillment` | Gate-Fortschritt, Verify-Semantik | `program_delivery`, `product_milestone_driven`, … | +| `work_cycle_scope` | Sprint-Zeitbox, Sprint-Backlog | `continuous_product`, `agile_iteration` | +| `recurring_rhythm` | Rhythmen / Journey-Fokus | `maturity_progression`, `recurring_control` | +| `queue_inbox` | Queue-Pull, Eingang prominent | `queue_pull` | +| `maturity_stage` | Reifegrad-Stufen | `maturity_progression` | +| `chapter_progression` | Kapitel-Fortschritt | `chapter_based_progression` | +| `dispute_timeline` | Fristen / Verfahren | `dispute_procedure` | + +**Registry:** `backend/steering/elements/registry.py` +**Frontend:** `frontend/src/registry/steeringElementRegistry.js` — Element → Panel/Widget + +**Regel:** Control-/Work-Seiten composen UI aus `operating_context.steering_elements` — **keine** Archetyp-Ifs. + +### 3.1 UI-Features (Verhaltensflags) + +Zusätzlich zu Elementen (was gerendert wird) können Methoden **Verhaltensflags** setzen: + +| Key | Bedeutung | +|-----|-----------| +| `criticalPathControl` | Next-Action-Copy / Pfad-Fokus | +| `continuousProductWorkMode` | Product-Ist ohne aktiven Sprint | +| `steeringSnapshotOnWorkSprint` | Snapshot lazy auf Work/Sprint | + +Quelle: **nur Methode** (`MethodDefinition.ui_features`), nicht Archetyp. + +--- + +## 4. Methoden-Vertrag (`MethodDefinition`) + +Erweiterung gegenüber AP2.3e: + +```python +@dataclass(frozen=True) +class MethodDefinition: + key: str + version: str + label: str + description: str + default_lifecycle_steps: tuple[str, ...] + next_action_strategy_key: str + data_slices: frozenset[str] + compatible_archetype_keys: frozenset[str] | Literal["*"] + steering_elements: frozenset[str] = frozenset() + ui_features: frozenset[str] = frozenset() + graph_profile: GraphMethodProfile = LIGHT + method_role: Literal["primary", "modifier"] = "primary" + composes_with: frozenset[str] = frozenset() +``` + +`GraphMethodProfile` wandert aus `graph/profiles.py` in den Methoden-Vertrag; `get_graph_profile(method_key)` liest aus Registry. + +--- + +## 5. Operating Context (API-Erweiterung) + +`GET /api/initiatives/{id}/operating-context` liefert zusätzlich: + +```json +{ + "steering_elements": ["critical_path", "next_action_primary"], + "ui_features": { "criticalPathControl": true }, + "graph_profile": { + "enforce_gate_blocking": true, + "emphasize_fulfillment": false + }, + "compatible_methods": [ + { "key": "sequential_dependency", "label": "…", "is_default": true } + ] +} +``` + +`ui_profile.uiFeatures` am Archetyp wird **deprecated** (leer); Migration in Methoden-Registrations. + +--- + +## 6. Steering API + +`GET /api/steering/methods?archetype_key=initiative.linear_project` + +- Filtert nach `method_compatible_with_archetype` +- Response enthält `steering_elements`, `ui_features`, `compatible_archetype_keys` + +Methoden-Dropdown in Control zeigt nur kompatible Einträge. + +--- + +## 7. Implementierungspakete + +| Paket | Lieferung | Abhängigkeit | +|-------|-----------|--------------| +| **AP2.4a** | `steering/elements/registry.py`, erweiterte `MethodDefinition`, Registrations | AP2.3e | +| **AP2.4b** | `operating-context` + `/steering/methods` Filter | AP2.4a | +| **AP2.4c** | FE Resolver, `steeringElementRegistry`, Pages ohne Ifs | AP2.4b | +| **AP2.4d** | pytest + Vitest + Truth Table | AP2.4c | + +Optional **AP2.4e:** explizites `omCapabilities` am Archetyp-Seed (statt `dataSlices`-Missbrauch). + +--- + +## 8. Erweiterungs-Workflow + +**Neue Methode `foo_bar`:** + +1. `steering/methods/registrations/foo_bar.py` — Contract vollständig +2. Nur Keys aus `steering/elements/registry.py` referenzieren +3. Import in `steering/__init__.py` +4. pytest `test_method_foo_bar_contract.py` +5. Kein Core-Edit außer Registry-Dispatcher + +**Neues Steuerungselement `my_panel`:** + +1. Eintrag in `steering/elements/registry.py` +2. FE `steeringElementRegistry.js` +3. Methoden, die es brauchen, tragen Key in `steering_elements` + +--- + +## 9. Nicht tun + +- Keine Archetyp-Ifs für Control (`initiative.linear_project === …`) +- Keine parallele Graph-Profile-Map ohne Methoden-Bezug +- Keine Tenant-DSL für Methoden +- Keine zweite SteeringContext-Tabelle für Komposition + +--- + +## 10. Abnahme + +| Kriterium | Test | +|-----------|------| +| Operating Context liefert `steering_elements` + `ui_features` aus Methode | pytest AP2.4 | +| Linear + `queue_pull` → keine `critical_path`, kleinere Slices | pytest AP2.3e | +| `/steering/methods?archetype_key=…` filtert | pytest AP2.4 | +| Control rendert Critical Path nur bei Element | Vitest + manuell | +| Kein `METHOD_UI_FEATURES` im FE | Code-Review | + +--- + +## 11. Bezug AP2.3 + +AP2.3 A–F liefert Operating Context, Slice-Loader, Route Gating. +AP2.4 **schließt die methodenunabhängige Steuerungsschicht** — Voraussetzung für Archetyp-Welle 2 (B1, A3, D1) ohne Page-Patches. diff --git a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md index 71bb5f8..d2b9b06 100644 --- a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md +++ b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md @@ -85,7 +85,8 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden. | AttentionItem | ◐ | | | Initiative Steering Snapshot | ✓ | Actions + linked; Archetyp/Guidance AP2.0c | | Operating Context API | ✓ | AP2.3a: `GET …/operating-context`, `ui_profile_json` Migration 027 | -| Archetyp-/Methoden-Plugin-Architektur | ◐ | AP2.3 A–E ✓; AP2.3f Restschuld (Resolver, Route-Gating, uiFeatures, slice reload) ◐ | +| Archetyp-/Methoden-Plugin-Architektur | ◐ | AP2.3 A–F ✓; AP2.4 Steuerungselemente + Methoden-Vertrag ◐ | +| Steuerungselement-Registry | ◐ | AP2.4: `steering/elements/`, FE `steeringElementRegistry` | | operating_phase | ✗ | entfernt AP1.2 | | signals (Snapshot) | ✓ | `snapshot_signals.py` | | Graph Read Models (blocked/ready) | ◐ | AP1.4d/e; Join/OR AP1.15d deferred | diff --git a/frontend/src/api/steering.js b/frontend/src/api/steering.js index 65fece5..b5a0a01 100644 --- a/frontend/src/api/steering.js +++ b/frontend/src/api/steering.js @@ -1,7 +1,10 @@ import { apiFetch } from './client.js' -export function listSteeringMethods() { - return apiFetch('/api/steering/methods') +export function listSteeringMethods(archetypeKey = null) { + const qs = archetypeKey + ? `?archetype_key=${encodeURIComponent(archetypeKey)}` + : '' + return apiFetch(`/api/steering/methods${qs}`) } export function updateInitiativeSteeringMethod(initiativeId, methodKey) { diff --git a/frontend/src/context/InitiativeOperationsContext.jsx b/frontend/src/context/InitiativeOperationsContext.jsx index a71409c..0f423c4 100644 --- a/frontend/src/context/InitiativeOperationsContext.jsx +++ b/frontend/src/context/InitiativeOperationsContext.jsx @@ -998,6 +998,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ ) const uiFeatures = operatingProfile.uiFeatures || {} + const steeringElements = operatingProfile.steeringElements || [] const value = { initiativeId: id, @@ -1031,6 +1032,7 @@ export function InitiativeOperationsProvider({ children, initiativeId: initiativ operatingContext, dataSlices: operatingContext?.data_slices || [], uiFeatures, + steeringElements, operatingProfile, methodBusy, loading, diff --git a/frontend/src/pages/initiative/InitiativeOverviewPage.jsx b/frontend/src/pages/initiative/InitiativeOverviewPage.jsx index 7ffe4bb..181e901 100644 --- a/frontend/src/pages/initiative/InitiativeOverviewPage.jsx +++ b/frontend/src/pages/initiative/InitiativeOverviewPage.jsx @@ -4,11 +4,14 @@ import { useInitiativeOperations } from '../../context/InitiativeOperationsConte import { SteeringSnapshotPanel } from '../../components/SteeringSnapshotPanel.jsx' import { CriticalPathPanel } from '../../components/CriticalPathPanel.jsx' import { NextActionWidget } from '../../widgets/NextActionWidget.jsx' +import { + hasSteeringElement, + steeringElementUi, +} from '../../registry/steeringElementRegistry.js' export function InitiativeOverviewPage() { const { initiativeId, - initiative, actions, error, steeringSnapshot, @@ -18,11 +21,12 @@ export function InitiativeOverviewPage() { methodBusy, capabilities, handleMethodChange, - uiFeatures, + steeringElements, } = useInitiativeOperations() const counts = steeringSnapshot?.counts || {} - const showCriticalPath = Boolean(uiFeatures.criticalPathControl) + const showCriticalPath = hasSteeringElement(steeringElements, 'critical_path') + const criticalPathUi = steeringElementUi(steeringElements, 'critical_path') return ( <> @@ -51,16 +55,8 @@ export function InitiativeOverviewPage() { items={steeringSnapshot?.next_actions} loading={steeringSnapshotLoading} embedded - title={ - showCriticalPath - ? 'Nächster Schritt am kritischen Pfad' - : undefined - } - subtitle={ - showCriticalPath - ? 'Empfehlung aus sequential_dependency — bereite Arbeitspakete zuerst.' - : undefined - } + title={criticalPathUi?.nextActionTitle} + subtitle={criticalPathUi?.nextActionSubtitle} /> )} diff --git a/frontend/src/registry/operatingSliceLoaders.js b/frontend/src/registry/operatingSliceLoaders.js index 4f01aad..a42e176 100644 --- a/frontend/src/registry/operatingSliceLoaders.js +++ b/frontend/src/registry/operatingSliceLoaders.js @@ -36,7 +36,10 @@ export const SLICE_LOADERS = { workCycles: await listInitiativeWorkCycles(initiativeId), activeWorkCycle: await getActiveWorkCycle(initiativeId).catch(() => null), }), - steering_methods: () => listSteeringMethods(), + steering_methods: async (initiativeId) => { + const initiative = await getInitiative(initiativeId) + return listSteeringMethods(initiative?.archetype_key || null) + }, steering_snapshot: (initiativeId) => getInitiativeSteeringSnapshot(initiativeId), } diff --git a/frontend/src/registry/resolveOperatingProfile.js b/frontend/src/registry/resolveOperatingProfile.js index d174529..9cb7501 100644 --- a/frontend/src/registry/resolveOperatingProfile.js +++ b/frontend/src/registry/resolveOperatingProfile.js @@ -1,5 +1,5 @@ /** - * AP2.3b/f — Operating Profile Resolver (API-first, methodUiDefaults nur als Fallback). + * AP2.3b/f / AP2.4 — Operating Profile Resolver (API-first, methodUiDefaults nur als Fallback). */ import { PLAN_OUTLINE_NODES } from '../plan/planOutlineNodes.js' import { WORK_NAV_ITEMS } from '../config/workNav.js' @@ -20,9 +20,14 @@ import { * archetype_key?: string | null, * method_key?: string | null, * method_profile_key?: string | null, - * ui_profile?: Partial, workDefaultRouteWithoutActiveWorkCycle?: string }> | null, + * ui_profile?: Partial | null, * data_slices?: string[], + * om_capabilities?: string[], * method_capabilities?: object, + * steering_elements?: string[], + * ui_features?: Record, + * graph_profile?: { enforce_gate_blocking?: boolean, emphasize_fulfillment?: boolean }, + * compatible_methods?: object[], * }} OperatingContextResponse * @typedef {{ * archetypeKey?: string | null, @@ -40,20 +45,21 @@ const EMPTY_PROFILE = { planOutlineKeys: null, workNavKeys: null, uiFeatures: {}, + steeringElements: [], } -/** Methoden-Features ergänzen Archetyp-Profil (bis MethodDefinition ui_features trägt). */ -const METHOD_UI_FEATURES = { - sequential_dependency: { criticalPathControl: true }, - continuous_product: { - continuousProductWorkMode: true, - steeringSnapshotOnWorkSprint: true, - }, +function resolveUiFeatures(context, profileFeatures) { + if (context?.ui_features && Object.keys(context.ui_features).length) { + return { ...(context.ui_features || {}) } + } + return { ...(profileFeatures || {}) } } -function mergeUiFeatures(profileFeatures, methodKey) { - const methodFeatures = METHOD_UI_FEATURES[methodKey] || {} - return { ...methodFeatures, ...(profileFeatures || {}) } +function resolveSteeringElements(context) { + if (Array.isArray(context?.steering_elements)) { + return [...context.steering_elements] + } + return [] } function resolveWorkDefaultRoute(profile, options = {}) { @@ -67,7 +73,6 @@ function resolveWorkDefaultRoute(profile, options = {}) { /** * @param {OperatingContextResponse | null | undefined} context * @param {{ hasActiveSprint?: boolean }} [options] - * @returns {MethodUiDefaults & { dataSlices: string[], uiFeatures: Record }} */ export function resolveOperatingProfile(context, options = {}) { if (context?.ui_profile) { @@ -81,7 +86,8 @@ export function resolveOperatingProfile(context, options = {}) { planOutlineKeys: profile.planOutlineKeys ?? null, workNavKeys: profile.workNavKeys ?? null, dataSlices: context.data_slices || profile.dataSlices || [], - uiFeatures: mergeUiFeatures(profile.uiFeatures, context?.method_key), + uiFeatures: resolveUiFeatures(context, profile.uiFeatures), + steeringElements: resolveSteeringElements(context), } } @@ -93,13 +99,13 @@ export function resolveOperatingProfile(context, options = {}) { return { ...fallback, dataSlices: context?.data_slices || [], - uiFeatures: mergeUiFeatures({}, context?.method_key), + uiFeatures: resolveUiFeatures(context, {}), + steeringElements: resolveSteeringElements(context), } } /** * @param {ResolveOperatingProfileInput} input - * @returns {MethodUiDefaults & { dataSlices: string[], uiFeatures: Record }} */ export function resolveOperatingProfileFromInput(input = {}) { if (input.operatingContext) { @@ -112,7 +118,7 @@ export function resolveOperatingProfileFromInput(input = {}) { methodKey: input.methodKey || null, hasActiveSprint: input.hasActiveSprint, }) - return { ...fallback, dataSlices: [], uiFeatures: {} } + return { ...fallback, dataSlices: [], uiFeatures: {}, steeringElements: [] } } /** diff --git a/frontend/src/registry/resolveOperatingProfile.test.js b/frontend/src/registry/resolveOperatingProfile.test.js index 6640c78..af1fa1a 100644 --- a/frontend/src/registry/resolveOperatingProfile.test.js +++ b/frontend/src/registry/resolveOperatingProfile.test.js @@ -5,6 +5,7 @@ import { resolveOperatingProfileFromInput, resolveRedirectForDisallowedRoute, } from './resolveOperatingProfile.js' +import { hasSteeringElement } from './steeringElementRegistry.js' const PRODUCT_CONTEXT = { initiative_id: 'test-id', @@ -23,6 +24,11 @@ const PRODUCT_CONTEXT = { workNavKeys: ['sprint', 'today', 'mine'], }, data_slices: ['backlog', 'actions', 'roadmap', 'work_cycles', 'projects'], + steering_elements: ['next_action_primary', 'work_cycle_scope', 'gate_fulfillment'], + ui_features: { + continuousProductWorkMode: true, + steeringSnapshotOnWorkSprint: true, + }, } const SUPPORT_QUEUE_CONTEXT = { @@ -37,6 +43,8 @@ const SUPPORT_QUEUE_CONTEXT = { workNavKeys: ['today', 'mine'], }, data_slices: ['backlog', 'actions', 'blockers'], + steering_elements: ['next_action_primary', 'queue_inbox'], + ui_features: {}, } describe('resolveOperatingProfile', () => { @@ -75,24 +83,28 @@ describe('resolveOperatingProfile', () => { expect(isRouteAllowedForProfile('/plan/sprint', input)).toBe(true) }) - it('liefert uiFeatures aus Archetyp-Profil', () => { - const profile = resolveOperatingProfile({ - ...PRODUCT_CONTEXT, - ui_profile: { - ...PRODUCT_CONTEXT.ui_profile, - uiFeatures: { continuousProductWorkMode: true }, - }, - }) + it('liefert uiFeatures aus Methoden-Vertrag (API)', () => { + const profile = resolveOperatingProfile(PRODUCT_CONTEXT) expect(profile.uiFeatures.continuousProductWorkMode).toBe(true) + expect(profile.uiFeatures.steeringSnapshotOnWorkSprint).toBe(true) }) - it('sequential_dependency ergänzt criticalPathControl', () => { + it('liefert steeringElements aus Methoden-Vertrag (API)', () => { const profile = resolveOperatingProfile({ - archetype_key: 'initiative.generic', + archetype_key: 'initiative.linear_project', method_key: 'sequential_dependency', - ui_profile: { uiFeatures: {} }, - data_slices: ['actions'], + ui_profile: PRODUCT_CONTEXT.ui_profile, + steering_elements: ['critical_path', 'next_action_primary'], + ui_features: { criticalPathControl: true }, }) + expect(hasSteeringElement(profile.steeringElements, 'critical_path')).toBe(true) expect(profile.uiFeatures.criticalPathControl).toBe(true) }) }) + +describe('steeringElementRegistry', () => { + it('hasSteeringElement erkennt aktive Elemente', () => { + expect(hasSteeringElement(['critical_path'], 'critical_path')).toBe(true) + expect(hasSteeringElement(['queue_inbox'], 'critical_path')).toBe(false) + }) +}) diff --git a/frontend/src/registry/steeringElementRegistry.js b/frontend/src/registry/steeringElementRegistry.js new file mode 100644 index 0000000..c8a0d85 --- /dev/null +++ b/frontend/src/registry/steeringElementRegistry.js @@ -0,0 +1,33 @@ +/** + * AP2.4 — Steuerungselement-Registry (Element → UI-Semantik). + */ + +/** @type {Record} */ +export const STEERING_ELEMENT_UI = { + critical_path: { + nextActionTitle: 'Nächster Schritt am kritischen Pfad', + nextActionSubtitle: + 'Empfehlung aus sequenzieller Abhängigkeit — bereite Arbeitspakete zuerst.', + }, + queue_inbox: { + nextActionTitle: 'Nächster Schritt aus der Queue', + nextActionSubtitle: 'Pull-Empfehlung aus dem Eingang.', + }, +} + +/** + * @param {string[] | undefined | null} elements + * @param {string} key + */ +export function hasSteeringElement(elements, key) { + return Array.isArray(elements) && elements.includes(key) +} + +/** + * @param {string[] | undefined | null} elements + * @param {string} key + */ +export function steeringElementUi(elements, key) { + if (!hasSteeringElement(elements, key)) return null + return STEERING_ELEMENT_UI[key] || null +}