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
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:
parent
c5e2c69b1e
commit
20636f88d2
|
|
@ -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,
|
||||
|
|
|
|||
96
backend/steering/eval_context.py
Normal file
96
backend/steering/eval_context.py
Normal 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,
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
1
backend/steering/proposals/__init__.py
Normal file
1
backend/steering/proposals/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Steering proposals — ranked suggestions without auto-mutation (P6+)."""
|
||||
60
backend/steering/proposals/registry.py
Normal file
60
backend/steering/proposals/registry.py
Normal 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()
|
||||
178
backend/steering/proposals/sprint_commit.py
Normal file
178
backend/steering/proposals/sprint_commit.py
Normal 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
|
||||
60
backend/steering/read_models/registry.py
Normal file
60
backend/steering/read_models/registry.py
Normal 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()
|
||||
75
backend/tests/test_ap22i_sprint_commit_proposal.py
Normal file
75
backend/tests/test_ap22i_sprint_commit_proposal.py
Normal 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") == []
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
86
backend/tests/test_sprint_commit_unit.py
Normal file
86
backend/tests/test_sprint_commit_unit.py
Normal 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 == []
|
||||
|
|
@ -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) |
|
||||
|
||||
|
|
|
|||
|
|
@ -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`).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 | P1–P4 ✓ · P5 Agent-Tasks offen · Kernel-Ext K-Ext-1…5 |
|
||||
| ADP Backlog/Epic | P1–P4 ✓ · P6 Sprint-Vorschlag ✓ · P5 Agent-Tasks offen · K-Ext-2…4 offen |
|
||||
| AP2.2b–e | Referenz-Archetypen End-to-End ◐→✓ |
|
||||
| AP2.1 | MVP-Abnahfe ✗→✓ |
|
||||
| AP1.7b | Op-API Parität |
|
||||
|
|
|
|||
66
frontend/src/components/SprintCommitProposalPanel.jsx
Normal file
66
frontend/src/components/SprintCommitProposalPanel.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
21
frontend/src/utils/sprintCommitProposal.js
Normal file
21
frontend/src/utils/sprintCommitProposal.js
Normal 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'
|
||||
}
|
||||
18
frontend/src/utils/sprintCommitProposal.test.js
Normal file
18
frontend/src/utils/sprintCommitProposal.test.js
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user