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)
|
evaluation = evaluate_steering(ctx, initiative_id=initiative_id, next_work_limit=5)
|
||||||
next_actions = evaluation.next_work
|
next_actions = evaluation.next_work
|
||||||
kernel_attention = evaluation.attention
|
kernel_attention = evaluation.attention
|
||||||
|
epic_rollup = evaluation.read_models.get("epic_rollup") or []
|
||||||
from services.operating_context import get_operating_context
|
sprint_commit_proposals = evaluation.proposals.get("sprint_commit") or []
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
steering = get_steering_context_dto(ctx, initiative_id=initiative_id)
|
steering = get_steering_context_dto(ctx, initiative_id=initiative_id)
|
||||||
metadata = steering.get("lifecycle_metadata") or {}
|
metadata = steering.get("lifecycle_metadata") or {}
|
||||||
|
|
@ -376,12 +353,15 @@ def get_initiative_steering_snapshot(
|
||||||
"primary_method_key": evaluation.binding.primary_method_key,
|
"primary_method_key": evaluation.binding.primary_method_key,
|
||||||
"composition_modifier": evaluation.binding.composition_modifier,
|
"composition_modifier": evaluation.binding.composition_modifier,
|
||||||
"data_source": evaluation.data_source,
|
"data_source": evaluation.data_source,
|
||||||
|
"read_models": evaluation.read_models,
|
||||||
|
"proposals": evaluation.proposals,
|
||||||
},
|
},
|
||||||
"upcoming_milestones": upcoming_milestones,
|
"upcoming_milestones": upcoming_milestones,
|
||||||
"upcoming_roadmap_items": upcoming_milestones,
|
"upcoming_roadmap_items": upcoming_milestones,
|
||||||
"next_actions": next_actions,
|
"next_actions": next_actions,
|
||||||
"active_work_cycle": active_work_cycle,
|
"active_work_cycle": active_work_cycle,
|
||||||
"epic_rollup": epic_rollup,
|
"epic_rollup": epic_rollup,
|
||||||
|
"sprint_commit_proposals": sprint_commit_proposals,
|
||||||
"counts": {
|
"counts": {
|
||||||
"actions_open": open_actions,
|
"actions_open": open_actions,
|
||||||
"actions_blocked": blocked_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."""
|
"""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
|
from steering.kernel.models import HorizonMarker, LifecycleContext, SteeringBinding, SteeringEvaluation
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|
@ -10,3 +9,10 @@ __all__ = [
|
||||||
"SteeringEvaluation",
|
"SteeringEvaluation",
|
||||||
"evaluate_steering",
|
"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,
|
binding: SteeringBinding,
|
||||||
horizon: HorizonMarker,
|
horizon: HorizonMarker,
|
||||||
next_work: list[dict[str, Any]],
|
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,
|
limit: int = 20,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Kernel attention for a single initiative — no fake next when planning debt."""
|
"""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
|
read_models = read_models or {}
|
||||||
from services import backlog as backlog_service
|
proposals = proposals or {}
|
||||||
from steering.read_models.epic_rollup import (
|
|
||||||
compute_epic_rollup,
|
|
||||||
epic_rollup_attention_items,
|
|
||||||
)
|
|
||||||
|
|
||||||
operating = get_operating_context(
|
from steering.read_models.epic_rollup import epic_rollup_attention_items
|
||||||
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
from steering.proposals.sprint_commit import sprint_commit_attention_items
|
||||||
)
|
|
||||||
if operating.get("backlog_vocabulary", {}).get("capabilities", {}).get(
|
epic_rollups = read_models.get("epic_rollup")
|
||||||
"epic_hierarchy"
|
if epic_rollups:
|
||||||
):
|
|
||||||
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)
|
|
||||||
items.extend(
|
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 (
|
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 __future__ import annotations
|
||||||
|
|
||||||
from steering.kernel.attention import evaluate_attention
|
from steering.kernel.attention import evaluate_attention
|
||||||
from steering.kernel.binding import resolve_steering_binding
|
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.horizon import resolve_horizon
|
||||||
from steering.kernel.lifecycle import resolve_lifecycle_context
|
from steering.kernel.lifecycle import resolve_lifecycle_context
|
||||||
from steering.kernel.models import SteeringEvaluation
|
from steering.kernel.models import SteeringEvaluation
|
||||||
from steering.kernel.next_work import evaluate_next_work
|
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
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -39,6 +42,14 @@ def evaluate_steering(
|
||||||
composition_modifier=binding.composition_modifier,
|
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(
|
next_work = evaluate_next_work(
|
||||||
ctx,
|
ctx,
|
||||||
initiative_id=initiative_id,
|
initiative_id=initiative_id,
|
||||||
|
|
@ -47,12 +58,18 @@ def evaluate_steering(
|
||||||
limit=next_work_limit,
|
limit=next_work_limit,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
read_models = compute_read_models(eval_ctx)
|
||||||
|
proposals = compute_proposals(eval_ctx, read_models=read_models)
|
||||||
|
|
||||||
attention = evaluate_attention(
|
attention = evaluate_attention(
|
||||||
ctx,
|
ctx,
|
||||||
initiative_id=initiative_id,
|
initiative_id=initiative_id,
|
||||||
binding=binding,
|
binding=binding,
|
||||||
horizon=horizon,
|
horizon=horizon,
|
||||||
next_work=next_work,
|
next_work=next_work,
|
||||||
|
read_models=read_models,
|
||||||
|
proposals=proposals,
|
||||||
|
eval_ctx=eval_ctx,
|
||||||
limit=attention_limit,
|
limit=attention_limit,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -63,4 +80,6 @@ def evaluate_steering(
|
||||||
lifecycle=lifecycle,
|
lifecycle=lifecycle,
|
||||||
next_work=next_work,
|
next_work=next_work,
|
||||||
attention=attention,
|
attention=attention,
|
||||||
|
read_models=read_models,
|
||||||
|
proposals=proposals,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,9 @@ class SteeringEvaluation:
|
||||||
lifecycle: LifecycleContext
|
lifecycle: LifecycleContext
|
||||||
next_work: list[dict[str, Any]] = field(default_factory=list)
|
next_work: list[dict[str, Any]] = field(default_factory=list)
|
||||||
attention: 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]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
|
|
@ -77,5 +79,7 @@ class SteeringEvaluation:
|
||||||
"lifecycle": self.lifecycle.to_dict(),
|
"lifecycle": self.lifecycle.to_dict(),
|
||||||
"next_work": self.next_work,
|
"next_work": self.next_work,
|
||||||
"attention": self.attention,
|
"attention": self.attention,
|
||||||
|
"read_models": self.read_models,
|
||||||
|
"proposals": self.proposals,
|
||||||
"data_source": self.data_source,
|
"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)
|
ctx = tenant_context_from_user(user)
|
||||||
evaluation = evaluate_steering(ctx, initiative_id=initiative_id)
|
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("action_selection") == "active"
|
||||||
assert evaluation.lifecycle.slot_map.get("validation") == "active"
|
assert evaluation.lifecycle.slot_map.get("validation") == "active"
|
||||||
assert evaluation.lifecycle.slot_map.get("review") == "optional"
|
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",
|
f"/api/initiatives/{initiative_id}/steering-snapshot",
|
||||||
headers=_auth(token),
|
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 "lifecycle" in snap.json()["steering_kernel"]
|
||||||
assert snap.json()["steering_kernel"]["lifecycle"]["slot_map"]["closure"] == "active"
|
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["accepted"] is True
|
||||||
assert result["handled"] 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()
|
body = snap.json()
|
||||||
kernel = body["steering_kernel"]
|
kernel = body["steering_kernel"]
|
||||||
assert kernel["primary_method_key"] == "continuous_product"
|
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"]["closure"] == "n/a"
|
||||||
assert kernel["lifecycle"]["slot_map"]["planning"] == "active"
|
assert kernel["lifecycle"]["slot_map"]["planning"] == "active"
|
||||||
assert body.get("next_actions") is not None
|
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 |
|
| Phase | Inhalt | Kernel-Extension | Plugin / OM |
|
||||||
|-------|--------|---------|--------------|
|
|-------|--------|---------|--------------|
|
||||||
| **P5** | Agent-Task-Baum unter Action | — | Ist / Recursive Tasks (AP1.5d) |
|
| **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 |
|
| **P7** | Tech-/Architekturschuld führen & abbauen | `ReadModelProvider` + `AttentionContributor` | Vokabular + Review/Evidence |
|
||||||
| **P8** | Automatisierte Reviews (Guardrails, Ziele) | `AgentSlotProvider` + Recurring-Attention | Review + Actor (KI nach Gate) |
|
| **P8** | Automatisierte Reviews (Guardrails, Ziele) | `AgentSlotProvider` + Recurring-Attention | Review + Actor (KI nach Gate) |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -219,9 +219,9 @@ class AgentSlotProvider:
|
||||||
|
|
||||||
| Phase | Inhalt | Entkoppelt |
|
| 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-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 | Methoden adden Contributor |
|
| **K-Ext-2** | `attention/contributors/registry.py`; inline-Attention refactoren | offen |
|
||||||
| **K-Ext-3** | `proposals/registry.py` + `SteeringEvaluation.proposals` | P6 Agile Sprint-Vorschlag |
|
| **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-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 |
|
| **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
|
## 9. P4-Ist → Ziel-Migration
|
||||||
|
|
||||||
P4 implementiert `epic_rollup` **funktional korrekt**, aber noch **doppelt** (Attention + Snapshot rufen Modul direkt).
|
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.3/4 | ✓ Plugin-Architektur + Element-Registry (2026-07-25) |
|
||||||
| AP2.2a | Starter-Kits ◐→✓ |
|
| 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.2b–e | Referenz-Archetypen End-to-End ◐→✓ |
|
||||||
| AP2.1 | MVP-Abnahfe ✗→✓ |
|
| AP2.1 | MVP-Abnahfe ✗→✓ |
|
||||||
| AP1.7b | Op-API Parität |
|
| 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 { RequireInitiativeScope } from '../../components/RequireInitiativeScope.jsx'
|
||||||
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
|
import { ScopedInitiativeProvider } from '../../layout/ScopedInitiativeProvider.jsx'
|
||||||
import { WorkCyclesPanel } from '../../components/WorkCyclesPanel.jsx'
|
import { WorkCyclesPanel } from '../../components/WorkCyclesPanel.jsx'
|
||||||
|
import { SprintCommitProposalPanel } from '../../components/SprintCommitProposalPanel.jsx'
|
||||||
import { InitiativeActionsHub } from '../../components/InitiativeActionsHub.jsx'
|
import { InitiativeActionsHub } from '../../components/InitiativeActionsHub.jsx'
|
||||||
import { LoadingState } from '../../components/LoadingState.jsx'
|
import { LoadingState } from '../../components/LoadingState.jsx'
|
||||||
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
|
||||||
|
|
@ -47,8 +48,24 @@ function PlanSprintInner() {
|
||||||
handleCreateBlockerForAction,
|
handleCreateBlockerForAction,
|
||||||
handleMoveActionToSprint,
|
handleMoveActionToSprint,
|
||||||
handleUnplanAction,
|
handleUnplanAction,
|
||||||
|
handleConvertBacklog,
|
||||||
|
reloadSlices,
|
||||||
|
steeringSnapshot,
|
||||||
|
operatingContext,
|
||||||
} = ops
|
} = ops
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reloadSlices(['steering_snapshot'])
|
||||||
|
}, [reloadSlices])
|
||||||
|
|
||||||
|
const sprintCommitProposals = useMemo(
|
||||||
|
() =>
|
||||||
|
steeringSnapshot?.sprint_commit_proposals ||
|
||||||
|
steeringSnapshot?.steering_kernel?.proposals?.sprint_commit ||
|
||||||
|
[],
|
||||||
|
[steeringSnapshot],
|
||||||
|
)
|
||||||
|
|
||||||
const actionCountByCycleId = useMemo(
|
const actionCountByCycleId = useMemo(
|
||||||
() => countActionsByWorkCycle(actions, { hideDone }),
|
() => countActionsByWorkCycle(actions, { hideDone }),
|
||||||
[actions, hideDone],
|
[actions, hideDone],
|
||||||
|
|
@ -92,6 +109,19 @@ function PlanSprintInner() {
|
||||||
onSelectSprint={setSelectedWorkCycleId}
|
onSelectSprint={setSelectedWorkCycleId}
|
||||||
busy={formBusy}
|
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 && (
|
{selectedWorkCycleId && selectedWorkCycle && (
|
||||||
<InitiativeActionsHub
|
<InitiativeActionsHub
|
||||||
initiativeId={initiativeId}
|
initiativeId={initiativeId}
|
||||||
|
|
|
||||||
|
|
@ -1699,6 +1699,23 @@
|
||||||
transition: width 0.2s ease;
|
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 {
|
.archetype-badge {
|
||||||
background: var(--jk-surface-muted, #eef2f6);
|
background: var(--jk-surface-muted, #eef2f6);
|
||||||
color: var(--jk-text-secondary, #445);
|
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