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
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:
parent
b747200f3a
commit
a5154cbd3b
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
11
backend/migrations/029_tech_debt_kinds.sql
Normal file
11
backend/migrations/029_tech_debt_kinds.sql
Normal 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'));
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
1
backend/steering/agent_slots/__init__.py
Normal file
1
backend/steering/agent_slots/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Agent slot providers — K-Ext-4 / P8."""
|
||||
54
backend/steering/agent_slots/guardrail_review.py
Normal file
54
backend/steering/agent_slots/guardrail_review.py
Normal 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,
|
||||
}
|
||||
]
|
||||
48
backend/steering/agent_slots/recurring_due.py
Normal file
48
backend/steering/agent_slots/recurring_due.py
Normal 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
|
||||
107
backend/steering/agent_slots/registry.py
Normal file
107
backend/steering/agent_slots/registry.py
Normal 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()
|
||||
54
backend/steering/agent_slots/review_due.py
Normal file
54
backend/steering/agent_slots/review_due.py
Normal 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
|
||||
148
backend/steering/attention/contributors/reviews.py
Normal file
148
backend/steering/attention/contributors/reviews.py
Normal 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)
|
||||
)
|
||||
|
|
@ -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"],
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
88
backend/steering/read_models/tech_debt.py
Normal file
88
backend/steering/read_models/tech_debt.py
Normal 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,
|
||||
}
|
||||
138
backend/tests/test_agent_slots_unit.py
Normal file
138
backend/tests/test_agent_slots_unit.py
Normal 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"
|
||||
81
backend/tests/test_tech_debt_unit.py
Normal file
81
backend/tests/test_tech_debt_unit.py
Normal 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
|
||||
|
|
@ -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 |
|
||||
|----------|------|
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 ✗→✓ |
|
||||
|
|
|
|||
47
frontend/src/components/AgentSlotsPanel.jsx
Normal file
47
frontend/src/components/AgentSlotsPanel.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') && (
|
||||
<AgentSlotsPanel
|
||||
slots={
|
||||
steeringSnapshot?.agent_slots ||
|
||||
steeringSnapshot?.steering_kernel?.agent_slots ||
|
||||
[]
|
||||
}
|
||||
loading={steeringSnapshotLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(counts.actions_blocked > 0 || counts.blockers_open > 0) && (
|
||||
<section className="card initiative-summary-strip">
|
||||
<h2 className="card-title">Roadblocker</h2>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
19
frontend/src/utils/agentSlots.js
Normal file
19
frontend/src/utils/agentSlots.js
Normal 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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user