diff --git a/backend/data_layer/initiative_snapshot.py b/backend/data_layer/initiative_snapshot.py index c722555..ddbb0b3 100644 --- a/backend/data_layer/initiative_snapshot.py +++ b/backend/data_layer/initiative_snapshot.py @@ -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, diff --git a/backend/steering/eval_context.py b/backend/steering/eval_context.py new file mode 100644 index 0000000..67bb5ac --- /dev/null +++ b/backend/steering/eval_context.py @@ -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, + ) diff --git a/backend/steering/kernel/__init__.py b/backend/steering/kernel/__init__.py index 5c21281..dad14cb 100644 --- a/backend/steering/kernel/__init__.py +++ b/backend/steering/kernel/__init__.py @@ -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) diff --git a/backend/steering/kernel/attention.py b/backend/steering/kernel/attention.py index de8a30e..4b5a882 100644 --- a/backend/steering/kernel/attention.py +++ b/backend/steering/kernel/attention.py @@ -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 ( diff --git a/backend/steering/kernel/evaluate.py b/backend/steering/kernel/evaluate.py index 2d70a27..e3125ed 100644 --- a/backend/steering/kernel/evaluate.py +++ b/backend/steering/kernel/evaluate.py @@ -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, ) diff --git a/backend/steering/kernel/models.py b/backend/steering/kernel/models.py index 6085675..f34d7ac 100644 --- a/backend/steering/kernel/models.py +++ b/backend/steering/kernel/models.py @@ -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, } diff --git a/backend/steering/proposals/__init__.py b/backend/steering/proposals/__init__.py new file mode 100644 index 0000000..fbcf0ad --- /dev/null +++ b/backend/steering/proposals/__init__.py @@ -0,0 +1 @@ +"""Steering proposals — ranked suggestions without auto-mutation (P6+).""" diff --git a/backend/steering/proposals/registry.py b/backend/steering/proposals/registry.py new file mode 100644 index 0000000..98bc74b --- /dev/null +++ b/backend/steering/proposals/registry.py @@ -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() diff --git a/backend/steering/proposals/sprint_commit.py b/backend/steering/proposals/sprint_commit.py new file mode 100644 index 0000000..078d560 --- /dev/null +++ b/backend/steering/proposals/sprint_commit.py @@ -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 diff --git a/backend/steering/read_models/registry.py b/backend/steering/read_models/registry.py new file mode 100644 index 0000000..f26ad3c --- /dev/null +++ b/backend/steering/read_models/registry.py @@ -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() diff --git a/backend/tests/test_ap22i_sprint_commit_proposal.py b/backend/tests/test_ap22i_sprint_commit_proposal.py new file mode 100644 index 0000000..52d0e5b --- /dev/null +++ b/backend/tests/test_ap22i_sprint_commit_proposal.py @@ -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") == [] diff --git a/backend/tests/test_ap_kernel_spine.py b/backend/tests/test_ap_kernel_spine.py index c094182..d78fc0d 100644 --- a/backend/tests/test_ap_kernel_spine.py +++ b/backend/tests/test_ap_kernel_spine.py @@ -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" diff --git a/backend/tests/test_ap_kernel_spine_v02.py b/backend/tests/test_ap_kernel_spine_v02.py index c842a02..e232166 100644 --- a/backend/tests/test_ap_kernel_spine_v02.py +++ b/backend/tests/test_ap_kernel_spine_v02.py @@ -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 diff --git a/backend/tests/test_sprint_commit_unit.py b/backend/tests/test_sprint_commit_unit.py new file mode 100644 index 0000000..2d6fde7 --- /dev/null +++ b/backend/tests/test_sprint_commit_unit.py @@ -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 == [] diff --git a/docs/architecture/ADP_Agile_Steering_Program_v0.1.md b/docs/architecture/ADP_Agile_Steering_Program_v0.1.md index 666a51f..c53579f 100644 --- a/docs/architecture/ADP_Agile_Steering_Program_v0.1.md +++ b/docs/architecture/ADP_Agile_Steering_Program_v0.1.md @@ -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) | diff --git a/docs/architecture/ADP_Steering_Kernel_Extension_Model_v0.1.md b/docs/architecture/ADP_Steering_Kernel_Extension_Model_v0.1.md index 9c946bd..ba34cbf 100644 --- a/docs/architecture/ADP_Steering_Kernel_Extension_Model_v0.1.md +++ b/docs/architecture/ADP_Steering_Kernel_Extension_Model_v0.1.md @@ -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`). --- diff --git a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md index bee61c3..4666cbf 100644 --- a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md +++ b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md @@ -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 | diff --git a/frontend/src/components/SprintCommitProposalPanel.jsx b/frontend/src/components/SprintCommitProposalPanel.jsx new file mode 100644 index 0000000..7d7beea --- /dev/null +++ b/frontend/src/components/SprintCommitProposalPanel.jsx @@ -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 ( + + + + Sprint-Vorschläge + + Vom Steuerungskern priorisiert — Accept committet ins Sprint-Backlog (kein Auto-Commit). + + + + + {filtered.map((proposal) => ( + + + + {proposal.rank}. {proposal.title} + + {proposalReasonLabel(proposal)} + + + {proposal.item_kind && ( + + {backlogKindLabel(vocabulary, proposal.item_kind)} + + )} + + {canManage && ( + + onAcceptProposal?.(proposal.scope_id, { + work_cycle_id: proposal.work_cycle_id, + }) + } + > + In Sprint planen + + )} + + + ))} + + + ) +} diff --git a/frontend/src/pages/modes/PlanSprintPage.jsx b/frontend/src/pages/modes/PlanSprintPage.jsx index 0461e05..a012fba 100644 --- a/frontend/src/pages/modes/PlanSprintPage.jsx +++ b/frontend/src/pages/modes/PlanSprintPage.jsx @@ -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 && ( + { + await handleConvertBacklog(itemId, options) + await reloadSlices(['steering_snapshot', 'actions', 'backlog']) + }} + busy={formBusy} + /> + )} {selectedWorkCycleId && selectedWorkCycle && ( 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' +} diff --git a/frontend/src/utils/sprintCommitProposal.test.js b/frontend/src/utils/sprintCommitProposal.test.js new file mode 100644 index 0000000..5b4236b --- /dev/null +++ b/frontend/src/utils/sprintCommitProposal.test.js @@ -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') + }) +})
+ Vom Steuerungskern priorisiert — Accept committet ins Sprint-Backlog (kein Auto-Commit). +
{proposalReasonLabel(proposal)}