From a5154cbd3b96f95b3c3843873e232a5cf73ff5e5 Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 27 Jul 2026 11:54:48 +0200 Subject: [PATCH] feat(steering): Kernel v0.4 Agent-Slots, Tech Debt und Review-Attention K-Ext-4/P8 deklarative Agent-Slots; P7 tech_debt Read Model und Vokabular; Review-Attention in den Kernel verlagert. Co-authored-by: Cursor --- .cursor/rules/kairo-steering-kernel.mdc | 15 +- backend/data_layer/initiative_snapshot.py | 3 + backend/migrations/029_tech_debt_kinds.sql | 11 ++ backend/routers/backlog.py | 4 +- backend/services/actions.py | 8 +- backend/services/backlog.py | 8 +- backend/services/operating_context.py | 15 ++ backend/steering/agent_slots/__init__.py | 1 + .../steering/agent_slots/guardrail_review.py | 54 +++++++ backend/steering/agent_slots/recurring_due.py | 48 ++++++ backend/steering/agent_slots/registry.py | 107 +++++++++++++ backend/steering/agent_slots/review_due.py | 54 +++++++ .../attention/contributors/reviews.py | 148 ++++++++++++++++++ backend/steering/backlog_vocabulary.py | 15 +- backend/steering/eval_context.py | 28 ++++ backend/steering/kernel/attention.py | 1 + backend/steering/kernel/evaluate.py | 8 +- backend/steering/kernel/models.py | 4 +- backend/steering/read_models/registry.py | 9 ++ backend/steering/read_models/tech_debt.py | 88 +++++++++++ backend/tests/test_agent_slots_unit.py | 138 ++++++++++++++++ backend/tests/test_tech_debt_unit.py | 81 ++++++++++ .../ADP_Steering_Kernel_Coding_Rules_v0.1.md | 18 ++- ...DP_Steering_Kernel_Extension_Model_v0.1.md | 2 +- .../Kairo_Implementation_Truth_Table_v0.1.md | 2 +- frontend/src/components/AgentSlotsPanel.jsx | 47 ++++++ .../components/SprintCommitProposalPanel.jsx | 73 --------- frontend/src/constants/operating.js | 1 + .../initiative/InitiativeOverviewPage.jsx | 12 ++ frontend/src/styles/components.css | 30 ++++ frontend/src/utils/agentSlots.js | 19 +++ 31 files changed, 954 insertions(+), 98 deletions(-) create mode 100644 backend/migrations/029_tech_debt_kinds.sql create mode 100644 backend/steering/agent_slots/__init__.py create mode 100644 backend/steering/agent_slots/guardrail_review.py create mode 100644 backend/steering/agent_slots/recurring_due.py create mode 100644 backend/steering/agent_slots/registry.py create mode 100644 backend/steering/agent_slots/review_due.py create mode 100644 backend/steering/attention/contributors/reviews.py create mode 100644 backend/steering/read_models/tech_debt.py create mode 100644 backend/tests/test_agent_slots_unit.py create mode 100644 backend/tests/test_tech_debt_unit.py create mode 100644 frontend/src/components/AgentSlotsPanel.jsx delete mode 100644 frontend/src/components/SprintCommitProposalPanel.jsx create mode 100644 frontend/src/utils/agentSlots.js diff --git a/.cursor/rules/kairo-steering-kernel.mdc b/.cursor/rules/kairo-steering-kernel.mdc index 2234f0f..7cc3dff 100644 --- a/.cursor/rules/kairo-steering-kernel.mdc +++ b/.cursor/rules/kairo-steering-kernel.mdc @@ -1,6 +1,6 @@ --- -description: Steering Kernel v0.3 — verbindliche Regeln für Read Models, Proposals, Attention, 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 +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/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 --- @@ -12,7 +12,7 @@ alwaysApply: true ## Herzmaschine - 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 @@ -21,6 +21,7 @@ alwaysApply: true | Read Model | `steering/read_models/registry.py` | | Proposal | `steering/proposals/registry.py` | | Attention | `steering/attention/contributors/*.py` + Registry | +| Agent-Slot | `steering/agent_slots/registry.py` | | 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. @@ -32,6 +33,12 @@ Aktivierung über `steering_elements` / `data_slices` im Methoden-Vertrag — ** - Abhängigkeiten: `dependency_refs`, `dependency_blocked` - 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 - `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` | | `epic_rollup` | `backlog_epic_hierarchy` | | `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 diff --git a/backend/data_layer/initiative_snapshot.py b/backend/data_layer/initiative_snapshot.py index 415cd7e..6c4760b 100644 --- a/backend/data_layer/initiative_snapshot.py +++ b/backend/data_layer/initiative_snapshot.py @@ -355,6 +355,7 @@ def get_initiative_steering_snapshot( "data_source": evaluation.data_source, "read_models": evaluation.read_models, "proposals": evaluation.proposals, + "agent_slots": evaluation.agent_slots, }, "upcoming_milestones": upcoming_milestones, "upcoming_roadmap_items": upcoming_milestones, @@ -364,6 +365,8 @@ def get_initiative_steering_snapshot( "sprint_commit_proposals": sprint_commit_proposals, "gate_next_actions_proposals": evaluation.proposals.get("gate_next_actions") 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": { "actions_open": open_actions, "actions_blocked": blocked_actions, diff --git a/backend/migrations/029_tech_debt_kinds.sql b/backend/migrations/029_tech_debt_kinds.sql new file mode 100644 index 0000000..da90c6a --- /dev/null +++ b/backend/migrations/029_tech_debt_kinds.sql @@ -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')); diff --git a/backend/routers/backlog.py b/backend/routers/backlog.py index 28d018e..8abc0e6 100644 --- a/backend/routers/backlog.py +++ b/backend/routers/backlog.py @@ -20,7 +20,7 @@ class BacklogCreateRequest(BaseModel): priority: Literal["low", "normal", "high"] = "normal" roadmap_item_id: Optional[str] = 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_backlog_id: Optional[str] = None @@ -33,7 +33,7 @@ class BacklogUpdateRequest(BaseModel): roadmap_item_id: Optional[str] = None clear_roadmap_item: bool = False 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 clear_parent_action: bool = False parent_backlog_id: Optional[str] = None diff --git a/backend/services/actions.py b/backend/services/actions.py index 5372e37..fcf2c6f 100644 --- a/backend/services/actions.py +++ b/backend/services/actions.py @@ -29,7 +29,7 @@ _ACTION_COLUMNS = """ 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"}) CARRYOVER_UNPLAN_STATUSES = frozenset( {"open", "ready", "in_progress", "blocked", "review_required"} @@ -83,8 +83,8 @@ def _validate_parent_action_in_initiative( if not row: raise ValueError("Referenz-Arbeitspaket (Feature) nicht gefunden") action_kind = row["action_kind"] if isinstance(row, dict) else row[0] - if action_kind in ("bug", "issue"): - raise ValueError("Referenz muss ein Feature-Arbeitspaket sein, kein Bug/Issue") + if action_kind in ("bug", "issue", "tech_debt"): + raise ValueError("Referenz muss ein Feature-Arbeitspaket sein, kein Bug/Issue/Schuld") 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" if action_kind == "issue": return "issue" + if action_kind == "tech_debt": + return "tech_debt" return "story" diff --git a/backend/services/backlog.py b/backend/services/backlog.py index 95f014b..b8831c1 100644 --- a/backend/services/backlog.py +++ b/backend/services/backlog.py @@ -14,11 +14,11 @@ from services.operating_context import get_operating_context from services.plan_ist import validate_roadmap_item_in_initiative 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_ITEM_KINDS = frozenset({"story", "bug", "issue", "epic"}) -CONVERTIBLE_ITEM_KINDS = frozenset({"story", "bug", "issue"}) +BACKLOG_ITEM_KINDS = frozenset({"story", "bug", "issue", "epic", "tech_debt"}) +CONVERTIBLE_ITEM_KINDS = frozenset({"story", "bug", "issue", "tech_debt"}) _BACKLOG_COLUMNS = """ id, tenant_id, initiative_id, title, description, status, @@ -89,6 +89,8 @@ def _item_kind_to_action_kind(item_kind: str) -> str: return "bug" if item_kind == "issue": return "issue" + if item_kind == "tech_debt": + return "tech_debt" return "delivery" diff --git a/backend/services/operating_context.py b/backend/services/operating_context.py index 63494d6..f5b3833 100644 --- a/backend/services/operating_context.py +++ b/backend/services/operating_context.py @@ -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( *, tenant_id: str, initiative_id: str ) -> Optional[dict[str, Any]]: @@ -190,6 +203,7 @@ def get_operating_context( method_key=method_key, steering_elements=effective_steering_elements, ) + agent_slot_config = _resolve_agent_slot_config(metadata) return { "initiative_id": initiative_id, @@ -215,6 +229,7 @@ def get_operating_context( ], "active_composition_modifier": active_composition_modifier, "backlog_vocabulary": backlog_vocabulary, + "agent_slot_config": agent_slot_config, } diff --git a/backend/steering/agent_slots/__init__.py b/backend/steering/agent_slots/__init__.py new file mode 100644 index 0000000..d946a6a --- /dev/null +++ b/backend/steering/agent_slots/__init__.py @@ -0,0 +1 @@ +"""Agent slot providers — K-Ext-4 / P8.""" diff --git a/backend/steering/agent_slots/guardrail_review.py b/backend/steering/agent_slots/guardrail_review.py new file mode 100644 index 0000000..ab05303 --- /dev/null +++ b/backend/steering/agent_slots/guardrail_review.py @@ -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, + } + ] diff --git a/backend/steering/agent_slots/recurring_due.py b/backend/steering/agent_slots/recurring_due.py new file mode 100644 index 0000000..9be045a --- /dev/null +++ b/backend/steering/agent_slots/recurring_due.py @@ -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 diff --git a/backend/steering/agent_slots/registry.py b/backend/steering/agent_slots/registry.py new file mode 100644 index 0000000..f15f0e8 --- /dev/null +++ b/backend/steering/agent_slots/registry.py @@ -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() diff --git a/backend/steering/agent_slots/review_due.py b/backend/steering/agent_slots/review_due.py new file mode 100644 index 0000000..6a64566 --- /dev/null +++ b/backend/steering/agent_slots/review_due.py @@ -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 diff --git a/backend/steering/attention/contributors/reviews.py b/backend/steering/attention/contributors/reviews.py new file mode 100644 index 0000000..8342f0c --- /dev/null +++ b/backend/steering/attention/contributors/reviews.py @@ -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) +) diff --git a/backend/steering/backlog_vocabulary.py b/backend/steering/backlog_vocabulary.py index 567f561..fce5a4e 100644 --- a/backend/steering/backlog_vocabulary.py +++ b/backend/steering/backlog_vocabulary.py @@ -19,42 +19,45 @@ _EMPTY: dict[str, Any] = { _AGILE_INTAKE: dict[str, Any] = { "profile_key": "agile_intake", - "kinds": ["epic", "story", "bug", "issue"], + "kinds": ["epic", "story", "bug", "issue", "tech_debt"], "labels": { "epic": "Epic", "story": "Story", "bug": "Bug", "issue": "Issue", + "tech_debt": "Technische Schuld", }, "default_kind": "story", "epic_hierarchy": True, - "convertible_kinds": ["story", "bug", "issue"], + "convertible_kinds": ["story", "bug", "issue", "tech_debt"], } _CONTINUOUS_INTAKE: dict[str, Any] = { "profile_key": "continuous_intake", - "kinds": ["story", "bug", "issue"], + "kinds": ["story", "bug", "issue", "tech_debt"], "labels": { "story": "Idee", "bug": "Bug", "issue": "Störung", + "tech_debt": "Technische Schuld", }, "default_kind": "story", "epic_hierarchy": False, - "convertible_kinds": ["story", "bug", "issue"], + "convertible_kinds": ["story", "bug", "issue", "tech_debt"], } _STANDARD_INTAKE: dict[str, Any] = { "profile_key": "standard_intake", - "kinds": ["story", "bug", "issue"], + "kinds": ["story", "bug", "issue", "tech_debt"], "labels": { "story": "Story", "bug": "Bug", "issue": "Issue", + "tech_debt": "Technische Schuld", }, "default_kind": "story", "epic_hierarchy": False, - "convertible_kinds": ["story", "bug", "issue"], + "convertible_kinds": ["story", "bug", "issue", "tech_debt"], } diff --git a/backend/steering/eval_context.py b/backend/steering/eval_context.py index 67bb5ac..bbaaed2 100644 --- a/backend/steering/eval_context.py +++ b/backend/steering/eval_context.py @@ -23,6 +23,8 @@ class SteeringEvalContext: _actions: list[dict[str, Any]] | None = field(default=None, repr=False) _backlog_items: list[dict[str, Any]] | None = field(default=None, repr=False) _work_cycles: list[dict[str, Any]] | None = field(default=None, repr=False) + _reviews: list[dict[str, Any]] | None = field(default=None, repr=False) + _recurring_elements: list[dict[str, Any]] | None = field(default=None, repr=False) @property def steering_elements(self) -> frozenset[str]: @@ -36,6 +38,10 @@ class SteeringEvalContext: def backlog_vocabulary(self) -> dict[str, Any]: 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 def actions(self) -> list[dict[str, Any]]: if self._actions is None: @@ -69,6 +75,28 @@ class SteeringEvalContext: ) 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( tenant_ctx: TenantContext, diff --git a/backend/steering/kernel/attention.py b/backend/steering/kernel/attention.py index 4b52e89..8d6b7b7 100644 --- a/backend/steering/kernel/attention.py +++ b/backend/steering/kernel/attention.py @@ -11,6 +11,7 @@ from tenant_context import TenantContext import steering.attention.contributors.core # noqa: F401 import steering.attention.contributors.gates # noqa: F401 import steering.attention.contributors.read_models # noqa: F401 +import steering.attention.contributors.reviews # noqa: F401 def evaluate_attention( diff --git a/backend/steering/kernel/evaluate.py b/backend/steering/kernel/evaluate.py index e3125ed..7e35cf4 100644 --- a/backend/steering/kernel/evaluate.py +++ b/backend/steering/kernel/evaluate.py @@ -1,4 +1,4 @@ -"""Steering Kernel Spine — single entry point v0.3.""" +"""Steering Kernel Spine — single entry point v0.4.""" 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.models import SteeringEvaluation 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.read_models.registry import compute_read_models from tenant_context import TenantContext @@ -73,6 +74,10 @@ def evaluate_steering( limit=attention_limit, ) + agent_slots = compute_agent_slots( + eval_ctx, read_models=read_models, proposals=proposals + ) + return SteeringEvaluation( initiative_id=initiative_id, binding=binding, @@ -82,4 +87,5 @@ def evaluate_steering( attention=attention, read_models=read_models, proposals=proposals, + agent_slots=agent_slots, ) diff --git a/backend/steering/kernel/models.py b/backend/steering/kernel/models.py index f34d7ac..8b32299 100644 --- a/backend/steering/kernel/models.py +++ b/backend/steering/kernel/models.py @@ -67,7 +67,8 @@ class SteeringEvaluation: attention: list[dict[str, Any]] = field(default_factory=list) read_models: 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]: return { @@ -81,5 +82,6 @@ class SteeringEvaluation: "attention": self.attention, "read_models": self.read_models, "proposals": self.proposals, + "agent_slots": self.agent_slots, "data_source": self.data_source, } diff --git a/backend/steering/read_models/registry.py b/backend/steering/read_models/registry.py index c39b3f5..79f8909 100644 --- a/backend/steering/read_models/registry.py +++ b/backend/steering/read_models/registry.py @@ -50,6 +50,7 @@ def _register_builtin_read_models() -> None: 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.planning_debt import compute_planning_debt_read_model + from steering.read_models.tech_debt import compute_tech_debt_summary register_read_model( ReadModelProvider( @@ -79,6 +80,14 @@ def _register_builtin_read_models() -> None: 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() diff --git a/backend/steering/read_models/tech_debt.py b/backend/steering/read_models/tech_debt.py new file mode 100644 index 0000000..41acae4 --- /dev/null +++ b/backend/steering/read_models/tech_debt.py @@ -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, + } diff --git a/backend/tests/test_agent_slots_unit.py b/backend/tests/test_agent_slots_unit.py new file mode 100644 index 0000000..754d50b --- /dev/null +++ b/backend/tests/test_agent_slots_unit.py @@ -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" diff --git a/backend/tests/test_tech_debt_unit.py b/backend/tests/test_tech_debt_unit.py new file mode 100644 index 0000000..01f241c --- /dev/null +++ b/backend/tests/test_tech_debt_unit.py @@ -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 diff --git a/docs/architecture/ADP_Steering_Kernel_Coding_Rules_v0.1.md b/docs/architecture/ADP_Steering_Kernel_Coding_Rules_v0.1.md index be2a20d..77590df 100644 --- a/docs/architecture/ADP_Steering_Kernel_Coding_Rules_v0.1.md +++ b/docs/architecture/ADP_Steering_Kernel_Coding_Rules_v0.1.md @@ -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. @@ -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. @@ -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 - Proposal-Logik in `BacklogSection` / `PlanSprintPage` @@ -124,7 +134,7 @@ Jeder Proposal-Eintrag liefert mindestens: --- -## 10. Referenzen +## 11. Referenzen | Dokument | Pfad | |----------|------| 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 6a866e7..026e4eb 100644 --- a/docs/architecture/ADP_Steering_Kernel_Extension_Model_v0.1.md +++ b/docs/architecture/ADP_Steering_Kernel_Extension_Model_v0.1.md @@ -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-2** | `attention/contributors/registry.py`; inline-Attention refactoren | ✓ AP2.2j | | **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 | **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). diff --git a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md index f4aad2f..9009a23 100644 --- a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md +++ b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md @@ -228,7 +228,7 @@ Details: `Kairo_Status_Review_and_Next_Steps_v0.1.md` §2.1 |----|-----------| | AP2.3/4 | ✓ Plugin-Architektur + Element-Registry (2026-07-25) | | AP2.2a | Starter-Kits ◐→✓ | -| ADP Backlog/Epic | P1–P4 ✓ · P6 Sprint-Vorschlag ✓ · K-Ext-1/2/3 ✓ · Agent-Slots offen | +| ADP Backlog/Epic | P1–P4 ✓ · 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) | | AP2.2b–e | Referenz-Archetypen End-to-End ◐→✓ | | AP2.1 | MVP-Abnahfe ✗→✓ | diff --git a/frontend/src/components/AgentSlotsPanel.jsx b/frontend/src/components/AgentSlotsPanel.jsx new file mode 100644 index 0000000..cb6acc1 --- /dev/null +++ b/frontend/src/components/AgentSlotsPanel.jsx @@ -0,0 +1,47 @@ +import { actorRoleHintLabel, agentSlotLabel } from '../utils/agentSlots.js' + +export function AgentSlotsPanel({ slots = [], loading = false }) { + if (loading) { + return ( +
+

Agent-Slots werden geladen …

+
+ ) + } + + if (!slots.length) { + return null + } + + return ( +
+
+
+

Agent-Slots

+

+ Deklarierte Aufgabenfenster für Actors — Ausführung über Review/Rhythmus-UI oder + auditierten Agent (nach Principle Gate). Keine Auto-Ausführung. +

+
+
+
    + {slots.map((slot) => ( +
  • +
    + {slot.title || agentSlotLabel(slot.slot_key)} + {agentSlotLabel(slot.slot_key)} + {slot.priority === 'high' && ( + Priorität hoch + )} +
    +

    {slot.summary}

    +

    + Rolle: {actorRoleHintLabel(slot.actor_role_hint)} + {slot.guardrail_pack ? ` · Pack: ${slot.guardrail_pack}` : ''} +

    +
  • + ))} +
+
+ ) +} diff --git a/frontend/src/components/SprintCommitProposalPanel.jsx b/frontend/src/components/SprintCommitProposalPanel.jsx deleted file mode 100644 index 83a4460..0000000 --- a/frontend/src/components/SprintCommitProposalPanel.jsx +++ /dev/null @@ -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 ( -
-
-
-

Sprint-Vorschläge

-

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

-
-
-
    - {filtered.map((proposal) => ( -
  1. -
    - - {proposal.rank}. {proposal.title} - -

    {proposalReasonLabel(proposal)}

    - {proposal.dependency_blocked && ( -

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

    - )} -
    -
    - {proposal.item_kind && ( - - {backlogKindLabel(vocabulary, proposal.item_kind)} - - )} - - {canManage && ( - - )} -
    -
  2. - ))} -
-
- ) -} diff --git a/frontend/src/constants/operating.js b/frontend/src/constants/operating.js index c3ded6d..669bee4 100644 --- a/frontend/src/constants/operating.js +++ b/frontend/src/constants/operating.js @@ -92,4 +92,5 @@ export const ATTENTION_KIND_LABELS = { review_due: 'Review fällig', recurring_due: 'Wiederkehrend fällig', action_review_required: 'Review ausstehend', + architecture_debt_stale: 'Technische Schuld veraltet', } diff --git a/frontend/src/pages/initiative/InitiativeOverviewPage.jsx b/frontend/src/pages/initiative/InitiativeOverviewPage.jsx index 181e901..88d4f0a 100644 --- a/frontend/src/pages/initiative/InitiativeOverviewPage.jsx +++ b/frontend/src/pages/initiative/InitiativeOverviewPage.jsx @@ -2,6 +2,7 @@ import { Link } from 'react-router-dom' import { scopedPath } from '../../utils/routes.js' import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx' import { SteeringSnapshotPanel } from '../../components/SteeringSnapshotPanel.jsx' +import { AgentSlotsPanel } from '../../components/AgentSlotsPanel.jsx' import { CriticalPathPanel } from '../../components/CriticalPathPanel.jsx' import { NextActionWidget } from '../../widgets/NextActionWidget.jsx' import { @@ -60,6 +61,17 @@ export function InitiativeOverviewPage() { /> )} + {capabilities.has('kairo.initiative.read') && ( + + )} + {(counts.actions_blocked > 0 || counts.blockers_open > 0) && (

Roadblocker

diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index 4da0840..b3526b9 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -1725,6 +1725,36 @@ 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 { list-style: none; padding: 0; diff --git a/frontend/src/utils/agentSlots.js b/frontend/src/utils/agentSlots.js new file mode 100644 index 0000000..ef8c1e5 --- /dev/null +++ b/frontend/src/utils/agentSlots.js @@ -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 +}