diff --git a/.cursor/rules/kairo-plugin-architecture.mdc b/.cursor/rules/kairo-plugin-architecture.mdc index e371cc5..44324a4 100644 --- a/.cursor/rules/kairo-plugin-architecture.mdc +++ b/.cursor/rules/kairo-plugin-architecture.mdc @@ -6,7 +6,7 @@ alwaysApply: true # Kairo Plugin-Architektur (AP2.3 / AP2.4) -Verbindliche ADPs: `docs/architecture/ADP_Archetype_Method_Plugin_Architecture_v0.1.md`, `docs/architecture/ADP_AP2_4_Steering_Elements_and_Method_Contract_v0.1.md` +Verbindliche ADPs: `docs/architecture/ADP_Archetype_Method_Plugin_Architecture_v0.1.md`, `docs/architecture/ADP_AP2_4_Steering_Elements_and_Method_Contract_v0.1.md`, `docs/architecture/ADP_Steering_Kernel_Spine_v0.1.md` ## Vier Schichten — Auflösung zur Laufzeit @@ -15,7 +15,7 @@ Verbindliche ADPs: `docs/architecture/ADP_Archetype_Method_Plugin_Architecture_v | **Archetyp** | `entity_archetypes` + `ui_profile_json` | Nav, Routen, Plan-Outline, `om_capabilities` | | **Methode** | `steering/methods/registrations/` | `data_slices`, `steering_elements`, `ui_features`, `graph_profile`, Strategie | | **Element** | `steering/elements/registry.py` + FE `steeringElementRegistry.js` | Control-/Work-Bausteine | -| **Runtime** | `GET /api/initiatives/:id/operating-context` | Schnittmenge + Methoden-Vertrag | +| **Runtime** | `GET /api/initiatives/:id/operating-context` + **`steering/kernel/evaluate_steering()`** | Plugin-Auflösung + **Herzmaschine** | ```text data_slices = archetype.om_capabilities ∩ method.data_slices @@ -48,6 +48,7 @@ Archetyp-Default-Methode ist **Empfehlung**, nicht Lock — kompatible Methoden - Hardcodierte Methoden-UI in `methodUiDefaults.js` für **neue** Archetypen - Parallele Steuerungslogik außerhalb `backend/steering/` - Methoden-Dropdown ohne Archetyp-Filter (`GET /steering/methods?archetype_key=…`) +- **Initiative-Steuerung** (Next, Attention) außerhalb `steering/kernel/evaluate_steering()` — siehe `ADP_Steering_Kernel_Spine_v0.1.md` ## Fallback (legacy) diff --git a/backend/data_layer/initiative_snapshot.py b/backend/data_layer/initiative_snapshot.py index 6da1807..3035c0f 100644 --- a/backend/data_layer/initiative_snapshot.py +++ b/backend/data_layer/initiative_snapshot.py @@ -10,10 +10,10 @@ from typing import Any, Optional from psycopg2.extras import RealDictCursor from db import get_connection -from data_layer.attention import get_next_action_candidates_for_initiative from entity_archetypes.registry import archetype_label from method_profiles.registry import get_method_profile from steering.context import get_steering_context_dto +from steering.kernel import evaluate_steering from steering.signals.snapshot_signals import derive_initiative_signals from services.initiatives import get_initiative from tenant_context import TenantContext @@ -293,9 +293,9 @@ def get_initiative_steering_snapshot( key=lambda m: (m["target_date"] is None, m["target_date"] or ""), )[:5] - next_actions = get_next_action_candidates_for_initiative( - ctx, initiative_id=initiative_id, limit=5 - ) + evaluation = evaluate_steering(ctx, initiative_id=initiative_id, next_work_limit=5) + next_actions = evaluation.next_work + kernel_attention = evaluation.attention steering = get_steering_context_dto(ctx, initiative_id=initiative_id) metadata = steering.get("lifecycle_metadata") or {} @@ -335,8 +335,22 @@ def get_initiative_steering_snapshot( signals=signals, method_key=steering["method_key"], ), - "attention_items": _attention_items(signals), + "attention_items": _attention_items(signals) + + [ + { + "code": item.get("reason_code") or item.get("kind", "attention"), + "label": item.get("summary") or item.get("title") or "Attention", + } + for item in kernel_attention[:5] + ], "signals": signals, + "steering_kernel": { + "horizon": evaluation.horizon.to_dict(), + "lifecycle": evaluation.lifecycle.to_dict(), + "primary_method_key": evaluation.binding.primary_method_key, + "composition_modifier": evaluation.binding.composition_modifier, + "data_source": evaluation.data_source, + }, "upcoming_milestones": upcoming_milestones, "upcoming_roadmap_items": upcoming_milestones, "next_actions": next_actions, diff --git a/backend/steering/kernel/__init__.py b/backend/steering/kernel/__init__.py new file mode 100644 index 0000000..6428b03 --- /dev/null +++ b/backend/steering/kernel/__init__.py @@ -0,0 +1,14 @@ +"""Steering Kernel Spine v0.1 — universal runtime entry for initiative steering.""" + +from steering.kernel.evaluate import evaluate_steering +from steering.kernel.events import apply_steering_event +from steering.kernel.models import HorizonMarker, LifecycleContext, SteeringBinding, SteeringEvaluation + +__all__ = [ + "HorizonMarker", + "LifecycleContext", + "SteeringBinding", + "SteeringEvaluation", + "apply_steering_event", + "evaluate_steering", +] diff --git a/backend/steering/kernel/attention.py b/backend/steering/kernel/attention.py new file mode 100644 index 0000000..d1c2c96 --- /dev/null +++ b/backend/steering/kernel/attention.py @@ -0,0 +1,162 @@ +"""Attention evaluation (Q5) — method- and horizon-aware for one initiative.""" + +from __future__ import annotations + +from typing import Any + +from steering.kernel.models import HorizonMarker, SteeringBinding +from steering.strategies.next_action.execution_ready import gate_horizon_scope_for_method +from tenant_context import TenantContext + +_GATE_METHODS = frozenset( + { + "sequential_dependency", + "program_delivery", + "maturity_progression", + } +) + + +def evaluate_attention( + ctx: TenantContext, + *, + initiative_id: str, + binding: SteeringBinding, + horizon: HorizonMarker, + next_work: list[dict[str, Any]], + limit: int = 20, +) -> list[dict[str, Any]]: + """Kernel attention for a single initiative — no fake next when planning debt.""" + from services import actions as action_service + from services import blockers as blocker_service + from services import roadmap as roadmap_service + from services.execution_plan import list_dependencies_for_initiative + from steering.graph.execution_engine import ( + compute_execution_graph_state, + compute_planning_debt, + execution_waiting_to_attention_items, + planning_debt_to_attention_items, + ) + + items: list[dict[str, Any]] = [] + + blockers = blocker_service.list_blockers_for_initiative( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ) + for blocker in blockers: + if blocker.get("status") not in ("open", "in_progress"): + continue + items.append( + { + "kind": "open_blocker", + "severity": "warning", + "title": blocker.get("title") or "Blocker", + "summary": "Offener Blocker im Vorhaben", + "scope_type": "blocker", + "scope_id": str(blocker["id"]), + "initiative_id": initiative_id, + "action_id": ( + str(blocker["action_id"]) if blocker.get("action_id") else None + ), + "blocker_id": str(blocker["id"]), + "reason_code": "path_blocked", + "data_source": "steering_kernel", + } + ) + + actions = action_service.list_actions_for_initiative( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ) + for action in actions: + if action.get("status") != "blocked": + continue + items.append( + { + "kind": "blocked_action", + "severity": "critical", + "title": action.get("title") or "Arbeitspaket", + "summary": "Maßnahme ist blockiert", + "scope_type": "action", + "scope_id": str(action["id"]), + "initiative_id": initiative_id, + "action_id": str(action["id"]), + "reason_code": "path_blocked", + "data_source": "steering_kernel", + } + ) + + gate_scope = gate_horizon_scope_for_method( + binding.primary_method_key, ctx, initiative_id + ) + roadmap_items = roadmap_service.list_roadmap_items_for_initiative( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ) + + scoped_roadmap = roadmap_items + scoped_actions = actions + if gate_scope: + scope = str(gate_scope) + scoped_roadmap = [ri for ri in roadmap_items if str(ri.get("id")) == scope] + scoped_actions = [ + a + for a in actions + if a.get("roadmap_item_id") and str(a["roadmap_item_id"]) == scope + ] + + if binding.primary_method_key in _GATE_METHODS: + debts = compute_planning_debt( + actions=scoped_actions, roadmap_items=scoped_roadmap + ) + items.extend( + planning_debt_to_attention_items( + initiative_id=initiative_id, debts=debts + ) + ) + + dependencies = list_dependencies_for_initiative( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ) + graph_state = compute_execution_graph_state( + actions=actions, + dependencies=dependencies, + scope_roadmap_item_id=gate_scope, + ) + items.extend( + execution_waiting_to_attention_items( + initiative_id=initiative_id, + actions=actions, + graph_state=graph_state, + ) + ) + + if ( + not next_work + and binding.primary_method_key in _GATE_METHODS + and horizon.kind == "gate" + ): + has_planning = any(i.get("reason_code") == "planning_debt" for i in items) + has_waiting = any(i.get("reason_code") == "execution_waiting" for i in items) + if not has_planning and not has_waiting and not graph_state.get("ready_actions"): + items.append( + { + "kind": "initiative_without_next_action", + "severity": "info", + "title": horizon.gate_title or "Aktiver Horizont", + "summary": "Keine ausführungsbereite Arbeit im aktiven Horizont", + "scope_type": "milestone", + "scope_id": horizon.gate_roadmap_item_id, + "initiative_id": initiative_id, + "reason_code": "horizon_no_ready_work", + "data_source": "steering_kernel", + } + ) + + for item in items: + item.setdefault("data_source", "steering_kernel") + + items.sort( + key=lambda x: {"critical": 0, "warning": 1, "info": 2}.get( + x.get("severity", "info"), 9 + ) + ) + return items[:limit] diff --git a/backend/steering/kernel/binding.py b/backend/steering/kernel/binding.py new file mode 100644 index 0000000..458c434 --- /dev/null +++ b/backend/steering/kernel/binding.py @@ -0,0 +1,52 @@ +"""Resolve primary method and composition modifier.""" + +from __future__ import annotations + +from services import steering_context as sc_service +from services.work_cycle import get_active_work_cycle +from steering.kernel.models import SteeringBinding +from steering.methods.registry import get_method +from steering.strategies.next_action.default_strategy import default_strategy +from tenant_context import TenantContext + +_AGILE_KEY = "agile_iteration" + + +def resolve_primary_method_key(ctx: TenantContext, initiative_id: str) -> str: + row = sc_service.get_steering_context( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ) + if row: + return row["method_key"] + return "generic_operating" + + +def _primary_composes_with_agile(primary_method_key: str) -> bool: + agile = get_method(_AGILE_KEY) + if not agile: + return False + return primary_method_key in agile.composes_with + + +def resolve_steering_binding(ctx: TenantContext, initiative_id: str) -> SteeringBinding: + primary_key = resolve_primary_method_key(ctx, initiative_id) + primary = get_method(primary_key) + primary_strategy = ( + primary.next_action_strategy_key if primary else default_strategy.key + ) + + composition: str | None = None + strategy_key = primary_strategy + + active_cycle = get_active_work_cycle( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ) + if active_cycle and _primary_composes_with_agile(primary_key): + composition = _AGILE_KEY + strategy_key = _AGILE_KEY + + return SteeringBinding( + primary_method_key=primary_key, + composition_modifier=composition, + next_work_strategy_key=strategy_key, + ) diff --git a/backend/steering/kernel/evaluate.py b/backend/steering/kernel/evaluate.py new file mode 100644 index 0000000..2d70a27 --- /dev/null +++ b/backend/steering/kernel/evaluate.py @@ -0,0 +1,66 @@ +"""Steering Kernel Spine — single entry point v0.1.""" + +from __future__ import annotations + +from steering.kernel.attention import evaluate_attention +from steering.kernel.binding import resolve_steering_binding +from steering.kernel.horizon import resolve_horizon +from steering.kernel.lifecycle import resolve_lifecycle_context +from steering.kernel.models import SteeringEvaluation +from steering.kernel.next_work import evaluate_next_work +from tenant_context import TenantContext + + +def evaluate_steering( + ctx: TenantContext, + *, + initiative_id: str, + next_work_limit: int = 10, + attention_limit: int = 20, +) -> SteeringEvaluation: + """ + Herzmaschine — ein Wahrheitsmodell pro Vorhaben. + + Alle Initiative-Steuerung (Snapshot, Cockpit, API) muss hier durch. + """ + if next_work_limit < 1: + next_work_limit = 1 + if attention_limit < 1: + attention_limit = 1 + + binding = resolve_steering_binding(ctx, initiative_id) + lifecycle = resolve_lifecycle_context( + ctx, initiative_id=initiative_id, binding=binding + ) + horizon = resolve_horizon( + ctx, + initiative_id=initiative_id, + primary_method_key=binding.primary_method_key, + composition_modifier=binding.composition_modifier, + ) + + next_work = evaluate_next_work( + ctx, + initiative_id=initiative_id, + binding=binding, + horizon=horizon, + limit=next_work_limit, + ) + + attention = evaluate_attention( + ctx, + initiative_id=initiative_id, + binding=binding, + horizon=horizon, + next_work=next_work, + limit=attention_limit, + ) + + return SteeringEvaluation( + initiative_id=initiative_id, + binding=binding, + horizon=horizon, + lifecycle=lifecycle, + next_work=next_work, + attention=attention, + ) diff --git a/backend/steering/kernel/events.py b/backend/steering/kernel/events.py new file mode 100644 index 0000000..4a27ba6 --- /dev/null +++ b/backend/steering/kernel/events.py @@ -0,0 +1,42 @@ +"""Event ingress stub — dual trigger (Plan | Event) v0.1.""" + +from __future__ import annotations + +from typing import Any, Literal + +from tenant_context import TenantContext + +EventKind = Literal[ + "actor_result", + "external_event", + "cadence_due", + "situational", +] + + +def apply_steering_event( + ctx: TenantContext, + *, + initiative_id: str, + event_kind: EventKind, + payload: dict[str, Any] | None = None, +) -> dict[str, Any]: + """ + Event-Schub in den Kernel (Stub v0.1). + + Plan-dominante Methoden: Event wird protokolliert, Auswertung erfolgt + weiterhin über evaluate_steering (Plan-Zug). Volle Event-Pipeline folgt + für dispute_procedure, care_navigation, recurring_control. + """ + from steering.kernel.evaluate import evaluate_steering + + evaluation = evaluate_steering(ctx, initiative_id=initiative_id) + return { + "accepted": True, + "event_kind": event_kind, + "initiative_id": initiative_id, + "handled": False, + "message": "Event ingress stub — re-evaluate via Plan-Zug", + "payload_keys": sorted((payload or {}).keys()), + "evaluation": evaluation.to_dict(), + } diff --git a/backend/steering/kernel/horizon.py b/backend/steering/kernel/horizon.py new file mode 100644 index 0000000..881ec97 --- /dev/null +++ b/backend/steering/kernel/horizon.py @@ -0,0 +1,67 @@ +"""Horizon resolution (Q1).""" + +from __future__ import annotations + +from steering.kernel.models import HorizonMarker +from steering.strategies.next_action.execution_ready import ( + first_active_gate_id, + gate_horizon_scope_for_method, +) +from services.work_cycle import get_active_work_cycle +from tenant_context import TenantContext + + +def resolve_horizon( + ctx: TenantContext, + *, + initiative_id: str, + primary_method_key: str, + composition_modifier: str | None, +) -> HorizonMarker: + gate_id = gate_horizon_scope_for_method( + primary_method_key, ctx, initiative_id + ) + gate_title: str | None = None + if gate_id: + gate_title = _load_gate_title(ctx, gate_id) + + active_cycle = get_active_work_cycle( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ) + if composition_modifier and active_cycle: + return HorizonMarker( + kind="work_cycle", + gate_roadmap_item_id=gate_id, + gate_title=gate_title, + work_cycle_id=str(active_cycle["id"]), + work_cycle_title=active_cycle.get("title"), + ) + + if gate_id: + return HorizonMarker( + kind="gate", + gate_roadmap_item_id=gate_id, + gate_title=gate_title, + ) + + return HorizonMarker(kind="none") + + +def _load_gate_title(ctx: TenantContext, gate_id: str) -> str | None: + from db import get_connection + from psycopg2.extras import RealDictCursor + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT title FROM roadmap_items + WHERE id = %s AND tenant_id = %s + """, + (gate_id, ctx.tenant_id), + ) + row = cur.fetchone() + return row["title"] if row else None + finally: + conn.close() diff --git a/backend/steering/kernel/lifecycle.py b/backend/steering/kernel/lifecycle.py new file mode 100644 index 0000000..cff5f8f --- /dev/null +++ b/backend/steering/kernel/lifecycle.py @@ -0,0 +1,65 @@ +"""Lifecycle slot resolution — Kernel §4 (declarative, v0.1.1).""" + +from __future__ import annotations + +from typing import Any + +from steering.kernel.models import LifecycleContext, SteeringBinding +from steering.lifecycle.slot_maps import ( + active_slot_keys, + is_slot_operational, + slot_map_from_tuple, +) +from steering.lifecycle.states import STANDARD_LIFECYCLE_STEPS, lifecycle_label +from steering.methods.registry import get_method +from services import steering_context as sc_service +from tenant_context import TenantContext + + +def _derive_slot_map_from_steps(steps: tuple[str, ...]) -> dict[str, str]: + active = set(steps) + return { + step: ("active" if step in active else "n/a") for step in STANDARD_LIFECYCLE_STEPS + } + + +def resolve_method_slot_map(method_key: str) -> dict[str, str]: + method = get_method(method_key) + if not method: + return _derive_slot_map_from_steps(STANDARD_LIFECYCLE_STEPS) + if method.slot_map: + return slot_map_from_tuple(method.slot_map) + return _derive_slot_map_from_steps(method.default_lifecycle_steps) + + +def resolve_lifecycle_context( + ctx: TenantContext, + *, + initiative_id: str, + binding: SteeringBinding, +) -> LifecycleContext: + slot_map = resolve_method_slot_map(binding.primary_method_key) + + row = sc_service.get_steering_context( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ) + current_state = row["lifecycle_state"] if row else "intake" + if current_state not in STANDARD_LIFECYCLE_STEPS: + current_state = "intake" + + active = active_slot_keys(slot_map) # type: ignore[arg-type] + operational = tuple( + step for step in STANDARD_LIFECYCLE_STEPS if is_slot_operational(slot_map.get(step, "n/a")) # type: ignore[arg-type] + ) + + return LifecycleContext( + current_state=current_state, + current_state_label=lifecycle_label(current_state), + slot_map=slot_map, + active_slots=active, + operational_slots=operational, + ) + + +def lifecycle_to_dict(lifecycle: LifecycleContext) -> dict[str, Any]: + return lifecycle.to_dict() diff --git a/backend/steering/kernel/models.py b/backend/steering/kernel/models.py new file mode 100644 index 0000000..5917589 --- /dev/null +++ b/backend/steering/kernel/models.py @@ -0,0 +1,81 @@ +"""Steering Kernel Spine — data models v0.1.1.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Literal + + +@dataclass(frozen=True) +class HorizonMarker: + """Aktiver Steuerungs-Horizont (Q1).""" + + kind: Literal["gate", "work_cycle", "none"] + gate_roadmap_item_id: str | None = None + gate_title: str | None = None + work_cycle_id: str | None = None + work_cycle_title: str | None = None + + def to_dict(self) -> dict[str, Any]: + return { + "kind": self.kind, + "gate_roadmap_item_id": self.gate_roadmap_item_id, + "gate_title": self.gate_title, + "work_cycle_id": self.work_cycle_id, + "work_cycle_title": self.work_cycle_title, + } + + +@dataclass(frozen=True) +class SteeringBinding: + """Aufgelöste Methode: Primary + optional Composition.""" + + primary_method_key: str + composition_modifier: str | None + next_work_strategy_key: str + + +@dataclass(frozen=True) +class LifecycleContext: + """Deklarativer Lifecycle-Vertrag + persistierter Pointer.""" + + current_state: str + current_state_label: str + slot_map: dict[str, str] + active_slots: tuple[str, ...] + operational_slots: tuple[str, ...] + + def to_dict(self) -> dict[str, Any]: + return { + "current_state": self.current_state, + "current_state_label": self.current_state_label, + "slot_map": dict(self.slot_map), + "active_slots": list(self.active_slots), + "operational_slots": list(self.operational_slots), + } + + +@dataclass +class SteeringEvaluation: + """Ergebnis von evaluate_steering — ein Wahrheitsmodell pro Vorhaben.""" + + initiative_id: str + binding: SteeringBinding + horizon: HorizonMarker + lifecycle: LifecycleContext + next_work: list[dict[str, Any]] = field(default_factory=list) + attention: list[dict[str, Any]] = field(default_factory=list) + data_source: str = "steering_kernel_v0.1.1" + + def to_dict(self) -> dict[str, Any]: + return { + "initiative_id": self.initiative_id, + "primary_method_key": self.binding.primary_method_key, + "composition_modifier": self.binding.composition_modifier, + "next_work_strategy_key": self.binding.next_work_strategy_key, + "horizon": self.horizon.to_dict(), + "lifecycle": self.lifecycle.to_dict(), + "next_work": self.next_work, + "attention": self.attention, + "data_source": self.data_source, + } diff --git a/backend/steering/kernel/next_work.py b/backend/steering/kernel/next_work.py new file mode 100644 index 0000000..9ecc148 --- /dev/null +++ b/backend/steering/kernel/next_work.py @@ -0,0 +1,37 @@ +"""Next work evaluation (Schlitz 5) — routes to registered strategies only.""" + +from __future__ import annotations + +from typing import Any + +from steering.kernel.models import HorizonMarker, SteeringBinding +from steering.strategies.next_action.default_strategy import default_strategy +from steering.strategies.next_action.registry import get_next_action_strategy +from tenant_context import TenantContext + + +def evaluate_next_work( + ctx: TenantContext, + *, + initiative_id: str, + binding: SteeringBinding, + horizon: HorizonMarker, + limit: int, +) -> list[dict[str, Any]]: + """Invoke the bound next-work strategy (primary or composition).""" + if limit < 1: + limit = 1 + + strategy = get_next_action_strategy(binding.next_work_strategy_key) or default_strategy + candidates = strategy.evaluate( + ctx, initiative_id=initiative_id, limit=limit + ) + + for item in candidates: + item.setdefault("data_source", "steering_kernel") + if horizon.gate_roadmap_item_id: + item.setdefault("horizon_gate_id", horizon.gate_roadmap_item_id) + if horizon.work_cycle_id: + item.setdefault("horizon_work_cycle_id", horizon.work_cycle_id) + + return candidates[:limit] diff --git a/backend/steering/lifecycle/slot_maps.py b/backend/steering/lifecycle/slot_maps.py new file mode 100644 index 0000000..9f8d973 --- /dev/null +++ b/backend/steering/lifecycle/slot_maps.py @@ -0,0 +1,76 @@ +"""Method slot maps — Kernel §4 / Spec-D D5.""" + +from __future__ import annotations + +from typing import Literal + +from steering.lifecycle.states import STANDARD_LIFECYCLE_STEPS + +SlotStatus = Literal["active", "skip", "n/a", "optional"] + +VALID_SLOT_STATUSES = frozenset({"active", "skip", "n/a", "optional"}) + +# Spec-D sequential_dependency D5 +SEQUENTIAL_DEPENDENCY_SLOTS: dict[str, SlotStatus] = { + "intake": "active", + "method_selection": "active", + "structure_setup": "active", + "planning": "active", + "action_selection": "active", + "assignment": "active", + "waiting": "active", + "result_intake": "active", + "validation": "active", + "review": "optional", + "adaptation": "active", + "closure": "active", +} + +# Spec-D generic_operating D5 (method_bind → method_selection, next_work → action_selection) +GENERIC_OPERATING_SLOTS: dict[str, SlotStatus] = { + "intake": "active", + "method_selection": "active", + "structure_setup": "optional", + "planning": "n/a", + "action_selection": "active", + "assignment": "active", + "waiting": "optional", + "result_intake": "active", + "validation": "n/a", + "review": "n/a", + "adaptation": "optional", + "closure": "optional", +} + + +def validate_slot_map(slot_map: dict[str, SlotStatus]) -> None: + for step, status in slot_map.items(): + if step not in STANDARD_LIFECYCLE_STEPS: + raise ValueError(f"Unknown lifecycle slot: {step}") + if status not in VALID_SLOT_STATUSES: + raise ValueError(f"Invalid slot status for {step}: {status}") + missing = set(STANDARD_LIFECYCLE_STEPS) - set(slot_map.keys()) + if missing: + raise ValueError(f"slot_map missing lifecycle steps: {sorted(missing)}") + + +def slot_map_to_tuple(slot_map: dict[str, SlotStatus]) -> tuple[tuple[str, SlotStatus], ...]: + validate_slot_map(slot_map) + return tuple((step, slot_map[step]) for step in STANDARD_LIFECYCLE_STEPS) + + +def slot_map_from_tuple(items: tuple[tuple[str, SlotStatus], ...]) -> dict[str, SlotStatus]: + if not items: + return {} + slot_map = dict(items) + validate_slot_map(slot_map) + return slot_map + + +def active_slot_keys(slot_map: dict[str, SlotStatus]) -> tuple[str, ...]: + return tuple(step for step in STANDARD_LIFECYCLE_STEPS if slot_map.get(step) == "active") + + +def is_slot_operational(status: SlotStatus) -> bool: + """Slots that participate in runtime evaluation (v0.1.1).""" + return status in ("active", "optional") diff --git a/backend/steering/methods/registrations/_helpers.py b/backend/steering/methods/registrations/_helpers.py index ea421ab..0a7fdc5 100644 --- a/backend/steering/methods/registrations/_helpers.py +++ b/backend/steering/methods/registrations/_helpers.py @@ -97,6 +97,7 @@ def register_stub_method( graph_profile: GraphMethodProfile = LIGHT, method_role: Literal["primary", "modifier"] = "primary", composes_with: frozenset[str] | None = None, + slot_map: tuple[tuple[str, str], ...] | None = None, ) -> None: if get_method(key): return @@ -115,6 +116,7 @@ def register_stub_method( graph_profile=graph_profile, method_role=method_role, composes_with=composes_with or frozenset(), + slot_map=slot_map or (), ) ) diff --git a/backend/steering/methods/registrations/ap20_method_stubs.py b/backend/steering/methods/registrations/ap20_method_stubs.py index ff1c37e..88f073e 100644 --- a/backend/steering/methods/registrations/ap20_method_stubs.py +++ b/backend/steering/methods/registrations/ap20_method_stubs.py @@ -3,6 +3,10 @@ from __future__ import annotations from steering.graph.profiles import FULFILLMENT, LIGHT, STRICT +from steering.lifecycle.slot_maps import ( + SEQUENTIAL_DEPENDENCY_SLOTS, + slot_map_to_tuple, +) from steering.methods.registrations._helpers import ( ELEM_NEXT, SLICES_AGILE, @@ -37,6 +41,7 @@ def register() -> None: | frozenset({"critical_path", "gate_fulfillment"}), ui_features=frozenset({"criticalPathControl"}), graph_profile=STRICT, + slot_map=slot_map_to_tuple(SEQUENTIAL_DEPENDENCY_SLOTS), ) register_stub_method( key="recurring_control", diff --git a/backend/steering/methods/registrations/generic_operating.py b/backend/steering/methods/registrations/generic_operating.py index df6f498..738fb29 100644 --- a/backend/steering/methods/registrations/generic_operating.py +++ b/backend/steering/methods/registrations/generic_operating.py @@ -2,8 +2,9 @@ from __future__ import annotations -from steering.lifecycle.states import STANDARD_LIFECYCLE_STEPS from steering.graph.profiles import LIGHT +from steering.lifecycle.slot_maps import GENERIC_OPERATING_SLOTS, slot_map_to_tuple +from steering.lifecycle.states import STANDARD_LIFECYCLE_STEPS from steering.methods.registrations._helpers import ELEM_NEXT, SLICES_GENERIC from steering.methods.registry import MethodDefinition, get_method, register_method @@ -23,5 +24,6 @@ def register() -> None: compatible_archetype_keys="*", steering_elements=ELEM_NEXT, graph_profile=LIGHT, + slot_map=slot_map_to_tuple(GENERIC_OPERATING_SLOTS), ) ) diff --git a/backend/steering/methods/registry.py b/backend/steering/methods/registry.py index fededa8..cdcf85a 100644 --- a/backend/steering/methods/registry.py +++ b/backend/steering/methods/registry.py @@ -26,6 +26,7 @@ class MethodDefinition: graph_profile: GraphMethodProfile = LIGHT method_role: Literal["primary", "modifier"] = "primary" composes_with: frozenset[str] = frozenset() + slot_map: tuple[tuple[str, str], ...] = () def ui_features_as_dict(features: frozenset[str]) -> dict[str, bool]: @@ -70,6 +71,10 @@ def register_method(defn: MethodDefinition) -> None: raise ValueError( f"Modifier method {defn.key} must declare composes_with primary keys" ) + if defn.slot_map: + from steering.lifecycle.slot_maps import slot_map_from_tuple + + slot_map_from_tuple(defn.slot_map) _METHODS[defn.key] = defn @@ -133,6 +138,10 @@ def method_to_dict( "composes_with": sorted(method.composes_with), "data_slices": sorted(method.data_slices), } + if method.slot_map: + payload["slot_map"] = { + step: status for step, status in method.slot_map + } if include_compatibility: compat = method.compatible_archetype_keys payload["compatible_archetype_keys"] = ( diff --git a/backend/steering/signals/engine.py b/backend/steering/signals/engine.py index a19308f..fa8c642 100644 --- a/backend/steering/signals/engine.py +++ b/backend/steering/signals/engine.py @@ -1,11 +1,10 @@ -"""Signal Engine — method-aware NextAction (AP1.1).""" +"""Signal Engine — delegates initiative steering to Kernel Spine v0.1.""" from __future__ import annotations from typing import Any, Literal -from services import steering_context as sc_service -from steering.methods.registry import get_method +from steering.kernel import evaluate_steering from steering.signals import default_rules from steering.strategies.next_action.default_strategy import default_strategy from steering.strategies.next_action.registry import get_next_action_strategy @@ -14,43 +13,6 @@ from tenant_context import TenantContext SignalKind = Literal["attention", "next_action"] -def _resolve_method_key(ctx: TenantContext, initiative_id: str | None) -> str: - if not initiative_id: - return "generic_operating" - row = sc_service.get_steering_context( - tenant_id=ctx.tenant_id, initiative_id=initiative_id - ) - if row: - return row["method_key"] - return "generic_operating" - - -def _primary_composes_with_agile(method_key: str) -> bool: - agile = get_method("agile_iteration") - if not agile: - return False - return method_key in agile.composes_with - - -def _resolve_next_action_strategy_key( - ctx: TenantContext, initiative_id: str | None -) -> str: - method_key = _resolve_method_key(ctx, initiative_id) - method = get_method(method_key) - strategy_key = method.next_action_strategy_key if method else default_strategy.key - if not initiative_id: - return strategy_key - - from services.work_cycle import get_active_work_cycle - - active = get_active_work_cycle(tenant_id=ctx.tenant_id, initiative_id=initiative_id) - if active and _primary_composes_with_agile(method_key): - agile = get_next_action_strategy("agile_iteration") - if agile: - return agile.key - return strategy_key - - def evaluate( ctx: TenantContext, kind: SignalKind = "attention", @@ -58,9 +20,19 @@ def evaluate( limit: int = 10, initiative_id: str | None = None, ) -> list[dict[str, Any]]: + if initiative_id: + evaluation = evaluate_steering( + ctx, + initiative_id=initiative_id, + next_work_limit=limit, + attention_limit=max(limit, 10), + ) + if kind == "next_action": + return evaluation.next_work + return evaluation.attention + if kind == "attention": return default_rules.get_attention_items(ctx) - strategy_key = _resolve_next_action_strategy_key(ctx, initiative_id) - strategy = get_next_action_strategy(strategy_key) or default_strategy - return strategy.evaluate(ctx, initiative_id=initiative_id, limit=limit) + strategy = get_next_action_strategy("default") or default_strategy + return strategy.evaluate(ctx, initiative_id=None, limit=limit) diff --git a/backend/tests/test_ap_kernel_spine.py b/backend/tests/test_ap_kernel_spine.py new file mode 100644 index 0000000..6f6aa04 --- /dev/null +++ b/backend/tests/test_ap_kernel_spine.py @@ -0,0 +1,111 @@ +"""Steering Kernel Spine v0.1 — unit and integration tests.""" + +from __future__ import annotations + +from steering.kernel import apply_steering_event, evaluate_steering +from steering.kernel.binding import resolve_steering_binding +from tests.factories import provision_user_in_tenant +from tests.test_initiatives_actions import _auth, _create_initiative, _login + + +def test_evaluate_steering_linear_binding(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + + created = _create_initiative( + client, + token, + title="Kernel Binding", + archetype_key="initiative.linear_project", + ) + initiative_id = created.json()["id"] + + from tenant_context import TenantContext + + ctx = TenantContext(tenant_id=user["tenant_id"], user_id=user["id"]) + binding = resolve_steering_binding(ctx, initiative_id) + assert binding.primary_method_key == "sequential_dependency" + assert binding.composition_modifier is None + assert binding.next_work_strategy_key == "sequential_dependency" + + +def test_evaluate_steering_returns_horizon_and_next(client): + user = provision_user_in_tenant(tenant_role="admin") + token = _login(client, user) + + created = _create_initiative( + client, + token, + title="Kernel Eval", + archetype_key="initiative.linear_project", + ) + initiative_id = created.json()["id"] + + from tenant_context import TenantContext + + ctx = TenantContext(tenant_id=user["tenant_id"], user_id=user["id"]) + evaluation = evaluate_steering(ctx, initiative_id=initiative_id) + assert evaluation.data_source == "steering_kernel_v0.1.1" + assert evaluation.lifecycle.slot_map.get("action_selection") == "active" + assert evaluation.lifecycle.slot_map.get("validation") == "active" + assert evaluation.lifecycle.slot_map.get("review") == "optional" + assert evaluation.horizon.kind in ("gate", "none", "work_cycle") + if evaluation.horizon.kind == "gate": + assert evaluation.horizon.gate_roadmap_item_id + + snap = client.get( + f"/api/initiatives/{initiative_id}/steering-snapshot", + headers=_auth(token), + ) + assert snap.status_code == 200 + body = snap.json() + assert body.get("steering_kernel") + assert body["steering_kernel"]["primary_method_key"] == "sequential_dependency" + assert body["next_actions"] == evaluation.next_work + + +def test_snapshot_routes_through_kernel_not_side_path(client): + user = provision_user_in_tenant(tenant_role="admin") + token = _login(client, user) + + created = _create_initiative( + client, + token, + title="Kernel Path", + archetype_key="initiative.linear_project", + ) + initiative_id = created.json()["id"] + + snap = client.get( + f"/api/initiatives/{initiative_id}/steering-snapshot", + headers=_auth(token), + ) + assert snap.json()["steering_kernel"]["data_source"] == "steering_kernel_v0.1.1" + assert "lifecycle" in snap.json()["steering_kernel"] + assert snap.json()["steering_kernel"]["lifecycle"]["slot_map"]["closure"] == "active" + + +def test_apply_steering_event_stub(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + + created = _create_initiative( + client, + token, + title="Event Stub", + archetype_key="initiative.linear_project", + ) + initiative_id = created.json()["id"] + + from tenant_context import TenantContext + + ctx = TenantContext(tenant_id=user["tenant_id"], user_id=user["id"]) + result = apply_steering_event( + ctx, + initiative_id=initiative_id, + event_kind="actor_result", + payload={"action_id": "test"}, + ) + assert result["accepted"] is True + assert result["handled"] is False + assert result["evaluation"]["data_source"] == "steering_kernel_v0.1.1" diff --git a/docs/architecture/ADP_Steering_Kernel_Spine_v0.1.md b/docs/architecture/ADP_Steering_Kernel_Spine_v0.1.md new file mode 100644 index 0000000..8f4d0b7 --- /dev/null +++ b/docs/architecture/ADP_Steering_Kernel_Spine_v0.1.md @@ -0,0 +1,93 @@ +# ADP — Steering Kernel Spine v0.1 + +**Status:** PO-freigegeben — **Implementierung aktiv** (2026-07-26) +**Stand:** 2026-07-26 +**Bezug:** `Kairo_Steering_Method_Kernel_v0.1.md`, `ADP_AP2_4_Steering_Elements_and_Method_Contract_v0.1.md`, Normierungsprogramm M5 +**Ersetzt nicht:** AP2.3/AP2.4 Plugin-Schicht — **setzt darauf auf** + +--- + +## 1. Problem + +Steuerungslogik (Next Action, Attention, Horizont) entsteht heute **verstreut** in Strategies, `signals/engine.py` und Snapshot-Hilfsregeln. Es gibt **keinen** zentralen Runtime-Einstieg — Produkt wirkt unführbar, Spec-Kern (M1c) ist nicht im Code. + +**PO-Entscheidung:** Erst **Herzmaschine** (Kernel Spine), dann Archetyp-Slices und UI. + +--- + +## 2. Scope Lock — Spine v0.1 (schmal) + +### In Scope + +| # | Lieferung | +|---|-----------| +| K1 | `backend/steering/kernel/` — **einziger** Einstieg `evaluate_steering()` pro Vorhaben | +| K2 | `SteeringBinding` — Primary-Methode + optional Composition (`agile_iteration`) | +| K3 | `HorizonMarker` — aktives Gate / aktiver Sprint | +| K4 | `next_work` + `attention` — nur über Spine; Snapshot/API für Initiative | +| K5 | Event-Ingress **Stub** `apply_steering_event()` (Plan-Methoden: no-op) | +| K6 | Referenz-Abnahme: **A2** + `sequential_dependency` (+ Agile-Komposition) | + +### Out of Scope (v0.1) + +- Voller 12-Schritt-Lifecycle-Orchestrator als Schleife +- Nested Programm-Impulse (B2a P0) +- Event-Methoden C1/Care voll +- Neue OM-Tabellen +- Frontend-Umbau (nutzt bestehende Snapshot-Felder) + +--- + +## 3. Architektur + +```text +GET …/steering-snapshot ─┐ +data_layer/attention ─┼─► steering.kernel.evaluate_steering() +signals/engine (init.) ─┘ │ + ├─ resolve_binding (primary + modifier) + ├─ resolve_horizon + ├─ next_work (Strategy via binding) + └─ attention (method-aware, horizon-scoped) +``` + +**Stop-the-line:** Keine neue Steuerungsheuristik außerhalb `backend/steering/kernel/` und registrierten Method-Strategies, die **nur** vom Kernel aufgerufen werden. + +--- + +## 4. Abnahme (DoD) + +1. A2 Happy Path Spec §9: Next im Gate-Horizont, blockiert → Attention, kein Fake-Next +2. Agile: Primary ohne Sprint; Sprint aktiv → `work_cycle_ready` ∩ Horizont +3. pytest `test_ap_kernel_spine.py` + `test_ap22c_*` grün +4. Kein Initiative-Snapshot-Pfad umgeht `evaluate_steering()` + +--- + +## 5. Migration + +| Alt | Neu | +|-----|-----| +| `signals/engine.evaluate(..., initiative_id=…)` | delegiert an Kernel | +| Composition-If in `engine._resolve_next_action_strategy_key` | `kernel/binding.py` | +| Horizont in einzelnen Strategies | `kernel/horizon.py` + Strategies konsumieren Scope | + +Strategies bleiben **Plugin-Lieferanten** — werden nicht dupliziert, aber nur über Kernel geroutet. + +--- + +## 6. Nächste Phasen (nach Spine ✓) + +1. Zweite Methode durch dieselbe Spine (`continuous_product`) +2. `checklist_flow`, `care_navigation` als Plugins +3. Event-Ingress für hybrid/event-Methoden +4. Lifecycle-Schleife schrittweise (nicht Big Bang) + +### Phase v0.1.1 (2026-07-26) ✓ + +- `slot_map` in `MethodDefinition` (Spec-D D5) für `sequential_dependency`, `generic_operating` +- `evaluate_steering()` liefert `lifecycle`: `current_state`, `slot_map`, `active_slots` +- Snapshot-Feld `steering_kernel.lifecycle` + +--- + +*Bei Erweiterung: v0.2 Event-Pfad + Slot-Provider — v0.2 + PO-Freigabe.* diff --git a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md index 1d36f97..efcbdfb 100644 --- a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md +++ b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md @@ -86,6 +86,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden. | 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–F + AP2.4; Operating Context, Slice-Loader, Route Gating | +| **Steering Kernel Spine** | ◐ | v0.1.1: evaluate_steering + slot_map (A2); Event-Ingress Stub | | Steuerungselement-Registry | ✓ | AP2.4: `steering/elements/`, FE `steeringElementRegistry`, Methoden-Vertrag | | operating_phase | ✗ | entfernt AP1.2 | | signals (Snapshot) | ✓ | `snapshot_signals.py` |