From 1bbc5dbfce08859e2f4a4a7d77baa68f045a8fb3 Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 27 Jul 2026 11:38:22 +0200 Subject: [PATCH] refactor(steering): Sprint-Vorschlaege mit austauschbarem Ranker und Abhaengigkeiten Keine feste Bug-zuerst-Policy: heuristic_v0 als Fallback, agent_v1-Slot vorbereitet, parent_action-Abhaengigkeiten und factors[] fuer spaetere KI-Steuerung. Co-authored-by: Cursor --- backend/steering/proposals/registry.py | 2 +- backend/steering/proposals/sprint_commit.py | 151 +++++---------- .../proposals/sprint_commit_agent_v1.py | 64 +++++++ .../proposals/sprint_commit_context.py | 173 ++++++++++++++++++ .../proposals/sprint_commit_heuristic_v0.py | 157 ++++++++++++++++ .../test_ap22i_sprint_commit_proposal.py | 4 +- backend/tests/test_sprint_commit_unit.py | 44 ++++- .../ADP_Agile_Steering_Program_v0.1.md | 19 +- ...DP_Steering_Kernel_Extension_Model_v0.1.md | 12 +- .../components/SprintCommitProposalPanel.jsx | 11 +- frontend/src/styles/components.css | 4 + frontend/src/utils/sprintCommitProposal.js | 14 +- .../src/utils/sprintCommitProposal.test.js | 3 +- 13 files changed, 524 insertions(+), 134 deletions(-) create mode 100644 backend/steering/proposals/sprint_commit_agent_v1.py create mode 100644 backend/steering/proposals/sprint_commit_context.py create mode 100644 backend/steering/proposals/sprint_commit_heuristic_v0.py diff --git a/backend/steering/proposals/registry.py b/backend/steering/proposals/registry.py index 98bc74b..4011629 100644 --- a/backend/steering/proposals/registry.py +++ b/backend/steering/proposals/registry.py @@ -52,7 +52,7 @@ def _register_builtin_proposals() -> None: 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), + compute=lambda ctx, read_models: propose_sprint_commit(ctx, read_models=read_models), ) ) diff --git a/backend/steering/proposals/sprint_commit.py b/backend/steering/proposals/sprint_commit.py index 078d560..6267e5c 100644 --- a/backend/steering/proposals/sprint_commit.py +++ b/backend/steering/proposals/sprint_commit.py @@ -1,4 +1,4 @@ -"""Sprint commit proposals — ADP P6 / agile_iteration.""" +"""Sprint commit proposals — ADP P6 / pluggable ranking.""" from __future__ import annotations @@ -6,126 +6,65 @@ 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", -} +# Ranker registration side effects +import steering.proposals.sprint_commit_agent_v1 # noqa: F401 +import steering.proposals.sprint_commit_heuristic_v0 # noqa: F401 +from steering.proposals.sprint_commit_context import ( + build_sprint_commit_candidates, + resolve_sprint_commit_ranker, + resolve_target_work_cycle, +) -def _resolve_target_work_cycle( +def propose_sprint_commit( 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) + *, + read_models: dict[str, Any] | None = None, +) -> list[dict[str, Any]]: + """Ranked backlog items for sprint commit — ranker is swappable, no auto-mutation.""" + read_models = read_models or {} + 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" + candidates = build_sprint_commit_candidates(ctx, target_work_cycle_id=cycle_id) + if not candidates: + return [] + + ranker = resolve_sprint_commit_ranker(ctx) + ranked = ranker.rank( + ctx, + candidates=candidates, + read_models=read_models, + target_work_cycle_id=cycle_id, + ) + 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" + for row in ranked: + item = row.candidate.item 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"), + "rank": row.rank, + "score": row.score, + "reason_code": row.reason_code, + "summary": row.summary, "title": item.get("title") or "Backlog-Item", - "item_kind": kind, + "item_kind": item.get("item_kind") or "story", "priority": item.get("priority") or "normal", "work_cycle_id": cycle_id, "work_cycle_title": cycle_title, "initiative_id": ctx.initiative_id, - "confidence": "heuristic", + "ranker_key": row.ranker_key, + "confidence": "agent" if row.ranker_key.startswith("agent") else "heuristic", + "factors": row.factors, + "dependency_refs": row.candidate.dependency_refs, + "dependency_blocked": row.candidate.dependency_blocked, "data_source": "steering_kernel", } ) @@ -140,6 +79,8 @@ def sprint_commit_attention_items( actions: list[dict[str, Any]], ) -> list[dict[str, Any]]: """Attention when sprint has room for proposed commits.""" + from steering.proposals.sprint_commit_context import OPEN_ACTION + if not proposals: return [] @@ -154,18 +95,20 @@ def sprint_commit_attention_items( ] open_in_sprint = sum(1 for a in sprint_actions if a.get("status") in OPEN_ACTION) + ready = [p for p in proposals if not p.get("dependency_blocked")] items: list[dict[str, Any]] = [] cycle_title = proposals[0].get("work_cycle_title") or "Sprint" - if open_in_sprint == 0: + if open_in_sprint == 0 and ready: + ranker = proposals[0].get("ranker_key", "heuristic_v0") 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" + f"{len(ready)} commit-bereite Item(s) — " + f"Vorschlag via Ranker {ranker}" ), "scope_type": "work_cycle", "scope_id": str(target_id), diff --git a/backend/steering/proposals/sprint_commit_agent_v1.py b/backend/steering/proposals/sprint_commit_agent_v1.py new file mode 100644 index 0000000..0f16461 --- /dev/null +++ b/backend/steering/proposals/sprint_commit_agent_v1.py @@ -0,0 +1,64 @@ +"""Agent sprint ranker slot — placeholder for Principle Gate / Actor execution.""" + +from __future__ import annotations + +from typing import Any + +from steering.eval_context import SteeringEvalContext +from steering.proposals.sprint_commit_context import ( + RankedSprintCommitCandidate, + SprintCommitCandidate, + register_sprint_commit_ranker, +) +from steering.proposals.sprint_commit_heuristic_v0 import HeuristicV0SprintCommitRanker + +_FALLBACK = HeuristicV0SprintCommitRanker() + + +class AgentV1SprintCommitRanker: + """ + Agent-gestütztes Ranking — noch nicht aktiv (Principle Gate). + + Kontext kommt aus Snapshot/Operating Context; Scoring über auditierten Actor, + nicht aus hardcodiertem Prompt. Bis Freigabe: Fallback auf heuristic_v0. + """ + + key = "agent_v1" + + def rank( + self, + ctx: SteeringEvalContext, + *, + candidates: list[SprintCommitCandidate], + read_models: dict[str, Any], + target_work_cycle_id: str, + ) -> list[RankedSprintCommitCandidate]: + # TODO(P6+): Actor-Slot + Governance-Pack; KI wählt Subset + Begründung + ranked = _FALLBACK.rank( + ctx, + candidates=candidates, + read_models=read_models, + target_work_cycle_id=target_work_cycle_id, + ) + return [ + RankedSprintCommitCandidate( + candidate=row.candidate, + rank=row.rank, + score=row.score, + reason_code=row.reason_code, + summary=f"{row.summary} (Agent-Ranker: Fallback heuristic_v0)", + factors=row.factors + + [ + { + "code": "ranker_fallback", + "weight": 0, + "label": "agent_v1 noch nicht freigegeben", + } + ], + ranker_key="agent_v1_fallback", + ) + for row in ranked + ] + + +register_sprint_commit_ranker(AgentV1SprintCommitRanker()) diff --git a/backend/steering/proposals/sprint_commit_context.py b/backend/steering/proposals/sprint_commit_context.py new file mode 100644 index 0000000..6b979bd --- /dev/null +++ b/backend/steering/proposals/sprint_commit_context.py @@ -0,0 +1,173 @@ +"""Sprint commit ranking — pluggable rankers (heuristic now, agent later).""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Protocol + +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"}) +_DONE_ACTION = frozenset({"done", "discarded"}) + + +@dataclass +class SprintCommitCandidate: + item: dict[str, Any] + dependency_refs: list[dict[str, Any]] = field(default_factory=list) + dependency_blocked: bool = False + dependency_reason: str | None = None + + +@dataclass +class RankedSprintCommitCandidate: + candidate: SprintCommitCandidate + rank: int + score: float + reason_code: str + summary: str + factors: list[dict[str, Any]] + ranker_key: str + + +class SprintCommitRanker(Protocol): + key: str + + def rank( + self, + ctx: SteeringEvalContext, + *, + candidates: list[SprintCommitCandidate], + read_models: dict[str, Any], + target_work_cycle_id: str, + ) -> list[RankedSprintCommitCandidate]: + ... + + +_RANKERS: dict[str, SprintCommitRanker] = {} + + +def register_sprint_commit_ranker(ranker: SprintCommitRanker) -> None: + _RANKERS[ranker.key] = ranker + + +def resolve_sprint_commit_ranker(ctx: SteeringEvalContext) -> SprintCommitRanker: + """ + Ranker-Auswahl — nicht hardcodiert. + + Zukünftig: Governance/Method-Metadata (`proposal_rankers.sprint_commit=agent_v1`) + oder Actor-Slot mit auditiertem Agent-Scoring. Default: heuristic_v0 (Fallback). + """ + configured = ( + (ctx.operating_context.get("proposal_rankers") or {}).get("sprint_commit") + or (ctx.operating_context.get("method_capabilities") or {}).get( + "sprint_commit_ranker" + ) + ) + if configured and configured in _RANKERS: + return _RANKERS[configured] + return _RANKERS["heuristic_v0"] + + +def _action_by_id(actions: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: + return {str(a["id"]): a for a in actions if a.get("id")} + + +def build_sprint_commit_candidates( + ctx: SteeringEvalContext, + *, + target_work_cycle_id: str, +) -> list[SprintCommitCandidate]: + """Kandidaten + Abhängigkeitskontext — keine feste Bug/Story-Reihenfolge.""" + vocabulary = ctx.backlog_vocabulary + convertible = frozenset( + vocabulary.get("convertible_kinds") or ["story", "bug", "issue"] + ) + actions_by_id = _action_by_id(ctx.actions) + + candidates: list[SprintCommitCandidate] = [] + for item in ctx.backlog_items: + if item.get("status") not in COMMITTABLE_STATUSES: + continue + if item.get("item_kind") == "epic": + continue + if (item.get("item_kind") or "story") not in convertible: + continue + + refs: list[dict[str, Any]] = [] + blocked = False + block_reason: str | None = None + + parent_action_id = item.get("parent_action_id") + if parent_action_id: + parent = actions_by_id.get(str(parent_action_id)) + refs.append( + { + "ref_type": "parent_action", + "ref_id": str(parent_action_id), + "status": parent.get("status") if parent else "missing", + "title": parent.get("title") if parent else None, + } + ) + if not parent: + blocked = True + block_reason = "missing_parent_action" + elif parent.get("status") not in _DONE_ACTION: + in_target_sprint = ( + parent.get("work_cycle_id") + and str(parent["work_cycle_id"]) == str(target_work_cycle_id) + ) + if not in_target_sprint: + blocked = True + block_reason = "waiting_on_feature_action" + + parent_backlog_id = item.get("parent_backlog_id") + if parent_backlog_id: + refs.append( + { + "ref_type": "parent_epic", + "ref_id": str(parent_backlog_id), + } + ) + + candidates.append( + SprintCommitCandidate( + item=item, + dependency_refs=refs, + dependency_blocked=blocked, + dependency_reason=block_reason, + ) + ) + + return candidates + + +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 diff --git a/backend/steering/proposals/sprint_commit_heuristic_v0.py b/backend/steering/proposals/sprint_commit_heuristic_v0.py new file mode 100644 index 0000000..d2e55dc --- /dev/null +++ b/backend/steering/proposals/sprint_commit_heuristic_v0.py @@ -0,0 +1,157 @@ +"""Default sprint ranker — explicit fallback, not product policy.""" + +from __future__ import annotations + +from typing import Any + +from steering.eval_context import SteeringEvalContext +from steering.proposals.sprint_commit_context import ( + RankedSprintCommitCandidate, + SprintCommitCandidate, + SprintCommitRanker, + register_sprint_commit_ranker, +) + +_PRIORITY_SCORE = {"critical": 100, "high": 75, "normal": 50, "low": 25} +_KIND_BIAS = {"bug": 5, "issue": 3, "incident": 3, "story": 0, "idea": -5} + + +class HeuristicV0SprintCommitRanker: + """ + Fallback-Ranker bis Agent/Regelwerk aktiv ist. + + Sortiert primär nach Priorität + Eingangsreihenfolge; item_kind ist nur leichte + Tendenz in `factors` — keine „Bugs immer zuerst“-Policy. + """ + + key = "heuristic_v0" + + def rank( + self, + ctx: SteeringEvalContext, + *, + candidates: list[SprintCommitCandidate], + read_models: dict[str, Any], + target_work_cycle_id: str, + ) -> list[RankedSprintCommitCandidate]: + scored: list[tuple[float, SprintCommitCandidate, list[dict[str, Any]]]] = [] + + for candidate in candidates: + item = candidate.item + priority = item.get("priority") or "normal" + kind = item.get("item_kind") or "story" + priority_score = _PRIORITY_SCORE.get(priority, 50) + kind_bias = _KIND_BIAS.get(kind, 0) + + factors: list[dict[str, Any]] = [ + { + "code": "priority", + "weight": priority_score, + "label": f"Priorität {priority}", + }, + { + "code": "intake_order", + "weight": -(item.get("sort_order") or 0), + "label": "Eingangsreihenfolge", + }, + ] + if kind_bias: + factors.append( + { + "code": "kind_bias", + "weight": kind_bias, + "label": f"Typ {kind} (leichte Tendenz, keine Policy)", + } + ) + + score = priority_score + kind_bias - (item.get("sort_order") or 0) * 0.01 + + if candidate.dependency_blocked: + score -= 1000 + factors.append( + { + "code": "dependency_blocked", + "weight": -1000, + "label": candidate.dependency_reason or "Abhängigkeit offen", + } + ) + + epic_rollups = read_models.get("epic_rollup") or [] + parent_epic = item.get("parent_backlog_id") + if parent_epic: + rollup = next( + ( + r + for r in epic_rollups + if str(r.get("epic_backlog_id")) == str(parent_epic) + ), + None, + ) + if rollup and rollup.get("status") == "in_progress": + score += 2 + factors.append( + { + "code": "epic_in_progress", + "weight": 2, + "label": "Epic mit laufender Arbeit", + } + ) + + scored.append((score, candidate, factors)) + + scored.sort( + key=lambda row: ( + -row[0], + (row[1].item.get("title") or "").lower(), + ) + ) + + ranked: list[RankedSprintCommitCandidate] = [] + for index, (score, candidate, factors) in enumerate(scored[:20], start=1): + item = candidate.item + reason_code = _reason_code(item, candidate) + ranked.append( + RankedSprintCommitCandidate( + candidate=candidate, + rank=index, + score=score, + reason_code=reason_code, + summary=_summary_for(reason_code, candidate), + factors=factors, + ranker_key=self.key, + ) + ) + return ranked + + +def _reason_code(item: dict[str, Any], candidate: SprintCommitCandidate) -> str: + if candidate.dependency_blocked: + return candidate.dependency_reason or "dependency_blocked" + priority = item.get("priority") or "normal" + if priority == "critical": + return "priority_critical" + if priority == "high": + return "priority_high" + kind = item.get("item_kind") or "story" + if kind in ("bug", "issue", "incident"): + return "candidate_bug_or_issue" + return "candidate_ready" + + +def _summary_for(reason_code: str, candidate: SprintCommitCandidate) -> str: + if candidate.dependency_blocked: + if reason_code == "waiting_on_feature_action": + return "Wartet auf Feature-Arbeitspaket im Sprint oder erledigt" + if reason_code == "missing_parent_action": + return "Feature-Referenz fehlt — Abhängigkeit klären" + return "Abhängigkeit noch nicht erfüllt" + labels = { + "priority_critical": "Hohe Dringlichkeit (Priorität critical)", + "priority_high": "Erhöhte Priorität", + "candidate_bug_or_issue": "Kandidat (Heuristik — kein Bug-Zwang)", + "candidate_ready": "Bereit zum Commit (Heuristik)", + } + return labels.get(reason_code, "Sprint-Commit-Kandidat (Heuristik)") + + +register_sprint_commit_ranker(HeuristicV0SprintCommitRanker()) diff --git a/backend/tests/test_ap22i_sprint_commit_proposal.py b/backend/tests/test_ap22i_sprint_commit_proposal.py index 52d0e5b..9ba5963 100644 --- a/backend/tests/test_ap22i_sprint_commit_proposal.py +++ b/backend/tests/test_ap22i_sprint_commit_proposal.py @@ -44,9 +44,9 @@ def test_steering_snapshot_includes_sprint_commit_proposals(client): 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" + assert proposals[0].get("ranker_key") == "heuristic_v0" + assert "factors" in proposals[0] attention_codes = [ item.get("code") or item.get("reason_code") diff --git a/backend/tests/test_sprint_commit_unit.py b/backend/tests/test_sprint_commit_unit.py index 2d6fde7..48c10a0 100644 --- a/backend/tests/test_sprint_commit_unit.py +++ b/backend/tests/test_sprint_commit_unit.py @@ -1,4 +1,4 @@ -"""Unit tests — sprint commit proposal (P6).""" +"""Unit tests — sprint commit proposal rankers (P6).""" from __future__ import annotations @@ -12,6 +12,7 @@ def _ctx( *, backlog_items: list, work_cycles: list, + actions: list | None = None, vocabulary: dict | None = None, ) -> SteeringEvalContext: binding = SteeringBinding( @@ -55,23 +56,50 @@ def _ctx( ) eval_ctx._backlog_items = backlog_items eval_ctx._work_cycles = work_cycles + eval_ctx._actions = actions or [] return eval_ctx -def test_sprint_commit_ranks_bugs_before_stories(): +def test_critical_story_can_rank_before_normal_bug(): 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}, + {"id": "s1", "title": "Critical story", "item_kind": "story", "status": "accepted", "priority": "critical", "sort_order": 1}, + {"id": "b1", "title": "Minor bug", "item_kind": "bug", "status": "accepted", "priority": "normal", "sort_order": 0}, ], ) ) - 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" + assert proposals[0]["scope_id"] == "s1" + assert proposals[0]["ranker_key"] == "heuristic_v0" + assert any(f["code"] == "priority" for f in proposals[0]["factors"]) + + +def test_dependency_blocked_bug_ranks_after_ready_story(): + proposals = propose_sprint_commit( + _ctx( + work_cycles=[{"id": "wc-1", "title": "Sprint 1", "status": "planned", "sort_order": 0}], + backlog_items=[ + {"id": "s1", "title": "Ready story", "item_kind": "story", "status": "accepted", "priority": "normal", "sort_order": 1}, + { + "id": "b1", + "title": "Blocked bug", + "item_kind": "bug", + "status": "accepted", + "priority": "critical", + "sort_order": 0, + "parent_action_id": "a-feature", + }, + ], + actions=[ + {"id": "a-feature", "title": "Feature", "status": "in_progress", "work_cycle_id": None}, + ], + ) + ) + assert proposals[0]["scope_id"] == "s1" + blocked = next(p for p in proposals if p["scope_id"] == "b1") + assert blocked["dependency_blocked"] is True + assert blocked["reason_code"] == "waiting_on_feature_action" def test_sprint_commit_empty_without_planable_cycle(): diff --git a/docs/architecture/ADP_Agile_Steering_Program_v0.1.md b/docs/architecture/ADP_Agile_Steering_Program_v0.1.md index c53579f..446078a 100644 --- a/docs/architecture/ADP_Agile_Steering_Program_v0.1.md +++ b/docs/architecture/ADP_Agile_Steering_Program_v0.1.md @@ -49,15 +49,22 @@ Kein Phase-Sprung. **KI/Prompt/MCP** für P8 erst nach Principle Gate und stabil ## 4. P6 — Sprint-Vorschlag (Skizze) -**Read Model** `sprint_planning_proposal` (kein Pflicht-Commit): +**Read Model** `sprint_commit` (kein Pflicht-Commit): -- Input: offene Backlog-Items (Epic-Subbaum optional), aktiver/geplanter Sprint, Action-Ist, Blocker -- Output: ranked Liste `{ backlog_item_id, reason_code, score_hint }` -- Attention wenn: Sprint startet in N Tagen und Commit leer; Bugs ohne Owner; Epic intake_only trotz aktivem Sprint +- Input: committable Backlog-Items, Sprint-Horizont, Action-Ist, **Abhängigkeiten** (`parent_action_id`, Epic-Roll-up) +- Output: ranked Liste mit `ranker_key`, `factors[]`, `dependency_refs`, `dependency_blocked` +- **Keine feste Bug-Policy** — Default-Ranker `heuristic_v0` ist expliziter Fallback; Produktentscheidung später via `agent_v1` (Actor/KI, Principle Gate) -**UI:** Plan → Sprint — „Vorgeschlagene Items“ (Accept/Adjust), nicht Auto-Commit. +**Ranker-Auswahl** (Operating Context / Governance, nicht Page-If): -**Steuerung:** `evaluate_next_work` / eigene Strategy-Hook — **keine** Page-Ifs. +| Ranker | Status | Verhalten | +|--------|--------|-----------| +| `heuristic_v0` | ✓ Fallback | Priorität + Eingang + leichte kind_bias + Abhängigkeiten | +| `agent_v1` | Stub | Agent-Slot — wählt Subset/Begründung; bis Freigabe Fallback | + +**UI:** Plan → Sprint — Vorschläge Accept/Adjust; blockierte Abhängigkeiten nicht committierbar. + +**Steuerung:** `ProposalProvider` + austauschbarer Ranker — **nicht** `evaluate_next_work`, **keine** hardcodierte Sortierung in UI. --- 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 ba34cbf..83dc04f 100644 --- a/docs/architecture/ADP_Steering_Kernel_Extension_Model_v0.1.md +++ b/docs/architecture/ADP_Steering_Kernel_Extension_Model_v0.1.md @@ -138,14 +138,20 @@ class ProposalProvider: "scope_type": "backlog_item" | "action" | "roadmap_item" | "review", "scope_id": "...", "rank": 1, - "reason_code": "bug_in_active_sprint", - "summary": "Bug X — hohe Priorität, Sprint aktiv", - "confidence": "heuristic" | "rule" | "agent", # später + "score": 127.5, + "reason_code": "priority_critical", + "summary": "...", + "ranker_key": "heuristic_v0" | "agent_v1", + "confidence": "heuristic" | "agent", + "factors": [{"code": "priority", "weight": 100, "label": "..."}], + "dependency_refs": [{"ref_type": "parent_action", "ref_id": "...", "status": "..."}], + "dependency_blocked": false, "data_source": "steering_kernel", } ``` - UI: Accept / Adjust / Ignore — Mensch oder Agent bestätigt +- **Kein** festes „Bugs zuerst“ — Ranker austauschbar via `proposal_rankers.sprint_commit` im Operating Context / Governance - **Agile P6:** `sprint_commit` ProposalProvider - **Gate-Methoden:** `gate_next_actions` (planning_debt → konkrete AP-Vorschläge) - **Continuous Product:** `intake_triage` (Eingang priorisieren) diff --git a/frontend/src/components/SprintCommitProposalPanel.jsx b/frontend/src/components/SprintCommitProposalPanel.jsx index 7d7beea..83a4460 100644 --- a/frontend/src/components/SprintCommitProposalPanel.jsx +++ b/frontend/src/components/SprintCommitProposalPanel.jsx @@ -23,7 +23,9 @@ export function SprintCommitProposalPanel({

Sprint-Vorschläge

- Vom Steuerungskern priorisiert — Accept committet ins Sprint-Backlog (kein Auto-Commit). + Vom Steuerungskern priorisiert (Ranker:{' '} + {filtered[0]?.ranker_key || 'heuristic_v0'}) — Accept committet ins Sprint-Backlog. + Keine feste Bug-Policy; später Agent/KI wählbar.

@@ -35,6 +37,11 @@ export function SprintCommitProposalPanel({ {proposal.rank}. {proposal.title}

{proposalReasonLabel(proposal)}

+ {proposal.dependency_blocked && ( +

+ Abhängigkeit offen — wartet auf Feature oder Epic-Kontext +

+ )}
{proposal.item_kind && ( @@ -47,7 +54,7 @@ export function SprintCommitProposalPanel({