refactor(steering): Sprint-Vorschlaege mit austauschbarem Ranker und Abhaengigkeiten
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 4m8s
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 19s
Test Suite / playwright-smoke (push) Successful in 13s

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 <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-27 11:38:22 +02:00
parent 20636f88d2
commit 1bbc5dbfce
13 changed files with 524 additions and 134 deletions

View File

@ -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),
)
)

View File

@ -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),

View File

@ -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())

View File

@ -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

View File

@ -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())

View File

@ -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")

View File

@ -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():

View File

@ -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.
---

View File

@ -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)

View File

@ -23,7 +23,9 @@ export function SprintCommitProposalPanel({
<div>
<h2>Sprint-Vorschläge</h2>
<p className="section-lead muted">
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.
</p>
</div>
</div>
@ -35,6 +37,11 @@ export function SprintCommitProposalPanel({
{proposal.rank}. {proposal.title}
</strong>
<p className="list-item-sub muted">{proposalReasonLabel(proposal)}</p>
{proposal.dependency_blocked && (
<p className="list-item-sub muted sprint-commit-proposal__blocked">
Abhängigkeit offen wartet auf Feature oder Epic-Kontext
</p>
)}
</div>
<div className="list-item-meta action-controls">
{proposal.item_kind && (
@ -47,7 +54,7 @@ export function SprintCommitProposalPanel({
<button
type="button"
className="btn btn-primary btn-sm"
disabled={busy}
disabled={busy || proposal.dependency_blocked}
onClick={() =>
onAcceptProposal?.(proposal.scope_id, {
work_cycle_id: proposal.work_cycle_id,

View File

@ -1716,6 +1716,10 @@
gap: 1rem;
}
.sprint-commit-proposal__blocked {
color: var(--jk-warning-text, #7a4b08);
}
.archetype-badge {
background: var(--jk-surface-muted, #eef2f6);
color: var(--jk-text-secondary, #445);

View File

@ -9,13 +9,13 @@ export function sprintProposalsForCycle(proposals = [], 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',
priority_critical: 'Dringlichkeit critical',
priority_high: 'Hohe Priorität',
candidate_bug_or_issue: 'Kandidat (Heuristik)',
candidate_ready: 'Commit-bereit (Heuristik)',
waiting_on_feature_action: 'Wartet auf Feature-AP',
missing_parent_action: 'Feature-Referenz fehlt',
dependency_blocked: 'Abhängigkeit offen',
}
return labels[proposal?.reason_code] || proposal?.summary || 'Vorschlag'
}

View File

@ -13,6 +13,7 @@ describe('sprintCommitProposal', () => {
})
it('labels reason codes', () => {
expect(proposalReasonLabel({ reason_code: 'bug_critical' })).toBe('Kritischer Bug')
expect(proposalReasonLabel({ reason_code: 'priority_critical' })).toBe('Dringlichkeit critical')
expect(proposalReasonLabel({ reason_code: 'waiting_on_feature_action' })).toContain('Feature')
})
})