feat(steering): Kernel Spine v0.1.1 als zentraler Initiative-Einstieg
Some checks failed
Deploy Development / deploy (push) Successful in 48s
Test Suite / pytest-backend (push) Failing after 3m33s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped

evaluate_steering() vereinheitlicht Horizon, Next Work und Attention; Snapshot und Signal Engine delegieren dorthin; slot_map deklarativ fuer sequential_dependency und generic_operating.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-26 16:08:58 +02:00
parent 753f178b0c
commit 75a6a63665
20 changed files with 923 additions and 51 deletions

View File

@ -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)

View File

@ -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,

View File

@ -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",
]

View File

@ -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]

View File

@ -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,
)

View File

@ -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,
)

View File

@ -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(),
}

View File

@ -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()

View File

@ -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()

View File

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

View File

@ -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]

View File

@ -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")

View File

@ -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 (),
)
)

View File

@ -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",

View File

@ -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),
)
)

View File

@ -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"] = (

View File

@ -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)

View File

@ -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"

View File

@ -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.*

View File

@ -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 AF + 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` |