feat(steering): Kernel v0.3 mit Read-Model- und Proposal-Registry (K-Ext-1, P6)
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 4m11s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 15s

Erweitert evaluate_steering um read_models und proposals, migriert epic_rollup auf Registry, liefert sprint_commit-Vorschlaege und UI auf Plan-Sprint.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-27 11:27:06 +02:00
parent c5e2c69b1e
commit 20636f88d2
22 changed files with 779 additions and 56 deletions

View File

@ -297,31 +297,8 @@ def get_initiative_steering_snapshot(
evaluation = evaluate_steering(ctx, initiative_id=initiative_id, next_work_limit=5)
next_actions = evaluation.next_work
kernel_attention = evaluation.attention
from services.operating_context import get_operating_context
from steering.read_models.epic_rollup import compute_epic_rollup
operating = get_operating_context(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
epic_rollup: list[dict[str, Any]] = []
if operating.get("backlog_vocabulary", {}).get("capabilities", {}).get(
"epic_hierarchy"
):
epic_rollup = compute_epic_rollup(
backlog_items=[
{
"id": _sid(b["id"]),
"title": b["title"],
"status": b["status"],
"item_kind": b.get("item_kind"),
"parent_backlog_id": _sid(b.get("parent_backlog_id")),
"converted_action_id": _sid(b.get("converted_action_id")),
}
for b in backlog
],
actions=actions_raw,
)
epic_rollup = evaluation.read_models.get("epic_rollup") or []
sprint_commit_proposals = evaluation.proposals.get("sprint_commit") or []
steering = get_steering_context_dto(ctx, initiative_id=initiative_id)
metadata = steering.get("lifecycle_metadata") or {}
@ -376,12 +353,15 @@ def get_initiative_steering_snapshot(
"primary_method_key": evaluation.binding.primary_method_key,
"composition_modifier": evaluation.binding.composition_modifier,
"data_source": evaluation.data_source,
"read_models": evaluation.read_models,
"proposals": evaluation.proposals,
},
"upcoming_milestones": upcoming_milestones,
"upcoming_roadmap_items": upcoming_milestones,
"next_actions": next_actions,
"active_work_cycle": active_work_cycle,
"epic_rollup": epic_rollup,
"sprint_commit_proposals": sprint_commit_proposals,
"counts": {
"actions_open": open_actions,
"actions_blocked": blocked_actions,

View File

@ -0,0 +1,96 @@
"""Shared evaluation context for steering read models and proposals (K-Ext-1)."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from steering.kernel.models import HorizonMarker, LifecycleContext, SteeringBinding
from tenant_context import TenantContext
@dataclass
class SteeringEvalContext:
"""Tenant-scoped context — lazy OM loads for extension providers."""
tenant_ctx: TenantContext
initiative_id: str
binding: SteeringBinding
horizon: HorizonMarker
lifecycle: LifecycleContext
operating_context: dict[str, Any]
_actions: list[dict[str, Any]] | None = field(default=None, repr=False)
_backlog_items: list[dict[str, Any]] | None = field(default=None, repr=False)
_work_cycles: list[dict[str, Any]] | None = field(default=None, repr=False)
@property
def steering_elements(self) -> frozenset[str]:
return frozenset(self.operating_context.get("steering_elements") or [])
@property
def data_slices(self) -> list[str]:
return list(self.operating_context.get("data_slices") or [])
@property
def backlog_vocabulary(self) -> dict[str, Any]:
return dict(self.operating_context.get("backlog_vocabulary") or {})
@property
def actions(self) -> list[dict[str, Any]]:
if self._actions is None:
from services import actions as action_service
self._actions = action_service.list_actions_for_initiative(
tenant_id=self.tenant_ctx.tenant_id,
initiative_id=self.initiative_id,
)
return self._actions
@property
def backlog_items(self) -> list[dict[str, Any]]:
if self._backlog_items is None:
from services import backlog as backlog_service
self._backlog_items = backlog_service.list_backlog_for_initiative(
tenant_id=self.tenant_ctx.tenant_id,
initiative_id=self.initiative_id,
)
return self._backlog_items
@property
def work_cycles(self) -> list[dict[str, Any]]:
if self._work_cycles is None:
from services.work_cycle import list_work_cycles_for_initiative
self._work_cycles = list_work_cycles_for_initiative(
tenant_id=self.tenant_ctx.tenant_id,
initiative_id=self.initiative_id,
)
return self._work_cycles
def build_steering_eval_context(
tenant_ctx: TenantContext,
*,
initiative_id: str,
binding: SteeringBinding,
horizon: HorizonMarker,
lifecycle: LifecycleContext,
) -> SteeringEvalContext:
from services.operating_context import get_operating_context
operating = (
get_operating_context(
tenant_id=tenant_ctx.tenant_id, initiative_id=initiative_id
)
or {}
)
return SteeringEvalContext(
tenant_ctx=tenant_ctx,
initiative_id=initiative_id,
binding=binding,
horizon=horizon,
lifecycle=lifecycle,
operating_context=operating,
)

View File

@ -1,6 +1,5 @@
"""Steering Kernel Spine — universal runtime entry for initiative steering."""
from steering.kernel.evaluate import evaluate_steering
from steering.kernel.models import HorizonMarker, LifecycleContext, SteeringBinding, SteeringEvaluation
__all__ = [
@ -10,3 +9,10 @@ __all__ = [
"SteeringEvaluation",
"evaluate_steering",
]
def evaluate_steering(*args, **kwargs):
"""Lazy import avoids circular dependency with extension registries."""
from steering.kernel.evaluate import evaluate_steering as _evaluate
return _evaluate(*args, **kwargs)

View File

@ -24,6 +24,9 @@ def evaluate_attention(
binding: SteeringBinding,
horizon: HorizonMarker,
next_work: list[dict[str, Any]],
read_models: dict[str, Any] | None = None,
proposals: dict[str, Any] | None = None,
eval_ctx=None,
limit: int = 20,
) -> list[dict[str, Any]]:
"""Kernel attention for a single initiative — no fake next when planning debt."""
@ -129,25 +132,28 @@ def evaluate_attention(
)
)
from services.operating_context import get_operating_context
from services import backlog as backlog_service
from steering.read_models.epic_rollup import (
compute_epic_rollup,
epic_rollup_attention_items,
)
read_models = read_models or {}
proposals = proposals or {}
operating = get_operating_context(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
if operating.get("backlog_vocabulary", {}).get("capabilities", {}).get(
"epic_hierarchy"
):
backlog_items = backlog_service.list_backlog_for_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
rollups = compute_epic_rollup(backlog_items=backlog_items, actions=actions)
from steering.read_models.epic_rollup import epic_rollup_attention_items
from steering.proposals.sprint_commit import sprint_commit_attention_items
epic_rollups = read_models.get("epic_rollup")
if epic_rollups:
items.extend(
epic_rollup_attention_items(initiative_id=initiative_id, rollups=rollups)
epic_rollup_attention_items(
initiative_id=initiative_id, rollups=epic_rollups
)
)
sprint_proposals = proposals.get("sprint_commit")
if sprint_proposals:
items.extend(
sprint_commit_attention_items(
initiative_id=initiative_id,
proposals=sprint_proposals,
actions=actions,
)
)
if (

View File

@ -1,13 +1,16 @@
"""Steering Kernel Spine — single entry point v0.1."""
"""Steering Kernel Spine — single entry point v0.3."""
from __future__ import annotations
from steering.kernel.attention import evaluate_attention
from steering.kernel.binding import resolve_steering_binding
from steering.eval_context import build_steering_eval_context
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 steering.proposals.registry import compute_proposals
from steering.read_models.registry import compute_read_models
from tenant_context import TenantContext
@ -39,6 +42,14 @@ def evaluate_steering(
composition_modifier=binding.composition_modifier,
)
eval_ctx = build_steering_eval_context(
ctx,
initiative_id=initiative_id,
binding=binding,
horizon=horizon,
lifecycle=lifecycle,
)
next_work = evaluate_next_work(
ctx,
initiative_id=initiative_id,
@ -47,12 +58,18 @@ def evaluate_steering(
limit=next_work_limit,
)
read_models = compute_read_models(eval_ctx)
proposals = compute_proposals(eval_ctx, read_models=read_models)
attention = evaluate_attention(
ctx,
initiative_id=initiative_id,
binding=binding,
horizon=horizon,
next_work=next_work,
read_models=read_models,
proposals=proposals,
eval_ctx=eval_ctx,
limit=attention_limit,
)
@ -63,4 +80,6 @@ def evaluate_steering(
lifecycle=lifecycle,
next_work=next_work,
attention=attention,
read_models=read_models,
proposals=proposals,
)

View File

@ -65,7 +65,9 @@ class SteeringEvaluation:
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.2"
read_models: dict[str, Any] = field(default_factory=dict)
proposals: dict[str, Any] = field(default_factory=dict)
data_source: str = "steering_kernel_v0.3"
def to_dict(self) -> dict[str, Any]:
return {
@ -77,5 +79,7 @@ class SteeringEvaluation:
"lifecycle": self.lifecycle.to_dict(),
"next_work": self.next_work,
"attention": self.attention,
"read_models": self.read_models,
"proposals": self.proposals,
"data_source": self.data_source,
}

View File

@ -0,0 +1 @@
"""Steering proposals — ranked suggestions without auto-mutation (P6+)."""

View File

@ -0,0 +1,60 @@
"""Proposal provider registry — K-Ext-1 / P6."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable
from steering.eval_context import SteeringEvalContext
ProposalCompute = Callable[[SteeringEvalContext, dict[str, Any]], Any]
@dataclass(frozen=True)
class ProposalProvider:
key: str
requires_elements: frozenset[str]
requires_data_slices: frozenset[str]
compute: ProposalCompute
_PROVIDERS: list[ProposalProvider] = []
def register_proposal(provider: ProposalProvider) -> None:
_PROVIDERS.append(provider)
def compute_proposals(
ctx: SteeringEvalContext,
*,
read_models: dict[str, Any],
) -> dict[str, Any]:
elements = ctx.steering_elements
slices = frozenset(ctx.data_slices)
result: dict[str, Any] = {}
for provider in _PROVIDERS:
if provider.requires_elements and not provider.requires_elements <= elements:
continue
if provider.requires_data_slices and not provider.requires_data_slices <= slices:
continue
result[provider.key] = provider.compute(ctx, read_models)
return result
def _register_builtin_proposals() -> None:
from steering.proposals.sprint_commit import propose_sprint_commit
register_proposal(
ProposalProvider(
key="sprint_commit",
requires_elements=frozenset({"work_cycle_scope"}),
requires_data_slices=frozenset({"backlog", "work_cycles"}),
compute=lambda ctx, _read_models: propose_sprint_commit(ctx),
)
)
_register_builtin_proposals()

View File

@ -0,0 +1,178 @@
"""Sprint commit proposals — ADP P6 / agile_iteration."""
from __future__ import annotations
from typing import Any
from steering.eval_context import SteeringEvalContext
COMMITTABLE_STATUSES = frozenset({"new", "triaged", "accepted"})
PLANABLE_CYCLE_STATUSES = frozenset({"planned", "active"})
OPEN_ACTION = frozenset({"open", "ready", "in_progress", "blocked", "review_required"})
_PRIORITY_RANK = {"critical": 0, "high": 1, "normal": 2, "low": 3}
_KIND_RANK = {"bug": 0, "issue": 1, "incident": 1, "story": 2, "idea": 3}
_REASON_LABELS = {
"bug_critical": "Kritischer Bug — Sprint-Priorität",
"bug_high_priority": "Bug mit hoher Priorität",
"bug_default": "Bug — vor Features priorisieren",
"issue_default": "Issue/Störung im Eingang",
"story_high_priority": "Story mit hoher Priorität",
"story_ready": "Story bereit zum Commit",
"idea_default": "Idee im Eingang",
}
def _resolve_target_work_cycle(
ctx: SteeringEvalContext,
) -> dict[str, Any] | None:
cycles = [
c for c in ctx.work_cycles if c.get("status") in PLANABLE_CYCLE_STATUSES
]
if not cycles:
return None
if ctx.horizon.work_cycle_id:
match = next(
(c for c in cycles if str(c["id"]) == str(ctx.horizon.work_cycle_id)),
None,
)
if match:
return match
planned = sorted(
[c for c in cycles if c.get("status") == "planned"],
key=lambda c: (c.get("sort_order", 0), str(c["id"])),
)
if planned:
return planned[0]
active = sorted(
[c for c in cycles if c.get("status") == "active"],
key=lambda c: (c.get("sort_order", 0), str(c["id"])),
)
return active[0] if active else None
def _reason_code(item: dict[str, Any]) -> str:
kind = item.get("item_kind") or "story"
priority = item.get("priority") or "normal"
if kind == "bug":
if priority == "critical":
return "bug_critical"
if priority == "high":
return "bug_high_priority"
return "bug_default"
if kind in ("issue", "incident"):
return "issue_default"
if kind == "idea":
return "idea_default"
if priority in ("critical", "high"):
return "story_high_priority"
return "story_ready"
def _sort_key(item: dict[str, Any]) -> tuple:
kind = item.get("item_kind") or "story"
priority = item.get("priority") or "normal"
return (
_KIND_RANK.get(kind, 9),
_PRIORITY_RANK.get(priority, 9),
item.get("sort_order") if item.get("sort_order") is not None else 9999,
(item.get("title") or "").lower(),
)
def propose_sprint_commit(ctx: SteeringEvalContext) -> list[dict[str, Any]]:
"""Ranked backlog items suggested for sprint commit — no auto-mutation."""
target = _resolve_target_work_cycle(ctx)
if not target:
return []
vocabulary = ctx.backlog_vocabulary
convertible = frozenset(vocabulary.get("convertible_kinds") or ["story", "bug", "issue"])
candidates = [
item
for item in ctx.backlog_items
if item.get("status") in COMMITTABLE_STATUSES
and item.get("item_kind") != "epic"
and (item.get("item_kind") or "story") in convertible
]
if not candidates:
return []
ranked = sorted(candidates, key=_sort_key)
cycle_id = str(target["id"])
cycle_title = target.get("title") or "Sprint"
proposals: list[dict[str, Any]] = []
for rank, item in enumerate(ranked[:20], start=1):
reason = _reason_code(item)
kind = item.get("item_kind") or "story"
proposals.append(
{
"proposal_key": "sprint_commit",
"scope_type": "backlog_item",
"scope_id": str(item["id"]),
"rank": rank,
"reason_code": reason,
"summary": _REASON_LABELS.get(reason, "Zum Sprint committen"),
"title": item.get("title") or "Backlog-Item",
"item_kind": kind,
"priority": item.get("priority") or "normal",
"work_cycle_id": cycle_id,
"work_cycle_title": cycle_title,
"initiative_id": ctx.initiative_id,
"confidence": "heuristic",
"data_source": "steering_kernel",
}
)
return proposals
def sprint_commit_attention_items(
*,
initiative_id: str,
proposals: list[dict[str, Any]],
actions: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Attention when sprint has room for proposed commits."""
if not proposals:
return []
target_id = proposals[0].get("work_cycle_id")
if not target_id:
return []
sprint_actions = [
a
for a in actions
if a.get("work_cycle_id") and str(a["work_cycle_id"]) == str(target_id)
]
open_in_sprint = sum(1 for a in sprint_actions if a.get("status") in OPEN_ACTION)
items: list[dict[str, Any]] = []
cycle_title = proposals[0].get("work_cycle_title") or "Sprint"
if open_in_sprint == 0:
items.append(
{
"kind": "sprint_commit_suggested",
"severity": "info",
"title": cycle_title,
"summary": (
f"{len(proposals)} Item(s) im Eingang — "
"Kernel schlägt Sprint-Commit vor"
),
"scope_type": "work_cycle",
"scope_id": str(target_id),
"initiative_id": initiative_id,
"reason_code": "sprint_commit_suggested",
"data_source": "sprint_commit_proposal",
}
)
return items

View File

@ -0,0 +1,60 @@
"""Read model provider registry — K-Ext-1."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable
from steering.eval_context import SteeringEvalContext
ReadModelCompute = Callable[[SteeringEvalContext], Any]
@dataclass(frozen=True)
class ReadModelProvider:
key: str
requires_elements: frozenset[str]
requires_data_slices: frozenset[str]
compute: ReadModelCompute
_PROVIDERS: list[ReadModelProvider] = []
def register_read_model(provider: ReadModelProvider) -> None:
_PROVIDERS.append(provider)
def compute_read_models(ctx: SteeringEvalContext) -> dict[str, Any]:
"""Run all matching read model providers for the initiative context."""
elements = ctx.steering_elements
slices = frozenset(ctx.data_slices)
result: dict[str, Any] = {}
for provider in _PROVIDERS:
if provider.requires_elements and not provider.requires_elements <= elements:
continue
if provider.requires_data_slices and not provider.requires_data_slices <= slices:
continue
result[provider.key] = provider.compute(ctx)
return result
def _register_builtin_read_models() -> None:
from steering.read_models.epic_rollup import compute_epic_rollup
register_read_model(
ReadModelProvider(
key="epic_rollup",
requires_elements=frozenset({"backlog_epic_hierarchy"}),
requires_data_slices=frozenset({"backlog"}),
compute=lambda ctx: compute_epic_rollup(
backlog_items=ctx.backlog_items,
actions=ctx.actions,
),
)
)
_register_builtin_read_models()

View File

@ -0,0 +1,75 @@
"""AP2.2i — Sprint commit proposals via Kernel v0.3 (P6)."""
from __future__ import annotations
from tests.factories import provision_user_in_tenant
from tests.test_initiatives_actions import _auth, _create_initiative, _login
def test_steering_snapshot_includes_sprint_commit_proposals(client):
user = provision_user_in_tenant(tenant_role="admin")
token = _login(client, user)
created = _create_initiative(
client,
token,
title="Sprint Proposal",
archetype_key="initiative.product",
)
initiative_id = created.json()["id"]
client.post(
f"/api/initiatives/{initiative_id}/work-cycles",
json={"title": "Sprint R2", "status": "planned"},
headers=_auth(token),
)
client.post(
f"/api/initiatives/{initiative_id}/backlog",
json={"title": "Fix login", "item_kind": "bug", "status": "accepted", "priority": "high"},
headers=_auth(token),
)
client.post(
f"/api/initiatives/{initiative_id}/backlog",
json={"title": "Checkout story", "item_kind": "story", "status": "accepted"},
headers=_auth(token),
)
snapshot = client.get(
f"/api/initiatives/{initiative_id}/steering-snapshot",
headers=_auth(token),
)
assert snapshot.status_code == 200
body = snapshot.json()
assert body["steering_kernel"]["data_source"] == "steering_kernel_v0.3"
proposals = body.get("sprint_commit_proposals") or []
assert len(proposals) >= 2
assert proposals[0]["item_kind"] == "bug"
assert proposals[0]["scope_type"] == "backlog_item"
assert proposals[0]["proposal_key"] == "sprint_commit"
attention_codes = [
item.get("code") or item.get("reason_code")
for item in body.get("attention_items", [])
]
assert "sprint_commit_suggested" in attention_codes
def test_linear_initiative_has_no_sprint_proposals(client):
user = provision_user_in_tenant(tenant_role="admin")
token = _login(client, user)
created = _create_initiative(
client,
token,
title="Linear Proposals",
archetype_key="initiative.linear_project",
)
initiative_id = created.json()["id"]
snapshot = client.get(
f"/api/initiatives/{initiative_id}/steering-snapshot",
headers=_auth(token),
)
assert snapshot.status_code == 200
assert snapshot.json().get("sprint_commit_proposals") == []

View File

@ -42,7 +42,7 @@ def test_evaluate_steering_returns_horizon_and_next(client):
ctx = tenant_context_from_user(user)
evaluation = evaluate_steering(ctx, initiative_id=initiative_id)
assert evaluation.data_source == "steering_kernel_v0.2"
assert evaluation.data_source == "steering_kernel_v0.3"
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"
@ -77,7 +77,7 @@ def test_snapshot_routes_through_kernel_not_side_path(client):
f"/api/initiatives/{initiative_id}/steering-snapshot",
headers=_auth(token),
)
assert snap.json()["steering_kernel"]["data_source"] == "steering_kernel_v0.2"
assert snap.json()["steering_kernel"]["data_source"] == "steering_kernel_v0.3"
assert "lifecycle" in snap.json()["steering_kernel"]
assert snap.json()["steering_kernel"]["lifecycle"]["slot_map"]["closure"] == "active"
@ -103,4 +103,4 @@ def test_apply_steering_event_stub(client):
)
assert result["accepted"] is True
assert result["handled"] is True
assert result["evaluation"]["data_source"] == "steering_kernel_v0.2"
assert result["evaluation"]["data_source"] == "steering_kernel_v0.3"

View File

@ -106,7 +106,7 @@ def test_continuous_product_routes_through_spine(client):
body = snap.json()
kernel = body["steering_kernel"]
assert kernel["primary_method_key"] == "continuous_product"
assert kernel["data_source"] == "steering_kernel_v0.2"
assert kernel["data_source"] == "steering_kernel_v0.3"
assert kernel["lifecycle"]["slot_map"]["closure"] == "n/a"
assert kernel["lifecycle"]["slot_map"]["planning"] == "active"
assert body.get("next_actions") is not None

View File

@ -0,0 +1,86 @@
"""Unit tests — sprint commit proposal (P6)."""
from __future__ import annotations
from steering.eval_context import SteeringEvalContext
from steering.kernel.models import HorizonMarker, LifecycleContext, SteeringBinding
from steering.proposals.sprint_commit import propose_sprint_commit
from tenant_context import TenantContext
def _ctx(
*,
backlog_items: list,
work_cycles: list,
vocabulary: dict | None = None,
) -> SteeringEvalContext:
binding = SteeringBinding(
primary_method_key="continuous_product",
composition_modifier="agile_iteration",
next_work_strategy_key="agile_iteration",
)
lifecycle = LifecycleContext(
current_state="execution",
current_state_label="Ausführung",
slot_map={},
active_slots=(),
operational_slots=(),
)
horizon = HorizonMarker(kind="work_cycle", work_cycle_id="wc-1", work_cycle_title="S1")
eval_ctx = SteeringEvalContext(
tenant_ctx=TenantContext(
user_id="u1",
email="t@example.com",
display_name="Test",
portal_role="user",
tenant_id="t1",
tenant_slug="t",
tenant_name="T",
tenant_role="admin",
actor_id=None,
actor_type=None,
session_token="test",
capabilities=frozenset(),
),
initiative_id="init-1",
binding=binding,
horizon=horizon,
lifecycle=lifecycle,
operating_context={
"steering_elements": ["work_cycle_scope", "backlog_epic_hierarchy"],
"data_slices": ["backlog", "work_cycles"],
"backlog_vocabulary": vocabulary
or {"convertible_kinds": ["story", "bug", "issue"]},
},
)
eval_ctx._backlog_items = backlog_items
eval_ctx._work_cycles = work_cycles
return eval_ctx
def test_sprint_commit_ranks_bugs_before_stories():
proposals = propose_sprint_commit(
_ctx(
work_cycles=[{"id": "wc-1", "title": "Sprint 1", "status": "planned", "sort_order": 0}],
backlog_items=[
{"id": "s1", "title": "Story", "item_kind": "story", "status": "accepted", "priority": "normal", "sort_order": 0},
{"id": "b1", "title": "Bug", "item_kind": "bug", "status": "accepted", "priority": "normal", "sort_order": 1},
],
)
)
assert len(proposals) == 2
assert proposals[0]["scope_id"] == "b1"
assert proposals[0]["reason_code"] == "bug_default"
assert proposals[0]["work_cycle_id"] == "wc-1"
def test_sprint_commit_empty_without_planable_cycle():
proposals = propose_sprint_commit(
_ctx(
work_cycles=[],
backlog_items=[
{"id": "s1", "title": "Story", "item_kind": "story", "status": "accepted", "priority": "normal", "sort_order": 0},
],
)
)
assert proposals == []

View File

@ -39,7 +39,7 @@ Das reicht für **Dokumentation und Nachverfolgung**, aber noch nicht für **vor
| Phase | Inhalt | Kernel-Extension | Plugin / OM |
|-------|--------|---------|--------------|
| **P5** | Agent-Task-Baum unter Action | — | Ist / Recursive Tasks (AP1.5d) |
| **P6** | Sprint-Vorschlag (ranked Backlog → Sprint) | `ProposalProvider` `sprint_commit` + Read Models | `agile_iteration` Strategy |
| **P6** | Sprint-Vorschlag (ranked Backlog → Sprint) | `ProposalProvider` `sprint_commit` | ✓ AP2.2i (Kernel v0.3) |
| **P7** | Tech-/Architekturschuld führen & abbauen | `ReadModelProvider` + `AttentionContributor` | Vokabular + Review/Evidence |
| **P8** | Automatisierte Reviews (Guardrails, Ziele) | `AgentSlotProvider` + Recurring-Attention | Review + Actor (KI nach Gate) |

View File

@ -219,9 +219,9 @@ class AgentSlotProvider:
| Phase | Inhalt | Entkoppelt |
|-------|--------|------------|
| **K-Ext-1** | `read_models/registry.py` + Kernel ruft Provider; P4 `epic_rollup` migrieren | Snapshot nur noch Kernel-Output spiegeln |
| **K-Ext-2** | `attention/contributors/registry.py`; inline-Attention refactoren | Methoden adden Contributor |
| **K-Ext-3** | `proposals/registry.py` + `SteeringEvaluation.proposals` | P6 Agile Sprint-Vorschlag |
| **K-Ext-1** | `read_models/registry.py` + Kernel ruft Provider; P4 `epic_rollup` migriert | ✓ Kernel v0.3 |
| **K-Ext-2** | `attention/contributors/registry.py`; inline-Attention refactoren | offen |
| **K-Ext-3** | `proposals/registry.py` + `SteeringEvaluation.proposals` | ✓ AP2.2i (P6) |
| **K-Ext-4** | `agent_slots/registry.py` + Operating Context Feld `agent_slots` | P8 Reviews; Principle Gate für KI |
| **K-Ext-5** | Portfolio-Ebene: Workspace aggregiert Attention/Proposals über Initiativen | Program Director multi-initiative |
@ -244,7 +244,7 @@ class AgentSlotProvider:
## 9. P4-Ist → Ziel-Migration
P4 implementiert `epic_rollup` **funktional korrekt**, aber noch **doppelt** (Attention + Snapshot rufen Modul direkt).
**K-Ext-1** konsolidiert auf Registry — kein Verhaltenswechsel, nur Architekturhygiene.
**K-Ext-1** konsolidiert auf Registry — ✓ geliefert (Kernel v0.3, Snapshot spiegelt `evaluation.read_models`).
---

View File

@ -228,7 +228,7 @@ Details: `Kairo_Status_Review_and_Next_Steps_v0.1.md` §2.1
|----|-----------|
| AP2.3/4 | ✓ Plugin-Architektur + Element-Registry (2026-07-25) |
| AP2.2a | Starter-Kits ◐→✓ |
| ADP Backlog/Epic | P1P4 ✓ · P5 Agent-Tasks offen · Kernel-Ext K-Ext-1…5 |
| ADP Backlog/Epic | P1P4 ✓ · P6 Sprint-Vorschlag ✓ · P5 Agent-Tasks offen · K-Ext-2…4 offen |
| AP2.2be | Referenz-Archetypen End-to-End ◐→✓ |
| AP2.1 | MVP-Abnahfe ✗→✓ |
| AP1.7b | Op-API Parität |

View File

@ -0,0 +1,66 @@
import { proposalReasonLabel, sprintProposalsForCycle } from '../utils/sprintCommitProposal.js'
import { backlogKindLabel, resolveBacklogVocabulary } from '../utils/resolveBacklogVocabulary.js'
import { PriorityBadge } from './PriorityBadge.jsx'
export function SprintCommitProposalPanel({
proposals = [],
selectedWorkCycleId = '',
backlogVocabulary = null,
canManage = false,
onAcceptProposal,
busy = false,
}) {
const filtered = sprintProposalsForCycle(proposals, selectedWorkCycleId)
const vocabulary = resolveBacklogVocabulary(backlogVocabulary)
if (!selectedWorkCycleId || filtered.length === 0) {
return null
}
return (
<section className="card sprint-commit-proposals">
<div className="section-header">
<div>
<h2>Sprint-Vorschläge</h2>
<p className="section-lead muted">
Vom Steuerungskern priorisiert Accept committet ins Sprint-Backlog (kein Auto-Commit).
</p>
</div>
</div>
<ol className="item-list sprint-commit-proposals__list">
{filtered.map((proposal) => (
<li key={proposal.scope_id} className="list-item card-list-item sprint-commit-proposal">
<div className="list-item-main">
<strong>
{proposal.rank}. {proposal.title}
</strong>
<p className="list-item-sub muted">{proposalReasonLabel(proposal)}</p>
</div>
<div className="list-item-meta action-controls">
{proposal.item_kind && (
<span className="badge badge--kind muted">
{backlogKindLabel(vocabulary, proposal.item_kind)}
</span>
)}
<PriorityBadge priority={proposal.priority} />
{canManage && (
<button
type="button"
className="btn btn-primary btn-sm"
disabled={busy}
onClick={() =>
onAcceptProposal?.(proposal.scope_id, {
work_cycle_id: proposal.work_cycle_id,
})
}
>
In Sprint planen
</button>
)}
</div>
</li>
))}
</ol>
</section>
)
}

View File

@ -1,7 +1,8 @@
import { useMemo } from 'react'
import { useEffect, useMemo } from 'react'
import { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx'
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
import { WorkCyclesPanel } from '../../components/WorkCyclesPanel.jsx'
import { SprintCommitProposalPanel } from '../../components/SprintCommitProposalPanel.jsx'
import { InitiativeActionsHub } from '../../components/InitiativeActionsHub.jsx'
import { LoadingState } from '../../components/LoadingState.jsx'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
@ -47,8 +48,24 @@ function PlanSprintInner() {
handleCreateBlockerForAction,
handleMoveActionToSprint,
handleUnplanAction,
handleConvertBacklog,
reloadSlices,
steeringSnapshot,
operatingContext,
} = ops
useEffect(() => {
reloadSlices(['steering_snapshot'])
}, [reloadSlices])
const sprintCommitProposals = useMemo(
() =>
steeringSnapshot?.sprint_commit_proposals ||
steeringSnapshot?.steering_kernel?.proposals?.sprint_commit ||
[],
[steeringSnapshot],
)
const actionCountByCycleId = useMemo(
() => countActionsByWorkCycle(actions, { hideDone }),
[actions, hideDone],
@ -92,6 +109,19 @@ function PlanSprintInner() {
onSelectSprint={setSelectedWorkCycleId}
busy={formBusy}
/>
{selectedWorkCycleId && (
<SprintCommitProposalPanel
proposals={sprintCommitProposals}
selectedWorkCycleId={selectedWorkCycleId}
backlogVocabulary={operatingContext?.backlog_vocabulary}
canManage={capabilities.has('kairo.backlog.manage')}
onAcceptProposal={async (itemId, options) => {
await handleConvertBacklog(itemId, options)
await reloadSlices(['steering_snapshot', 'actions', 'backlog'])
}}
busy={formBusy}
/>
)}
{selectedWorkCycleId && selectedWorkCycle && (
<InitiativeActionsHub
initiativeId={initiativeId}

View File

@ -1699,6 +1699,23 @@
transition: width 0.2s ease;
}
.sprint-commit-proposals {
margin-top: 1rem;
}
.sprint-commit-proposals__list {
list-style: none;
padding: 0;
margin: 0;
}
.sprint-commit-proposal {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.archetype-badge {
background: var(--jk-surface-muted, #eef2f6);
color: var(--jk-text-secondary, #445);

View File

@ -0,0 +1,21 @@
/** Sprint commit proposals from steering kernel (P6). */
export function sprintProposalsForCycle(proposals = [], workCycleId = '') {
if (!workCycleId) return proposals || []
return (proposals || []).filter(
(item) => String(item.work_cycle_id) === String(workCycleId),
)
}
export function proposalReasonLabel(proposal) {
const labels = {
bug_critical: 'Kritischer Bug',
bug_high_priority: 'Bug (hoch)',
bug_default: 'Bug',
issue_default: 'Issue',
story_high_priority: 'Story (hoch)',
story_ready: 'Story bereit',
idea_default: 'Idee',
}
return labels[proposal?.reason_code] || proposal?.summary || 'Vorschlag'
}

View File

@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { proposalReasonLabel, sprintProposalsForCycle } from './sprintCommitProposal.js'
describe('sprintCommitProposal', () => {
const proposals = [
{ scope_id: 'b1', work_cycle_id: 'wc1', reason_code: 'bug_default' },
{ scope_id: 's1', work_cycle_id: 'wc2', reason_code: 'story_ready' },
]
it('filters by work cycle', () => {
expect(sprintProposalsForCycle(proposals, 'wc1')).toHaveLength(1)
expect(sprintProposalsForCycle(proposals, 'wc2')[0].scope_id).toBe('s1')
})
it('labels reason codes', () => {
expect(proposalReasonLabel({ reason_code: 'bug_critical' })).toBe('Kritischer Bug')
})
})