feat(steering): Kernel v0.4 Agent-Slots, Tech Debt und Review-Attention
Some checks failed
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Failing after 4m15s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped

K-Ext-4/P8 deklarative Agent-Slots; P7 tech_debt Read Model und Vokabular; Review-Attention in den Kernel verlagert.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-27 11:54:48 +02:00
parent b747200f3a
commit a5154cbd3b
31 changed files with 954 additions and 98 deletions

View File

@ -1,6 +1,6 @@
--- ---
description: Steering Kernel v0.3 — verbindliche Regeln für Read Models, Proposals, Attention, Ranker (alle Coding-Agenten) description: Steering Kernel v0.4 — verbindliche Regeln für Read Models, Proposals, Attention, Agent-Slots, Ranker (alle Coding-Agenten)
globs: backend/steering/**,backend/data_layer/initiative_snapshot.py,frontend/src/components/SteeringProposalsPanel.jsx,frontend/src/components/SprintCommitProposalPanel.jsx,frontend/src/utils/steeringProposals.js,frontend/src/utils/sprintCommitProposal.js,frontend/src/pages/initiative/InitiativeInboxPage.jsx,frontend/src/pages/initiative/InitiativePlanPage.jsx,frontend/src/pages/modes/PlanSprintPage.jsx globs: backend/steering/**,backend/data_layer/initiative_snapshot.py,frontend/src/components/SteeringProposalsPanel.jsx,frontend/src/components/AgentSlotsPanel.jsx,frontend/src/utils/steeringProposals.js,frontend/src/utils/agentSlots.js,frontend/src/utils/sprintCommitProposal.js,frontend/src/pages/initiative/InitiativeInboxPage.jsx,frontend/src/pages/initiative/InitiativePlanPage.jsx,frontend/src/pages/initiative/InitiativeOverviewPage.jsx,frontend/src/pages/modes/PlanSprintPage.jsx
alwaysApply: true alwaysApply: true
--- ---
@ -12,7 +12,7 @@ alwaysApply: true
## Herzmaschine ## Herzmaschine
- Initiative-Steuerung **nur** via `evaluate_steering()` in `backend/steering/kernel/evaluate.py` - Initiative-Steuerung **nur** via `evaluate_steering()` in `backend/steering/kernel/evaluate.py`
- Snapshot spiegelt `evaluation.read_models` + `evaluation.proposals` — **kein** Duplikat-Rechnen - Snapshot spiegelt `evaluation.read_models` + `evaluation.proposals` + `evaluation.agent_slots` — **kein** Duplikat-Rechnen
## Neue Fähigkeit = Provider registrieren ## Neue Fähigkeit = Provider registrieren
@ -21,6 +21,7 @@ alwaysApply: true
| Read Model | `steering/read_models/registry.py` | | Read Model | `steering/read_models/registry.py` |
| Proposal | `steering/proposals/registry.py` | | Proposal | `steering/proposals/registry.py` |
| Attention | `steering/attention/contributors/*.py` + Registry | | Attention | `steering/attention/contributors/*.py` + Registry |
| Agent-Slot | `steering/agent_slots/registry.py` |
| Sprint-Ranker | `register_sprint_commit_ranker()` — weitere Ranker analog | | Sprint-Ranker | `register_sprint_commit_ranker()` — weitere Ranker analog |
Aktivierung über `steering_elements` / `data_slices` im Methoden-Vertrag — **nicht** über Page-Ifs oder `method_key`-Branches im Kernel. Aktivierung über `steering_elements` / `data_slices` im Methoden-Vertrag — **nicht** über Page-Ifs oder `method_key`-Branches im Kernel.
@ -32,6 +33,12 @@ Aktivierung über `steering_elements` / `data_slices` im Methoden-Vertrag — **
- Abhängigkeiten: `dependency_refs`, `dependency_blocked` - Abhängigkeiten: `dependency_refs`, `dependency_blocked`
- KI: `agent_v1` Ranker + Actor-Slot — **keine** hardcodierten Prompts (Principle Gate) - KI: `agent_v1` Ranker + Actor-Slot — **keine** hardcodierten Prompts (Principle Gate)
## Agent-Slots
- Deklarative Fenster für Actors — **kein** Prompt, **keine** Auto-Ausführung
- Config: `operatingContext.agent_slot_config` (`enabled_slots`, `guardrail_pack`, `stale_debt_days`)
- UI: `AgentSlotsPanel` + Snapshot `steering_kernel.agent_slots`
## Frontend ## Frontend
- `SteeringProposalsPanel` + `PROPOSAL_UI_CONFIG` in `utils/steeringProposals.js` - `SteeringProposalsPanel` + `PROPOSAL_UI_CONFIG` in `utils/steeringProposals.js`
@ -47,6 +54,8 @@ Aktivierung über `steering_elements` / `data_slices` im Methoden-Vertrag — **
| `intake_triage` | `backlog` slice, **excludes** `work_cycle_scope` | | `intake_triage` | `backlog` slice, **excludes** `work_cycle_scope` |
| `epic_rollup` | `backlog_epic_hierarchy` | | `epic_rollup` | `backlog_epic_hierarchy` |
| `planning_debt` / `execution_graph` | `gate_fulfillment` / `critical_path` | | `planning_debt` / `execution_graph` | `gate_fulfillment` / `critical_path` |
| `tech_debt_summary` | `backlog` + `actions` slices |
| `review.due` / `recurring.due` / `review.guardrail` | Agent-Slots (data_slices + elements) |
## Verboten ## Verboten

View File

@ -355,6 +355,7 @@ def get_initiative_steering_snapshot(
"data_source": evaluation.data_source, "data_source": evaluation.data_source,
"read_models": evaluation.read_models, "read_models": evaluation.read_models,
"proposals": evaluation.proposals, "proposals": evaluation.proposals,
"agent_slots": evaluation.agent_slots,
}, },
"upcoming_milestones": upcoming_milestones, "upcoming_milestones": upcoming_milestones,
"upcoming_roadmap_items": upcoming_milestones, "upcoming_roadmap_items": upcoming_milestones,
@ -364,6 +365,8 @@ def get_initiative_steering_snapshot(
"sprint_commit_proposals": sprint_commit_proposals, "sprint_commit_proposals": sprint_commit_proposals,
"gate_next_actions_proposals": evaluation.proposals.get("gate_next_actions") or [], "gate_next_actions_proposals": evaluation.proposals.get("gate_next_actions") or [],
"intake_triage_proposals": evaluation.proposals.get("intake_triage") or [], "intake_triage_proposals": evaluation.proposals.get("intake_triage") or [],
"agent_slots": evaluation.agent_slots,
"tech_debt_summary": evaluation.read_models.get("tech_debt_summary") or {},
"counts": { "counts": {
"actions_open": open_actions, "actions_open": open_actions,
"actions_blocked": blocked_actions, "actions_blocked": blocked_actions,

View File

@ -0,0 +1,11 @@
-- P7: Technische Schuld — item_kind / action_kind tech_debt
ALTER TABLE actions DROP CONSTRAINT IF EXISTS actions_action_kind_check;
ALTER TABLE actions
ADD CONSTRAINT actions_action_kind_check
CHECK (action_kind IN ('delivery', 'planning', 'review', 'bug', 'issue', 'tech_debt'));
ALTER TABLE backlog_items DROP CONSTRAINT IF EXISTS backlog_items_item_kind_check;
ALTER TABLE backlog_items
ADD CONSTRAINT backlog_items_item_kind_check
CHECK (item_kind IN ('story', 'bug', 'issue', 'epic', 'tech_debt'));

View File

@ -20,7 +20,7 @@ class BacklogCreateRequest(BaseModel):
priority: Literal["low", "normal", "high"] = "normal" priority: Literal["low", "normal", "high"] = "normal"
roadmap_item_id: Optional[str] = None roadmap_item_id: Optional[str] = None
sort_order: Optional[int] = None sort_order: Optional[int] = None
item_kind: Literal["story", "bug", "issue", "epic"] = "story" item_kind: Literal["story", "bug", "issue", "epic", "tech_debt"] = "story"
parent_action_id: Optional[str] = None parent_action_id: Optional[str] = None
parent_backlog_id: Optional[str] = None parent_backlog_id: Optional[str] = None
@ -33,7 +33,7 @@ class BacklogUpdateRequest(BaseModel):
roadmap_item_id: Optional[str] = None roadmap_item_id: Optional[str] = None
clear_roadmap_item: bool = False clear_roadmap_item: bool = False
sort_order: Optional[int] = None sort_order: Optional[int] = None
item_kind: Optional[Literal["story", "bug", "issue", "epic"]] = None item_kind: Optional[Literal["story", "bug", "issue", "epic", "tech_debt"]] = None
parent_action_id: Optional[str] = None parent_action_id: Optional[str] = None
clear_parent_action: bool = False clear_parent_action: bool = False
parent_backlog_id: Optional[str] = None parent_backlog_id: Optional[str] = None

View File

@ -29,7 +29,7 @@ _ACTION_COLUMNS = """
status, priority, due_at, sort_order, action_kind, parent_action_id, created_at, updated_at status, priority, due_at, sort_order, action_kind, parent_action_id, created_at, updated_at
""" """
ACTION_KINDS = frozenset({"delivery", "planning", "review", "bug", "issue"}) ACTION_KINDS = frozenset({"delivery", "planning", "review", "bug", "issue", "tech_debt"})
UNPLAN_ALLOWED_STATUSES = frozenset({"open", "ready"}) UNPLAN_ALLOWED_STATUSES = frozenset({"open", "ready"})
CARRYOVER_UNPLAN_STATUSES = frozenset( CARRYOVER_UNPLAN_STATUSES = frozenset(
{"open", "ready", "in_progress", "blocked", "review_required"} {"open", "ready", "in_progress", "blocked", "review_required"}
@ -83,8 +83,8 @@ def _validate_parent_action_in_initiative(
if not row: if not row:
raise ValueError("Referenz-Arbeitspaket (Feature) nicht gefunden") raise ValueError("Referenz-Arbeitspaket (Feature) nicht gefunden")
action_kind = row["action_kind"] if isinstance(row, dict) else row[0] action_kind = row["action_kind"] if isinstance(row, dict) else row[0]
if action_kind in ("bug", "issue"): if action_kind in ("bug", "issue", "tech_debt"):
raise ValueError("Referenz muss ein Feature-Arbeitspaket sein, kein Bug/Issue") raise ValueError("Referenz muss ein Feature-Arbeitspaket sein, kein Bug/Issue/Schuld")
def _action_kind_to_item_kind(action_kind: str) -> str: def _action_kind_to_item_kind(action_kind: str) -> str:
@ -92,6 +92,8 @@ def _action_kind_to_item_kind(action_kind: str) -> str:
return "bug" return "bug"
if action_kind == "issue": if action_kind == "issue":
return "issue" return "issue"
if action_kind == "tech_debt":
return "tech_debt"
return "story" return "story"

View File

@ -14,11 +14,11 @@ from services.operating_context import get_operating_context
from services.plan_ist import validate_roadmap_item_in_initiative from services.plan_ist import validate_roadmap_item_in_initiative
BacklogStatus = Literal["new", "triaged", "accepted", "rejected", "converted"] BacklogStatus = Literal["new", "triaged", "accepted", "rejected", "converted"]
BacklogItemKind = Literal["story", "bug", "issue", "epic"] BacklogItemKind = Literal["story", "bug", "issue", "epic", "tech_debt"]
BACKLOG_STATUSES = frozenset({"new", "triaged", "accepted", "rejected", "converted"}) BACKLOG_STATUSES = frozenset({"new", "triaged", "accepted", "rejected", "converted"})
BACKLOG_ITEM_KINDS = frozenset({"story", "bug", "issue", "epic"}) BACKLOG_ITEM_KINDS = frozenset({"story", "bug", "issue", "epic", "tech_debt"})
CONVERTIBLE_ITEM_KINDS = frozenset({"story", "bug", "issue"}) CONVERTIBLE_ITEM_KINDS = frozenset({"story", "bug", "issue", "tech_debt"})
_BACKLOG_COLUMNS = """ _BACKLOG_COLUMNS = """
id, tenant_id, initiative_id, title, description, status, id, tenant_id, initiative_id, title, description, status,
@ -89,6 +89,8 @@ def _item_kind_to_action_kind(item_kind: str) -> str:
return "bug" return "bug"
if item_kind == "issue": if item_kind == "issue":
return "issue" return "issue"
if item_kind == "tech_debt":
return "tech_debt"
return "delivery" return "delivery"

View File

@ -145,6 +145,19 @@ def _compatible_methods_payload(
] ]
def _resolve_agent_slot_config(metadata: dict[str, Any]) -> dict[str, Any]:
"""Governance-Konfiguration für Agent-Slots — keine Prompts."""
defaults: dict[str, Any] = {
"enabled_slots": ["review.due", "recurring.due", "review.guardrail"],
"guardrail_pack": "default_v0",
"stale_debt_days": 30,
}
custom = metadata.get("agent_slots") or metadata.get("agent_slot_config") or {}
if isinstance(custom, dict):
return {**defaults, **custom}
return defaults
def get_operating_context( def get_operating_context(
*, tenant_id: str, initiative_id: str *, tenant_id: str, initiative_id: str
) -> Optional[dict[str, Any]]: ) -> Optional[dict[str, Any]]:
@ -190,6 +203,7 @@ def get_operating_context(
method_key=method_key, method_key=method_key,
steering_elements=effective_steering_elements, steering_elements=effective_steering_elements,
) )
agent_slot_config = _resolve_agent_slot_config(metadata)
return { return {
"initiative_id": initiative_id, "initiative_id": initiative_id,
@ -215,6 +229,7 @@ def get_operating_context(
], ],
"active_composition_modifier": active_composition_modifier, "active_composition_modifier": active_composition_modifier,
"backlog_vocabulary": backlog_vocabulary, "backlog_vocabulary": backlog_vocabulary,
"agent_slot_config": agent_slot_config,
} }

View File

@ -0,0 +1 @@
"""Agent slot providers — K-Ext-4 / P8."""

View File

@ -0,0 +1,54 @@
"""Agent slots for architecture guardrail reviews at active gates."""
from __future__ import annotations
from typing import Any
from steering.eval_context import SteeringEvalContext
def resolve_guardrail_review_slots(
ctx: SteeringEvalContext,
read_models: dict[str, Any],
) -> list[dict[str, Any]]:
"""Emit guardrail slot when active gate has planning debt or open review actions."""
horizon = ctx.horizon
if horizon.kind != "gate" or not horizon.gate_roadmap_item_id:
return []
gate_id = str(horizon.gate_roadmap_item_id)
guardrail_pack = ctx.agent_slot_config.get("guardrail_pack") or "default_v0"
has_planned_guardrail = any(
review.get("status") == "planned"
and str(review.get("milestone_id") or "") == gate_id
for review in ctx.reviews
)
if has_planned_guardrail:
return []
planning_debt = read_models.get("planning_debt") or []
gate_debt = any(str(d.get("roadmap_item_id") or "") == gate_id for d in planning_debt)
open_review_actions = any(
action.get("action_kind") == "review"
and str(action.get("roadmap_item_id") or "") == gate_id
and action.get("status") in ("open", "ready", "in_progress", "review_required")
for action in ctx.actions
)
if not gate_debt and not open_review_actions:
return []
return [
{
"slot_key": "review.guardrail",
"scope_type": "roadmap_item",
"scope_id": gate_id,
"title": horizon.gate_title or "Guardrail-Review",
"summary": "Architektur-Guardrail für aktives Gate prüfen",
"actor_role_hint": "architecture_reviewer",
"payload_keys": ["checklist_id", "guardrail_pack"],
"guardrail_pack": guardrail_pack,
"priority": "normal",
"roadmap_item_id": gate_id,
}
]

View File

@ -0,0 +1,48 @@
"""Agent slots for due recurring elements."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from steering.eval_context import SteeringEvalContext
def _parse_due(value: Any) -> datetime | None:
if not value:
return None
if isinstance(value, datetime):
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
try:
text = str(value).replace("Z", "+00:00")
parsed = datetime.fromisoformat(text)
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
except ValueError:
return None
def resolve_recurring_due_slots(ctx: SteeringEvalContext) -> list[dict[str, Any]]:
now = datetime.now(timezone.utc)
slots: list[dict[str, Any]] = []
for element in ctx.recurring_elements:
if element.get("status") != "active":
continue
due = _parse_due(element.get("next_due_at"))
if due is None or due > now:
continue
slots.append(
{
"slot_key": "recurring.due",
"scope_type": "recurring_element",
"scope_id": str(element["id"]),
"title": element.get("title") or "Rhythmus",
"summary": "Wiederkehrendes Element ist fällig",
"actor_role_hint": "operator",
"payload_keys": ["recurring_id", "interval_days"],
"priority": "normal",
"recurring_element_id": str(element["id"]),
}
)
return slots

View File

@ -0,0 +1,107 @@
"""Agent slot provider registry — K-Ext-4."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Callable
from steering.eval_context import SteeringEvalContext
AgentSlotResolve = Callable[[SteeringEvalContext, dict[str, Any], dict[str, Any]], list[dict]]
@dataclass(frozen=True)
class AgentSlotProvider:
slot_key: str
requires_elements: frozenset[str]
requires_data_slices: frozenset[str]
resolve: AgentSlotResolve
requires_any_element: frozenset[str] = field(default_factory=frozenset)
excludes_elements: frozenset[str] = field(default_factory=frozenset)
_PROVIDERS: list[AgentSlotProvider] = []
def register_agent_slot(provider: AgentSlotProvider) -> None:
_PROVIDERS.append(provider)
def _slot_enabled(ctx: SteeringEvalContext, slot_key: str) -> bool:
config = ctx.agent_slot_config
enabled = config.get("enabled_slots")
if enabled is None:
return True
return slot_key in enabled
def compute_agent_slots(
ctx: SteeringEvalContext,
*,
read_models: dict[str, Any],
proposals: dict[str, Any],
) -> list[dict[str, Any]]:
elements = ctx.steering_elements
slices = frozenset(ctx.data_slices)
slots: list[dict[str, Any]] = []
for provider in _PROVIDERS:
if not _slot_enabled(ctx, provider.slot_key):
continue
if provider.requires_elements and not provider.requires_elements <= elements:
continue
if provider.excludes_elements and provider.excludes_elements & elements:
continue
if provider.requires_any_element and not (
provider.requires_any_element & elements
):
continue
if provider.requires_data_slices and not provider.requires_data_slices <= slices:
continue
slots.extend(provider.resolve(ctx, read_models, proposals))
for slot in slots:
slot.setdefault("data_source", "steering_kernel")
slot.setdefault("initiative_id", ctx.initiative_id)
slot.setdefault("context_refs", ["steering_snapshot", "operating_context"])
slots.sort(
key=lambda s: {"high": 0, "normal": 1, "low": 2}.get(s.get("priority", "normal"), 9)
)
return slots
def _register_builtin_agent_slots() -> None:
from steering.agent_slots.guardrail_review import resolve_guardrail_review_slots
from steering.agent_slots.recurring_due import resolve_recurring_due_slots
from steering.agent_slots.review_due import resolve_review_due_slots
register_agent_slot(
AgentSlotProvider(
slot_key="review.due",
requires_elements=frozenset(),
requires_data_slices=frozenset({"reviews"}),
resolve=lambda ctx, read_models, proposals: resolve_review_due_slots(ctx),
)
)
register_agent_slot(
AgentSlotProvider(
slot_key="recurring.due",
requires_elements=frozenset({"recurring_rhythm"}),
requires_data_slices=frozenset({"recurring"}),
resolve=lambda ctx, read_models, proposals: resolve_recurring_due_slots(ctx),
)
)
register_agent_slot(
AgentSlotProvider(
slot_key="review.guardrail",
requires_elements=frozenset({"gate_fulfillment"}),
requires_data_slices=frozenset({"reviews", "roadmap"}),
resolve=lambda ctx, read_models, proposals: resolve_guardrail_review_slots(
ctx, read_models
),
)
)
_register_builtin_agent_slots()

View File

@ -0,0 +1,54 @@
"""Agent slots for due planned reviews."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any
from steering.eval_context import SteeringEvalContext
def _parse_due(value: Any) -> datetime | None:
if not value:
return None
if isinstance(value, datetime):
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
try:
text = str(value).replace("Z", "+00:00")
parsed = datetime.fromisoformat(text)
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
except ValueError:
return None
def resolve_review_due_slots(ctx: SteeringEvalContext) -> list[dict[str, Any]]:
now = datetime.now(timezone.utc)
slots: list[dict[str, Any]] = []
for review in ctx.reviews:
if review.get("status") != "planned":
continue
due = _parse_due(review.get("due_at"))
if due is None or due > now:
continue
slots.append(
{
"slot_key": "review.due",
"scope_type": "review",
"scope_id": str(review["id"]),
"title": review.get("title") or "Review",
"summary": "Geplantes Review ist fällig",
"actor_role_hint": "reviewer",
"payload_keys": ["review_id", "checklist_id"],
"priority": "high" if due < now else "normal",
"review_id": str(review["id"]),
"action_id": (
str(review["action_id"]) if review.get("action_id") else None
),
"milestone_id": (
str(review["milestone_id"]) if review.get("milestone_id") else None
),
}
)
return slots

View File

@ -0,0 +1,148 @@
"""Attention contributors for reviews, recurring, tech debt."""
from __future__ import annotations
from datetime import datetime, timezone
from steering.attention.context import AttentionEvalContext
from steering.attention.contributors.registry import (
AttentionContributor,
register_attention_contributor,
)
def _parse_due(value) -> datetime | None:
if not value:
return None
if isinstance(value, datetime):
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
try:
text = str(value).replace("Z", "+00:00")
parsed = datetime.fromisoformat(text)
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
except ValueError:
return None
def _reviews_due(ctx: AttentionEvalContext) -> list[dict]:
if not ctx.eval_ctx or "reviews" not in ctx.eval_ctx.data_slices:
return []
now = datetime.now(timezone.utc)
items: list[dict] = []
for review in ctx.eval_ctx.reviews:
if review.get("status") != "planned":
continue
due = _parse_due(review.get("due_at"))
if due is None or due > now:
continue
items.append(
{
"kind": "review_due",
"severity": "warning",
"title": review.get("title") or "Review",
"summary": "Review fällig",
"scope_type": "review",
"scope_id": str(review["id"]),
"initiative_id": ctx.initiative_id,
"review_id": str(review["id"]),
"reason_code": "review_due",
}
)
return items
def _recurring_due(ctx: AttentionEvalContext) -> list[dict]:
if not ctx.eval_ctx or "recurring" not in ctx.eval_ctx.data_slices:
return []
now = datetime.now(timezone.utc)
items: list[dict] = []
for element in ctx.eval_ctx.recurring_elements:
if element.get("status") != "active":
continue
due = _parse_due(element.get("next_due_at"))
if due is None or due > now:
continue
items.append(
{
"kind": "recurring_due",
"severity": "info",
"title": element.get("title") or "Rhythmus",
"summary": "Wiederkehrendes Element fällig",
"scope_type": "recurring_element",
"scope_id": str(element["id"]),
"initiative_id": ctx.initiative_id,
"recurring_element_id": str(element["id"]),
"reason_code": "recurring_due",
}
)
return items
def _action_review_required(ctx: AttentionEvalContext) -> list[dict]:
if not ctx.eval_ctx or "reviews" not in ctx.eval_ctx.data_slices:
return []
planned_by_action = {
str(review["action_id"])
for review in ctx.eval_ctx.reviews
if review.get("status") == "planned" and review.get("action_id")
}
items: list[dict] = []
for action in ctx.actions:
if action.get("status") != "review_required":
continue
action_id = str(action["id"])
if action_id in planned_by_action:
continue
items.append(
{
"kind": "action_review_required",
"severity": "warning",
"title": action.get("title") or "Arbeitspaket",
"summary": "Maßnahme wartet auf Review — kein geplantes Review verknüpft",
"scope_type": "action",
"scope_id": action_id,
"initiative_id": ctx.initiative_id,
"action_id": action_id,
"reason_code": "action_review_required_no_review",
}
)
return items
def _architecture_debt_stale(ctx: AttentionEvalContext) -> list[dict]:
summary = ctx.read_models.get("tech_debt_summary") or {}
if not summary.get("enabled") or not summary.get("stale_count"):
return []
return [
{
"kind": "architecture_debt_stale",
"severity": "warning",
"title": "Technische Schuld",
"summary": (
f"{summary['stale_count']} offene Schuld-Einträge älter als "
f"{summary.get('stale_days', 30)} Tage ohne Fortschritt"
),
"scope_type": "initiative",
"scope_id": ctx.initiative_id,
"initiative_id": ctx.initiative_id,
"reason_code": "architecture_debt_stale",
}
]
register_attention_contributor(
AttentionContributor(key="review_due", contribute=_reviews_due)
)
register_attention_contributor(
AttentionContributor(
key="recurring_due",
requires_elements=frozenset({"recurring_rhythm"}),
contribute=_recurring_due,
)
)
register_attention_contributor(
AttentionContributor(key="action_review_required", contribute=_action_review_required)
)
register_attention_contributor(
AttentionContributor(key="architecture_debt_stale", contribute=_architecture_debt_stale)
)

View File

@ -19,42 +19,45 @@ _EMPTY: dict[str, Any] = {
_AGILE_INTAKE: dict[str, Any] = { _AGILE_INTAKE: dict[str, Any] = {
"profile_key": "agile_intake", "profile_key": "agile_intake",
"kinds": ["epic", "story", "bug", "issue"], "kinds": ["epic", "story", "bug", "issue", "tech_debt"],
"labels": { "labels": {
"epic": "Epic", "epic": "Epic",
"story": "Story", "story": "Story",
"bug": "Bug", "bug": "Bug",
"issue": "Issue", "issue": "Issue",
"tech_debt": "Technische Schuld",
}, },
"default_kind": "story", "default_kind": "story",
"epic_hierarchy": True, "epic_hierarchy": True,
"convertible_kinds": ["story", "bug", "issue"], "convertible_kinds": ["story", "bug", "issue", "tech_debt"],
} }
_CONTINUOUS_INTAKE: dict[str, Any] = { _CONTINUOUS_INTAKE: dict[str, Any] = {
"profile_key": "continuous_intake", "profile_key": "continuous_intake",
"kinds": ["story", "bug", "issue"], "kinds": ["story", "bug", "issue", "tech_debt"],
"labels": { "labels": {
"story": "Idee", "story": "Idee",
"bug": "Bug", "bug": "Bug",
"issue": "Störung", "issue": "Störung",
"tech_debt": "Technische Schuld",
}, },
"default_kind": "story", "default_kind": "story",
"epic_hierarchy": False, "epic_hierarchy": False,
"convertible_kinds": ["story", "bug", "issue"], "convertible_kinds": ["story", "bug", "issue", "tech_debt"],
} }
_STANDARD_INTAKE: dict[str, Any] = { _STANDARD_INTAKE: dict[str, Any] = {
"profile_key": "standard_intake", "profile_key": "standard_intake",
"kinds": ["story", "bug", "issue"], "kinds": ["story", "bug", "issue", "tech_debt"],
"labels": { "labels": {
"story": "Story", "story": "Story",
"bug": "Bug", "bug": "Bug",
"issue": "Issue", "issue": "Issue",
"tech_debt": "Technische Schuld",
}, },
"default_kind": "story", "default_kind": "story",
"epic_hierarchy": False, "epic_hierarchy": False,
"convertible_kinds": ["story", "bug", "issue"], "convertible_kinds": ["story", "bug", "issue", "tech_debt"],
} }

View File

@ -23,6 +23,8 @@ class SteeringEvalContext:
_actions: list[dict[str, Any]] | None = field(default=None, repr=False) _actions: list[dict[str, Any]] | None = field(default=None, repr=False)
_backlog_items: 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) _work_cycles: list[dict[str, Any]] | None = field(default=None, repr=False)
_reviews: list[dict[str, Any]] | None = field(default=None, repr=False)
_recurring_elements: list[dict[str, Any]] | None = field(default=None, repr=False)
@property @property
def steering_elements(self) -> frozenset[str]: def steering_elements(self) -> frozenset[str]:
@ -36,6 +38,10 @@ class SteeringEvalContext:
def backlog_vocabulary(self) -> dict[str, Any]: def backlog_vocabulary(self) -> dict[str, Any]:
return dict(self.operating_context.get("backlog_vocabulary") or {}) return dict(self.operating_context.get("backlog_vocabulary") or {})
@property
def agent_slot_config(self) -> dict[str, Any]:
return dict(self.operating_context.get("agent_slot_config") or {})
@property @property
def actions(self) -> list[dict[str, Any]]: def actions(self) -> list[dict[str, Any]]:
if self._actions is None: if self._actions is None:
@ -69,6 +75,28 @@ class SteeringEvalContext:
) )
return self._work_cycles return self._work_cycles
@property
def reviews(self) -> list[dict[str, Any]]:
if self._reviews is None:
from services.reviews import list_reviews_for_initiative
self._reviews = list_reviews_for_initiative(
tenant_id=self.tenant_ctx.tenant_id,
initiative_id=self.initiative_id,
)
return self._reviews
@property
def recurring_elements(self) -> list[dict[str, Any]]:
if self._recurring_elements is None:
from services.recurring import list_recurring_for_initiative
self._recurring_elements = list_recurring_for_initiative(
tenant_id=self.tenant_ctx.tenant_id,
initiative_id=self.initiative_id,
)
return self._recurring_elements
def build_steering_eval_context( def build_steering_eval_context(
tenant_ctx: TenantContext, tenant_ctx: TenantContext,

View File

@ -11,6 +11,7 @@ from tenant_context import TenantContext
import steering.attention.contributors.core # noqa: F401 import steering.attention.contributors.core # noqa: F401
import steering.attention.contributors.gates # noqa: F401 import steering.attention.contributors.gates # noqa: F401
import steering.attention.contributors.read_models # noqa: F401 import steering.attention.contributors.read_models # noqa: F401
import steering.attention.contributors.reviews # noqa: F401
def evaluate_attention( def evaluate_attention(

View File

@ -1,4 +1,4 @@
"""Steering Kernel Spine — single entry point v0.3.""" """Steering Kernel Spine — single entry point v0.4."""
from __future__ import annotations from __future__ import annotations
@ -9,6 +9,7 @@ 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.agent_slots.registry import compute_agent_slots
from steering.proposals.registry import compute_proposals from steering.proposals.registry import compute_proposals
from steering.read_models.registry import compute_read_models from steering.read_models.registry import compute_read_models
from tenant_context import TenantContext from tenant_context import TenantContext
@ -73,6 +74,10 @@ def evaluate_steering(
limit=attention_limit, limit=attention_limit,
) )
agent_slots = compute_agent_slots(
eval_ctx, read_models=read_models, proposals=proposals
)
return SteeringEvaluation( return SteeringEvaluation(
initiative_id=initiative_id, initiative_id=initiative_id,
binding=binding, binding=binding,
@ -82,4 +87,5 @@ def evaluate_steering(
attention=attention, attention=attention,
read_models=read_models, read_models=read_models,
proposals=proposals, proposals=proposals,
agent_slots=agent_slots,
) )

View File

@ -67,7 +67,8 @@ class SteeringEvaluation:
attention: list[dict[str, Any]] = field(default_factory=list) attention: list[dict[str, Any]] = field(default_factory=list)
read_models: dict[str, Any] = field(default_factory=dict) read_models: dict[str, Any] = field(default_factory=dict)
proposals: dict[str, Any] = field(default_factory=dict) proposals: dict[str, Any] = field(default_factory=dict)
data_source: str = "steering_kernel_v0.3" agent_slots: list[dict[str, Any]] = field(default_factory=list)
data_source: str = "steering_kernel_v0.4"
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
return { return {
@ -81,5 +82,6 @@ class SteeringEvaluation:
"attention": self.attention, "attention": self.attention,
"read_models": self.read_models, "read_models": self.read_models,
"proposals": self.proposals, "proposals": self.proposals,
"agent_slots": self.agent_slots,
"data_source": self.data_source, "data_source": self.data_source,
} }

View File

@ -50,6 +50,7 @@ def _register_builtin_read_models() -> None:
from steering.read_models.epic_rollup import compute_epic_rollup from steering.read_models.epic_rollup import compute_epic_rollup
from steering.read_models.execution_graph import compute_execution_graph_read_model from steering.read_models.execution_graph import compute_execution_graph_read_model
from steering.read_models.planning_debt import compute_planning_debt_read_model from steering.read_models.planning_debt import compute_planning_debt_read_model
from steering.read_models.tech_debt import compute_tech_debt_summary
register_read_model( register_read_model(
ReadModelProvider( ReadModelProvider(
@ -79,6 +80,14 @@ def _register_builtin_read_models() -> None:
compute=compute_execution_graph_read_model, compute=compute_execution_graph_read_model,
) )
) )
register_read_model(
ReadModelProvider(
key="tech_debt_summary",
requires_elements=frozenset(),
requires_data_slices=frozenset({"backlog", "actions"}),
compute=compute_tech_debt_summary,
)
)
_register_builtin_read_models() _register_builtin_read_models()

View File

@ -0,0 +1,88 @@
"""Tech debt summary read model — P7."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
from steering.eval_context import SteeringEvalContext
_OPEN_BACKLOG = frozenset({"new", "triaged", "accepted"})
_OPEN_ACTION = frozenset({"open", "ready", "in_progress", "blocked", "review_required"})
def _parse_ts(value: Any) -> datetime | None:
if not value:
return None
if isinstance(value, datetime):
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
try:
text = str(value).replace("Z", "+00:00")
parsed = datetime.fromisoformat(text)
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
except ValueError:
return None
def compute_tech_debt_summary(ctx: SteeringEvalContext) -> dict[str, Any]:
vocabulary = ctx.backlog_vocabulary
if "tech_debt" not in (vocabulary.get("kinds") or []):
return {
"enabled": False,
"backlog_items": [],
"actions": [],
"total_open": 0,
"stale_count": 0,
}
stale_days = int(ctx.agent_slot_config.get("stale_debt_days") or 30)
cutoff = datetime.now(timezone.utc) - timedelta(days=stale_days)
backlog_items: list[dict[str, Any]] = []
for item in ctx.backlog_items:
if item.get("item_kind") != "tech_debt":
continue
if item.get("status") not in _OPEN_BACKLOG:
continue
created = _parse_ts(item.get("created_at"))
stale = created is not None and created < cutoff
backlog_items.append(
{
"id": str(item["id"]),
"title": item.get("title") or "Technische Schuld",
"status": item.get("status"),
"stale": stale,
"created_at": item.get("created_at"),
}
)
actions: list[dict[str, Any]] = []
for action in ctx.actions:
if action.get("action_kind") != "tech_debt":
continue
if action.get("status") not in _OPEN_ACTION:
continue
created = _parse_ts(action.get("created_at"))
stale = created is not None and created < cutoff
actions.append(
{
"id": str(action["id"]),
"title": action.get("title") or "Technische Schuld",
"status": action.get("status"),
"stale": stale,
"created_at": action.get("created_at"),
}
)
stale_count = sum(1 for row in backlog_items if row["stale"]) + sum(
1 for row in actions if row["stale"]
)
return {
"enabled": True,
"backlog_items": backlog_items,
"actions": actions,
"total_open": len(backlog_items) + len(actions),
"stale_count": stale_count,
"stale_days": stale_days,
}

View File

@ -0,0 +1,138 @@
"""Unit tests for agent slot providers."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from steering.agent_slots.guardrail_review import resolve_guardrail_review_slots
from steering.agent_slots.recurring_due import resolve_recurring_due_slots
from steering.agent_slots.review_due import resolve_review_due_slots
from steering.agent_slots.registry import compute_agent_slots
from steering.kernel.models import HorizonMarker
def _ctx(**kwargs):
defaults = {
"initiative_id": "init-1",
"horizon": HorizonMarker(kind="none"),
"operating_context": {
"data_slices": ["reviews", "recurring", "roadmap"],
"steering_elements": ["gate_fulfillment", "recurring_rhythm"],
"agent_slot_config": {},
},
"_reviews": [],
"_recurring_elements": [],
"_actions": [],
}
defaults.update(kwargs)
class FakeCtx:
initiative_id = defaults["initiative_id"]
horizon = defaults["horizon"]
operating_context = defaults["operating_context"]
@property
def steering_elements(self):
return frozenset(self.operating_context.get("steering_elements") or [])
@property
def data_slices(self):
return list(self.operating_context.get("data_slices") or [])
@property
def agent_slot_config(self):
return dict(self.operating_context.get("agent_slot_config") or {})
@property
def reviews(self):
return defaults["_reviews"]
@property
def recurring_elements(self):
return defaults["_recurring_elements"]
@property
def actions(self):
return defaults["_actions"]
return FakeCtx()
def test_review_due_slot_for_overdue_planned_review():
past = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
ctx = _ctx(
_reviews=[
{
"id": "rev-1",
"title": "Architektur-Review",
"status": "planned",
"due_at": past,
}
]
)
slots = resolve_review_due_slots(ctx)
assert len(slots) == 1
assert slots[0]["slot_key"] == "review.due"
assert slots[0]["scope_id"] == "rev-1"
assert slots[0]["actor_role_hint"] == "reviewer"
def test_recurring_due_slot():
past = (datetime.now(timezone.utc) - timedelta(hours=2)).isoformat()
ctx = _ctx(
_recurring_elements=[
{
"id": "rec-1",
"title": "Sprint-Retro",
"status": "active",
"next_due_at": past,
}
]
)
slots = resolve_recurring_due_slots(ctx)
assert len(slots) == 1
assert slots[0]["slot_key"] == "recurring.due"
def test_guardrail_slot_when_gate_has_planning_debt():
ctx = _ctx(
horizon=HorizonMarker(
kind="gate",
gate_roadmap_item_id="gate-1",
gate_title="G3 Architektur",
),
_reviews=[],
)
read_models = {
"planning_debt": [{"roadmap_item_id": "gate-1", "title": "G3"}],
}
slots = resolve_guardrail_review_slots(ctx, read_models)
assert len(slots) == 1
assert slots[0]["slot_key"] == "review.guardrail"
assert slots[0]["guardrail_pack"] == "default_v0"
def test_compute_agent_slots_respects_enabled_slots_filter():
past = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
ctx = _ctx(
operating_context={
"data_slices": ["reviews", "recurring", "roadmap"],
"steering_elements": ["gate_fulfillment", "recurring_rhythm"],
"agent_slot_config": {"enabled_slots": ["review.due"]},
},
_reviews=[
{"id": "rev-1", "title": "Review", "status": "planned", "due_at": past}
],
_recurring_elements=[
{
"id": "rec-1",
"title": "Retro",
"status": "active",
"next_due_at": past,
}
],
)
slots = compute_agent_slots(ctx, read_models={}, proposals={})
assert len(slots) == 1
assert slots[0]["slot_key"] == "review.due"

View File

@ -0,0 +1,81 @@
"""Unit tests for tech debt read model."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from steering.read_models.tech_debt import compute_tech_debt_summary
def _ctx(*, backlog_items, actions, stale_days=30):
class FakeCtx:
initiative_id = "init-1"
operating_context = {
"backlog_vocabulary": {
"kinds": ["story", "bug", "tech_debt"],
},
"agent_slot_config": {"stale_debt_days": stale_days},
}
@property
def backlog_vocabulary(self):
return self.operating_context["backlog_vocabulary"]
@property
def agent_slot_config(self):
return self.operating_context["agent_slot_config"]
@property
def backlog_items(self):
return backlog_items
@property
def actions(self):
return actions
return FakeCtx()
def test_tech_debt_summary_counts_open_and_stale():
old = (datetime.now(timezone.utc) - timedelta(days=45)).isoformat()
recent = datetime.now(timezone.utc).isoformat()
summary = compute_tech_debt_summary(
_ctx(
backlog_items=[
{
"id": "b1",
"title": "Alt",
"item_kind": "tech_debt",
"status": "new",
"created_at": old,
},
{
"id": "b2",
"title": "Neu",
"item_kind": "tech_debt",
"status": "triaged",
"created_at": recent,
},
],
actions=[
{
"id": "a1",
"title": "AP Schuld",
"action_kind": "tech_debt",
"status": "open",
"created_at": old,
}
],
)
)
assert summary["enabled"] is True
assert summary["total_open"] == 3
assert summary["stale_count"] == 2
def test_tech_debt_disabled_when_not_in_vocabulary():
ctx = _ctx(backlog_items=[], actions=[])
ctx.operating_context["backlog_vocabulary"] = {"kinds": ["story", "bug"]}
summary = compute_tech_debt_summary(ctx)
assert summary["enabled"] is False
assert summary["total_open"] == 0

View File

@ -98,7 +98,17 @@ Jeder Proposal-Eintrag liefert mindestens:
--- ---
## 7. KI / Agent (eingefroren bis Principle Gate) ## 7. Agent-Slots (K-Ext-4)
**SK-17:** Agent-Slots = deklarierte Aufgabenfenster — **keine** Prompts, **keine** Auto-Ausführung.
**SK-18:** Neue Slot-Art = `AgentSlotProvider` in `steering/agent_slots/registry.py`; Aktivierung via `agent_slot_config.enabled_slots` im Operating Context.
**SK-19:** Slot-DTO mindestens: `slot_key`, `scope_type`, `scope_id`, `actor_role_hint`, `context_refs`, `payload_keys`, `priority`.
---
## 8. KI / Agent (eingefroren bis Principle Gate)
**SK-13:** Keine Prompts hardcodieren. Agent-Ranker implementieren als `ranker_key: agent_v1` + Actor-Slot — Kontext aus Snapshot/Operating Context. **SK-13:** Keine Prompts hardcodieren. Agent-Ranker implementieren als `ranker_key: agent_v1` + Actor-Slot — Kontext aus Snapshot/Operating Context.
@ -106,7 +116,7 @@ Jeder Proposal-Eintrag liefert mindestens:
--- ---
## 8. Tests & Abnahme ## 9. Tests & Abnahme
**SK-15:** Jeder neue Provider: Unit-Test (reine Logik) + Integration über Snapshot wenn DB verfügbar. **SK-15:** Jeder neue Provider: Unit-Test (reine Logik) + Integration über Snapshot wenn DB verfügbar.
@ -114,7 +124,7 @@ Jeder Proposal-Eintrag liefert mindestens:
--- ---
## 9. Verboten (Anti-Patterns) ## 10. Verboten (Anti-Patterns)
- Bug-zuerst / Story-zuerst als globale Sortierregel ohne `ranker_key`-Kennzeichnung - Bug-zuerst / Story-zuerst als globale Sortierregel ohne `ranker_key`-Kennzeichnung
- Proposal-Logik in `BacklogSection` / `PlanSprintPage` - Proposal-Logik in `BacklogSection` / `PlanSprintPage`
@ -124,7 +134,7 @@ Jeder Proposal-Eintrag liefert mindestens:
--- ---
## 10. Referenzen ## 11. Referenzen
| Dokument | Pfad | | Dokument | Pfad |
|----------|------| |----------|------|

View File

@ -230,7 +230,7 @@ class AgentSlotProvider:
| **K-Ext-1** | `read_models/registry.py` + Kernel ruft Provider; P4 `epic_rollup` migriert | ✓ Kernel v0.3 | | **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 | ✓ AP2.2j | | **K-Ext-2** | `attention/contributors/registry.py`; inline-Attention refactoren | ✓ AP2.2j |
| **K-Ext-3** | `proposals/registry.py` + `SteeringEvaluation.proposals` | ✓ AP2.2i (P6) | | **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 |
**Reihenfolge-Empfehlung:** K-Ext-1 → K-Ext-2 (technische Schuld aus P4 abbauen) → K-Ext-3 (P6) → P5 Tasks parallel möglich → K-Ext-4 (P8). **Reihenfolge-Empfehlung:** K-Ext-1 → K-Ext-2 (technische Schuld aus P4 abbauen) → K-Ext-3 (P6) → P5 Tasks parallel möglich → K-Ext-4 (P8).

View File

@ -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 | P1P4 ✓ · P6 Sprint-Vorschlag ✓ · K-Ext-1/2/3 ✓ · Agent-Slots offen | | ADP Backlog/Epic | P1P4 ✓ · P5 Tasks ✓ · P6 Sprint-Vorschlag ✓ · P7 Tech Debt ✓ · P8 Agent-Slots ✓ · K-Ext-1/2/3/4 ✓ |
| Steering Kernel Proposals | ◐ | sprint_commit · gate_next_actions · intake_triage (Kernel v0.3) | | Steering Kernel Proposals | ◐ | sprint_commit · gate_next_actions · intake_triage (Kernel v0.3) |
| AP2.2be | Referenz-Archetypen End-to-End ◐→✓ | | AP2.2be | Referenz-Archetypen End-to-End ◐→✓ |
| AP2.1 | MVP-Abnahfe ✗→✓ | | AP2.1 | MVP-Abnahfe ✗→✓ |

View File

@ -0,0 +1,47 @@
import { actorRoleHintLabel, agentSlotLabel } from '../utils/agentSlots.js'
export function AgentSlotsPanel({ slots = [], loading = false }) {
if (loading) {
return (
<section className="card agent-slots-panel">
<p className="muted">Agent-Slots werden geladen </p>
</section>
)
}
if (!slots.length) {
return null
}
return (
<section className="card agent-slots-panel">
<div className="section-header">
<div>
<h2>Agent-Slots</h2>
<p className="section-lead muted">
Deklarierte Aufgabenfenster für Actors Ausführung über Review/Rhythmus-UI oder
auditierten Agent (nach Principle Gate). Keine Auto-Ausführung.
</p>
</div>
</div>
<ul className="agent-slots-list">
{slots.map((slot) => (
<li key={`${slot.slot_key}-${slot.scope_id}`} className="agent-slots-list__item">
<div className="agent-slots-list__main">
<strong>{slot.title || agentSlotLabel(slot.slot_key)}</strong>
<span className="badge badge-muted">{agentSlotLabel(slot.slot_key)}</span>
{slot.priority === 'high' && (
<span className="badge badge-warning">Priorität hoch</span>
)}
</div>
<p className="muted">{slot.summary}</p>
<p className="agent-slots-list__meta muted">
Rolle: {actorRoleHintLabel(slot.actor_role_hint)}
{slot.guardrail_pack ? ` · Pack: ${slot.guardrail_pack}` : ''}
</p>
</li>
))}
</ul>
</section>
)
}

View File

@ -1,73 +0,0 @@
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 (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>
<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>
{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 && (
<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 || proposal.dependency_blocked}
onClick={() =>
onAcceptProposal?.(proposal.scope_id, {
work_cycle_id: proposal.work_cycle_id,
})
}
>
In Sprint planen
</button>
)}
</div>
</li>
))}
</ol>
</section>
)
}

View File

@ -92,4 +92,5 @@ export const ATTENTION_KIND_LABELS = {
review_due: 'Review fällig', review_due: 'Review fällig',
recurring_due: 'Wiederkehrend fällig', recurring_due: 'Wiederkehrend fällig',
action_review_required: 'Review ausstehend', action_review_required: 'Review ausstehend',
architecture_debt_stale: 'Technische Schuld veraltet',
} }

View File

@ -2,6 +2,7 @@ import { Link } from 'react-router-dom'
import { scopedPath } from '../../utils/routes.js' import { scopedPath } from '../../utils/routes.js'
import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx' import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx'
import { SteeringSnapshotPanel } from '../../components/SteeringSnapshotPanel.jsx' import { SteeringSnapshotPanel } from '../../components/SteeringSnapshotPanel.jsx'
import { AgentSlotsPanel } from '../../components/AgentSlotsPanel.jsx'
import { CriticalPathPanel } from '../../components/CriticalPathPanel.jsx' import { CriticalPathPanel } from '../../components/CriticalPathPanel.jsx'
import { NextActionWidget } from '../../widgets/NextActionWidget.jsx' import { NextActionWidget } from '../../widgets/NextActionWidget.jsx'
import { import {
@ -60,6 +61,17 @@ export function InitiativeOverviewPage() {
/> />
)} )}
{capabilities.has('kairo.initiative.read') && (
<AgentSlotsPanel
slots={
steeringSnapshot?.agent_slots ||
steeringSnapshot?.steering_kernel?.agent_slots ||
[]
}
loading={steeringSnapshotLoading}
/>
)}
{(counts.actions_blocked > 0 || counts.blockers_open > 0) && ( {(counts.actions_blocked > 0 || counts.blockers_open > 0) && (
<section className="card initiative-summary-strip"> <section className="card initiative-summary-strip">
<h2 className="card-title">Roadblocker</h2> <h2 className="card-title">Roadblocker</h2>

View File

@ -1725,6 +1725,36 @@
margin-top: 1rem; margin-top: 1rem;
} }
.agent-slots-panel {
margin-top: 1rem;
}
.agent-slots-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.agent-slots-list__item {
padding: 0.75rem 0;
border-bottom: 1px solid var(--jk-border-subtle, #e8e8e8);
}
.agent-slots-list__main {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
}
.agent-slots-list__meta {
font-size: 0.875rem;
margin: 0.25rem 0 0;
}
.steering-proposals__list { .steering-proposals__list {
list-style: none; list-style: none;
padding: 0; padding: 0;

View File

@ -0,0 +1,19 @@
const SLOT_LABELS = {
'review.due': 'Review fällig',
'recurring.due': 'Rhythmus fällig',
'review.guardrail': 'Guardrail-Review',
}
const ROLE_LABELS = {
reviewer: 'Reviewer',
operator: 'Operator',
architecture_reviewer: 'Architektur-Reviewer',
}
export function agentSlotLabel(slotKey) {
return SLOT_LABELS[slotKey] || slotKey
}
export function actorRoleHintLabel(roleHint) {
return ROLE_LABELS[roleHint] || roleHint
}