diff --git a/.cursor/rules/kairo-plugin-architecture.mdc b/.cursor/rules/kairo-plugin-architecture.mdc index 44324a4..211e41c 100644 --- a/.cursor/rules/kairo-plugin-architecture.mdc +++ b/.cursor/rules/kairo-plugin-architecture.mdc @@ -6,7 +6,7 @@ alwaysApply: true # Kairo Plugin-Architektur (AP2.3 / AP2.4) -Verbindliche ADPs: `docs/architecture/ADP_Archetype_Method_Plugin_Architecture_v0.1.md`, `docs/architecture/ADP_AP2_4_Steering_Elements_and_Method_Contract_v0.1.md`, `docs/architecture/ADP_Steering_Kernel_Spine_v0.1.md` +Verbindliche ADPs: `docs/architecture/ADP_Archetype_Method_Plugin_Architecture_v0.1.md`, `docs/architecture/ADP_AP2_4_Steering_Elements_and_Method_Contract_v0.1.md`, `docs/architecture/ADP_Steering_Kernel_Spine_v0.1.md`, **`docs/architecture/ADP_Steering_Kernel_Coding_Rules_v0.1.md`**, **`.cursor/rules/kairo-steering-kernel.mdc`** ## Vier Schichten — Auflösung zur Laufzeit diff --git a/.cursor/rules/kairo-steering-kernel.mdc b/.cursor/rules/kairo-steering-kernel.mdc new file mode 100644 index 0000000..2234f0f --- /dev/null +++ b/.cursor/rules/kairo-steering-kernel.mdc @@ -0,0 +1,62 @@ +--- +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 +alwaysApply: true +--- + +# Kairo Steering Kernel — Coding Rules (verbindlich) + +**Primärdokument:** `docs/architecture/ADP_Steering_Kernel_Coding_Rules_v0.1.md` +**Architektur:** `docs/architecture/ADP_Steering_Kernel_Extension_Model_v0.1.md` + +## Herzmaschine + +- Initiative-Steuerung **nur** via `evaluate_steering()` in `backend/steering/kernel/evaluate.py` +- Snapshot spiegelt `evaluation.read_models` + `evaluation.proposals` — **kein** Duplikat-Rechnen + +## Neue Fähigkeit = Provider registrieren + +| Was | Wo | +|-----|-----| +| Read Model | `steering/read_models/registry.py` | +| Proposal | `steering/proposals/registry.py` | +| Attention | `steering/attention/contributors/*.py` + Registry | +| 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. + +## Proposals + +- **Kein Auto-Commit** — Accept in UI/API +- **Keine feste Bug/Story-Policy** — `ranker_key` + `factors[]`; Heuristik = Fallback (`heuristic_v0`) +- Abhängigkeiten: `dependency_refs`, `dependency_blocked` +- KI: `agent_v1` Ranker + Actor-Slot — **keine** hardcodierten Prompts (Principle Gate) + +## Frontend + +- `SteeringProposalsPanel` + `PROPOSAL_UI_CONFIG` in `utils/steeringProposals.js` +- Daten aus `steeringSnapshot.steering_kernel.proposals` +- Keine neue Proposal-Page pro Methode + +## Methoden-Parität (Ist) + +| Provider | Element-Gate | +|----------|--------------| +| `sprint_commit` | `work_cycle_scope` | +| `gate_next_actions` | `gate_fulfillment` | +| `intake_triage` | `backlog` slice, **excludes** `work_cycle_scope` | +| `epic_rollup` | `backlog_epic_hierarchy` | +| `planning_debt` / `execution_graph` | `gate_fulfillment` / `critical_path` | + +## Verboten + +- Steuerungslogik außerhalb `backend/steering/` +- Archetyp-Ifs für Steuerungs-UI +- `evaluate_next_work` für Commit-Vorschläge (Plan ≠ Ist) +- OM-Tabellen für Vorschläge + +## Tests + +- Unit-Test pro Provider/Ranker +- Snapshot-Integration wenn DB-Test verfügbar +- Truth Table aktualisieren diff --git a/CLAUDE.md b/CLAUDE.md index 0f5d37a..483d7a4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,6 +50,7 @@ Lies bei Projektstart in dieser Reihenfolge: 8. `docs/architecture/Kairo_Tenant_Invariants_v0.1.md` 9. `docs/architecture/Kairo_Sprint0_Principle_Gate_v0.1.md` 10. `.cursor/rules/kairo-architecture.mdc` +11. **`.cursor/rules/kairo-steering-kernel.mdc`** + `docs/architecture/ADP_Steering_Kernel_Coding_Rules_v0.1.md` (Steering Kernel v0.3 — Read Models, Proposals, Ranker) 11. `docs/architecture/ADP_Archetype_Method_Plugin_Architecture_v0.1.md` (AP2.3 — **geliefert**) 12. `docs/architecture/ADP_AP2_4_Steering_Elements_and_Method_Contract_v0.1.md` (AP2.4 — **geliefert**) 13. `.cursor/rules/kairo-plugin-architecture.mdc` diff --git a/backend/data_layer/initiative_snapshot.py b/backend/data_layer/initiative_snapshot.py index ddbb0b3..415cd7e 100644 --- a/backend/data_layer/initiative_snapshot.py +++ b/backend/data_layer/initiative_snapshot.py @@ -362,6 +362,8 @@ def get_initiative_steering_snapshot( "active_work_cycle": active_work_cycle, "epic_rollup": epic_rollup, "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 [], "counts": { "actions_open": open_actions, "actions_blocked": blocked_actions, diff --git a/backend/steering/attention/__init__.py b/backend/steering/attention/__init__.py new file mode 100644 index 0000000..6ff5ba0 --- /dev/null +++ b/backend/steering/attention/__init__.py @@ -0,0 +1 @@ +"""Attention contributors — K-Ext-2.""" diff --git a/backend/steering/attention/context.py b/backend/steering/attention/context.py new file mode 100644 index 0000000..d46077c --- /dev/null +++ b/backend/steering/attention/context.py @@ -0,0 +1,109 @@ +"""Attention evaluation context — shared caches for contributors.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from steering.eval_context import SteeringEvalContext +from steering.kernel.models import HorizonMarker, SteeringBinding +from tenant_context import TenantContext + + +@dataclass +class AttentionEvalContext: + tenant_ctx: TenantContext + initiative_id: str + binding: SteeringBinding + horizon: HorizonMarker + next_work: list[dict[str, Any]] + read_models: dict[str, Any] + proposals: dict[str, Any] + eval_ctx: SteeringEvalContext | None = None + + _actions: list[dict[str, Any]] | None = field(default=None, repr=False) + _blockers: list[dict[str, Any]] | None = field(default=None, repr=False) + _roadmap_items: list[dict[str, Any]] | None = field(default=None, repr=False) + _dependencies: list[dict[str, Any]] | None = field(default=None, repr=False) + _graph_state: dict[str, Any] | None = field(default=None, repr=False) + _gate_scope: str | None = field(default=None, repr=False) + + @property + def steering_elements(self) -> frozenset[str]: + if self.eval_ctx: + return self.eval_ctx.steering_elements + return frozenset() + + @property + def actions(self) -> list[dict[str, Any]]: + if self._actions is None: + if self.eval_ctx: + self._actions = self.eval_ctx.actions + else: + from services import actions as action_service + + self._actions = action_service.list_actions_for_initiative( + tenant_id=self.tenant_ctx.tenant_id, + initiative_id=self.initiative_id, + ) + return self._actions + + @property + def blockers(self) -> list[dict[str, Any]]: + if self._blockers is None: + from services import blockers as blocker_service + + self._blockers = blocker_service.list_blockers_for_initiative( + tenant_id=self.tenant_ctx.tenant_id, initiative_id=self.initiative_id + ) + return self._blockers + + @property + def roadmap_items(self) -> list[dict[str, Any]]: + if self._roadmap_items is None: + from services import roadmap as roadmap_service + + self._roadmap_items = roadmap_service.list_roadmap_items_for_initiative( + tenant_id=self.tenant_ctx.tenant_id, initiative_id=self.initiative_id + ) + return self._roadmap_items + + @property + def gate_scope(self) -> str | None: + if self._gate_scope is None: + from steering.strategies.next_action.execution_ready import ( + gate_horizon_scope_for_method, + ) + + self._gate_scope = gate_horizon_scope_for_method( + self.binding.primary_method_key, + self.tenant_ctx, + self.initiative_id, + ) + return self._gate_scope + + @property + def dependencies(self) -> list[dict[str, Any]]: + if self._dependencies is None: + from services.execution_plan import list_dependencies_for_initiative + + self._dependencies = list_dependencies_for_initiative( + tenant_id=self.tenant_ctx.tenant_id, initiative_id=self.initiative_id + ) + return self._dependencies + + @property + def graph_state(self) -> dict[str, Any]: + if self._graph_state is None: + cached = self.read_models.get("execution_graph") + if cached is not None: + self._graph_state = cached + else: + from steering.graph.execution_engine import compute_execution_graph_state + + self._graph_state = compute_execution_graph_state( + actions=self.actions, + dependencies=self.dependencies, + scope_roadmap_item_id=self.gate_scope, + ) + return self._graph_state diff --git a/backend/steering/attention/contributors/__init__.py b/backend/steering/attention/contributors/__init__.py new file mode 100644 index 0000000..420274c --- /dev/null +++ b/backend/steering/attention/contributors/__init__.py @@ -0,0 +1 @@ +"""Attention contributors package.""" diff --git a/backend/steering/attention/contributors/core.py b/backend/steering/attention/contributors/core.py new file mode 100644 index 0000000..e3a8d7d --- /dev/null +++ b/backend/steering/attention/contributors/core.py @@ -0,0 +1,62 @@ +"""Core attention contributors — blockers, blocked actions.""" + +from __future__ import annotations + +from steering.attention.context import AttentionEvalContext +from steering.attention.contributors.registry import ( + AttentionContributor, + register_attention_contributor, +) + + +def _open_blockers(ctx: AttentionEvalContext) -> list[dict]: + items: list[dict] = [] + for blocker in ctx.blockers: + if blocker.get("status") not in ("open", "in_progress"): + continue + items.append( + { + "kind": "open_blocker", + "severity": "warning", + "title": blocker.get("title") or "Blocker", + "summary": "Offener Blocker im Vorhaben", + "scope_type": "blocker", + "scope_id": str(blocker["id"]), + "initiative_id": ctx.initiative_id, + "action_id": ( + str(blocker["action_id"]) if blocker.get("action_id") else None + ), + "blocker_id": str(blocker["id"]), + "reason_code": "path_blocked", + } + ) + return items + + +def _blocked_actions(ctx: AttentionEvalContext) -> list[dict]: + items: list[dict] = [] + for action in ctx.actions: + if action.get("status") != "blocked": + continue + items.append( + { + "kind": "blocked_action", + "severity": "critical", + "title": action.get("title") or "Arbeitspaket", + "summary": "Maßnahme ist blockiert", + "scope_type": "action", + "scope_id": str(action["id"]), + "initiative_id": ctx.initiative_id, + "action_id": str(action["id"]), + "reason_code": "path_blocked", + } + ) + return items + + +register_attention_contributor( + AttentionContributor(key="open_blockers", contribute=_open_blockers) +) +register_attention_contributor( + AttentionContributor(key="blocked_actions", contribute=_blocked_actions) +) diff --git a/backend/steering/attention/contributors/gates.py b/backend/steering/attention/contributors/gates.py new file mode 100644 index 0000000..42426dd --- /dev/null +++ b/backend/steering/attention/contributors/gates.py @@ -0,0 +1,105 @@ +"""Gate / execution graph attention — planning_debt, execution_waiting.""" + +from __future__ import annotations + +from steering.attention.context import AttentionEvalContext +from steering.attention.contributors.registry import ( + AttentionContributor, + register_attention_contributor, +) +from steering.graph.execution_engine import ( + execution_waiting_to_attention_items, + planning_debt_to_attention_items, +) + +_GATE_METHODS = frozenset( + {"sequential_dependency", "program_delivery", "maturity_progression"} +) + + +def _planning_debt(ctx: AttentionEvalContext) -> list[dict]: + if ctx.binding.primary_method_key not in _GATE_METHODS: + return [] + debts = ctx.read_models.get("planning_debt") + if debts is None: + from steering.graph.execution_engine import compute_planning_debt + + gate_scope = ctx.gate_scope + scoped_roadmap = ctx.roadmap_items + scoped_actions = ctx.actions + if gate_scope: + scoped_roadmap = [ + ri for ri in ctx.roadmap_items if str(ri.get("id")) == str(gate_scope) + ] + scoped_actions = [ + a + for a in ctx.actions + if a.get("roadmap_item_id") + and str(a["roadmap_item_id"]) == str(gate_scope) + ] + debts = compute_planning_debt( + actions=scoped_actions, roadmap_items=scoped_roadmap + ) + return planning_debt_to_attention_items( + initiative_id=ctx.initiative_id, debts=debts or [] + ) + + +def _execution_waiting(ctx: AttentionEvalContext) -> list[dict]: + if "critical_path" not in ctx.steering_elements and "gate_fulfillment" not in ctx.steering_elements: + return [] + return execution_waiting_to_attention_items( + initiative_id=ctx.initiative_id, + actions=ctx.actions, + graph_state=ctx.graph_state, + ) + + +def _horizon_no_ready_work(ctx: AttentionEvalContext) -> list[dict]: + if ctx.next_work: + return [] + if ctx.binding.primary_method_key not in _GATE_METHODS: + return [] + if ctx.horizon.kind != "gate": + return [] + + debts = ctx.read_models.get("planning_debt") or [] + has_planning = len(debts) > 0 + has_waiting = bool(ctx.graph_state.get("blocked_actions")) + if not has_planning and not has_waiting and not ctx.graph_state.get("ready_actions"): + return [ + { + "kind": "initiative_without_next_action", + "severity": "info", + "title": ctx.horizon.gate_title or "Aktiver Horizont", + "summary": "Keine ausführungsbereite Arbeit im aktiven Horizont", + "scope_type": "milestone", + "scope_id": ctx.horizon.gate_roadmap_item_id, + "initiative_id": ctx.initiative_id, + "reason_code": "horizon_no_ready_work", + } + ] + return [] + + +register_attention_contributor( + AttentionContributor( + key="planning_debt", + requires_elements=frozenset({"gate_fulfillment"}), + contribute=_planning_debt, + ) +) +register_attention_contributor( + AttentionContributor( + key="execution_waiting", + requires_any_element=frozenset({"critical_path", "gate_fulfillment"}), + contribute=_execution_waiting, + ) +) +register_attention_contributor( + AttentionContributor( + key="horizon_no_ready_work", + requires_elements=frozenset({"gate_fulfillment"}), + contribute=_horizon_no_ready_work, + ) +) diff --git a/backend/steering/attention/contributors/read_models.py b/backend/steering/attention/contributors/read_models.py new file mode 100644 index 0000000..e525d7a --- /dev/null +++ b/backend/steering/attention/contributors/read_models.py @@ -0,0 +1,80 @@ +"""Read-model / proposal derived attention.""" + +from __future__ import annotations + +from steering.attention.context import AttentionEvalContext +from steering.attention.contributors.registry import ( + AttentionContributor, + register_attention_contributor, +) +from steering.proposals.gate_next_actions import gate_next_actions_attention_items +from steering.proposals.intake_triage import intake_triage_attention_items +from steering.proposals.sprint_commit import sprint_commit_attention_items +from steering.read_models.epic_rollup import epic_rollup_attention_items + + +def _epic_rollup(ctx: AttentionEvalContext) -> list[dict]: + rollups = ctx.read_models.get("epic_rollup") + if not rollups: + return [] + return epic_rollup_attention_items( + initiative_id=ctx.initiative_id, rollups=rollups + ) + + +def _sprint_commit(ctx: AttentionEvalContext) -> list[dict]: + proposals = ctx.proposals.get("sprint_commit") + if not proposals: + return [] + return sprint_commit_attention_items( + initiative_id=ctx.initiative_id, + proposals=proposals, + actions=ctx.actions, + ) + + +def _gate_next_actions(ctx: AttentionEvalContext) -> list[dict]: + proposals = ctx.proposals.get("gate_next_actions") + if not proposals: + return [] + return gate_next_actions_attention_items( + initiative_id=ctx.initiative_id, proposals=proposals + ) + + +def _intake_triage(ctx: AttentionEvalContext) -> list[dict]: + proposals = ctx.proposals.get("intake_triage") + if not proposals: + return [] + return intake_triage_attention_items( + initiative_id=ctx.initiative_id, proposals=proposals + ) + + +register_attention_contributor( + AttentionContributor( + key="epic_rollup", + requires_elements=frozenset({"backlog_epic_hierarchy"}), + contribute=_epic_rollup, + ) +) +register_attention_contributor( + AttentionContributor( + key="sprint_commit", + requires_elements=frozenset({"work_cycle_scope"}), + contribute=_sprint_commit, + ) +) +register_attention_contributor( + AttentionContributor( + key="gate_next_actions", + requires_elements=frozenset({"gate_fulfillment"}), + contribute=_gate_next_actions, + ) +) +register_attention_contributor( + AttentionContributor( + key="intake_triage", + contribute=_intake_triage, + ) +) diff --git a/backend/steering/attention/contributors/registry.py b/backend/steering/attention/contributors/registry.py new file mode 100644 index 0000000..c11ba46 --- /dev/null +++ b/backend/steering/attention/contributors/registry.py @@ -0,0 +1,59 @@ +"""Attention contributor registry — K-Ext-2.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Callable + +from steering.attention.context import AttentionEvalContext + +AttentionContribute = Callable[[AttentionEvalContext], list[dict]] + + +@dataclass(frozen=True) +class AttentionContributor: + key: str + requires_elements: frozenset[str] = field(default_factory=frozenset) + requires_any_element: frozenset[str] = field(default_factory=frozenset) + requires_primary_methods: frozenset[str] = field(default_factory=frozenset) + contribute: AttentionContribute = lambda _ctx: [] + + +_CONTRIBUTORS: list[AttentionContributor] = [] + + +def register_attention_contributor(contributor: AttentionContributor) -> None: + _CONTRIBUTORS.append(contributor) + + +def collect_attention_items(ctx: AttentionEvalContext) -> list[dict]: + elements = ctx.steering_elements + primary = ctx.binding.primary_method_key + items: list[dict] = [] + + for contributor in _CONTRIBUTORS: + if ( + contributor.requires_elements + and not contributor.requires_elements <= elements + ): + continue + if contributor.requires_any_element and not ( + contributor.requires_any_element & elements + ): + continue + if ( + contributor.requires_primary_methods + and primary not in contributor.requires_primary_methods + ): + continue + items.extend(contributor.contribute(ctx)) + + for item in items: + item.setdefault("data_source", "steering_kernel") + + items.sort( + key=lambda x: {"critical": 0, "warning": 1, "info": 2}.get( + x.get("severity", "info"), 9 + ) + ) + return items diff --git a/backend/steering/kernel/attention.py b/backend/steering/kernel/attention.py index 4b5a882..4b52e89 100644 --- a/backend/steering/kernel/attention.py +++ b/backend/steering/kernel/attention.py @@ -5,16 +5,12 @@ from __future__ import annotations from typing import Any from steering.kernel.models import HorizonMarker, SteeringBinding -from steering.strategies.next_action.execution_ready import gate_horizon_scope_for_method from tenant_context import TenantContext -_GATE_METHODS = frozenset( - { - "sequential_dependency", - "program_delivery", - "maturity_progression", - } -) +# Side-effect: register contributors +import steering.attention.contributors.core # noqa: F401 +import steering.attention.contributors.gates # noqa: F401 +import steering.attention.contributors.read_models # noqa: F401 def evaluate_attention( @@ -29,161 +25,18 @@ def evaluate_attention( eval_ctx=None, limit: int = 20, ) -> list[dict[str, Any]]: - """Kernel attention for a single initiative — no fake next when planning debt.""" - from services import actions as action_service - from services import blockers as blocker_service - from services import roadmap as roadmap_service - from services.execution_plan import list_dependencies_for_initiative - from steering.graph.execution_engine import ( - compute_execution_graph_state, - compute_planning_debt, - execution_waiting_to_attention_items, - planning_debt_to_attention_items, + """Kernel attention — contributors via registry (K-Ext-2).""" + from steering.attention.context import AttentionEvalContext + from steering.attention.contributors.registry import collect_attention_items + + actx = AttentionEvalContext( + tenant_ctx=ctx, + initiative_id=initiative_id, + binding=binding, + horizon=horizon, + next_work=next_work, + read_models=read_models or {}, + proposals=proposals or {}, + eval_ctx=eval_ctx, ) - - items: list[dict[str, Any]] = [] - - blockers = blocker_service.list_blockers_for_initiative( - tenant_id=ctx.tenant_id, initiative_id=initiative_id - ) - for blocker in blockers: - if blocker.get("status") not in ("open", "in_progress"): - continue - items.append( - { - "kind": "open_blocker", - "severity": "warning", - "title": blocker.get("title") or "Blocker", - "summary": "Offener Blocker im Vorhaben", - "scope_type": "blocker", - "scope_id": str(blocker["id"]), - "initiative_id": initiative_id, - "action_id": ( - str(blocker["action_id"]) if blocker.get("action_id") else None - ), - "blocker_id": str(blocker["id"]), - "reason_code": "path_blocked", - "data_source": "steering_kernel", - } - ) - - actions = action_service.list_actions_for_initiative( - tenant_id=ctx.tenant_id, initiative_id=initiative_id - ) - for action in actions: - if action.get("status") != "blocked": - continue - items.append( - { - "kind": "blocked_action", - "severity": "critical", - "title": action.get("title") or "Arbeitspaket", - "summary": "Maßnahme ist blockiert", - "scope_type": "action", - "scope_id": str(action["id"]), - "initiative_id": initiative_id, - "action_id": str(action["id"]), - "reason_code": "path_blocked", - "data_source": "steering_kernel", - } - ) - - gate_scope = gate_horizon_scope_for_method( - binding.primary_method_key, ctx, initiative_id - ) - roadmap_items = roadmap_service.list_roadmap_items_for_initiative( - tenant_id=ctx.tenant_id, initiative_id=initiative_id - ) - - scoped_roadmap = roadmap_items - scoped_actions = actions - if gate_scope: - scope = str(gate_scope) - scoped_roadmap = [ri for ri in roadmap_items if str(ri.get("id")) == scope] - scoped_actions = [ - a - for a in actions - if a.get("roadmap_item_id") and str(a["roadmap_item_id"]) == scope - ] - - if binding.primary_method_key in _GATE_METHODS: - debts = compute_planning_debt( - actions=scoped_actions, roadmap_items=scoped_roadmap - ) - items.extend( - planning_debt_to_attention_items( - initiative_id=initiative_id, debts=debts - ) - ) - - dependencies = list_dependencies_for_initiative( - tenant_id=ctx.tenant_id, initiative_id=initiative_id - ) - graph_state = compute_execution_graph_state( - actions=actions, - dependencies=dependencies, - scope_roadmap_item_id=gate_scope, - ) - items.extend( - execution_waiting_to_attention_items( - initiative_id=initiative_id, - actions=actions, - graph_state=graph_state, - ) - ) - - read_models = read_models or {} - proposals = proposals or {} - - from steering.read_models.epic_rollup import epic_rollup_attention_items - from steering.proposals.sprint_commit import sprint_commit_attention_items - - epic_rollups = read_models.get("epic_rollup") - if epic_rollups: - items.extend( - epic_rollup_attention_items( - initiative_id=initiative_id, rollups=epic_rollups - ) - ) - - sprint_proposals = proposals.get("sprint_commit") - if sprint_proposals: - items.extend( - sprint_commit_attention_items( - initiative_id=initiative_id, - proposals=sprint_proposals, - actions=actions, - ) - ) - - if ( - not next_work - and binding.primary_method_key in _GATE_METHODS - and horizon.kind == "gate" - ): - has_planning = any(i.get("reason_code") == "planning_debt" for i in items) - has_waiting = any(i.get("reason_code") == "execution_waiting" for i in items) - if not has_planning and not has_waiting and not graph_state.get("ready_actions"): - items.append( - { - "kind": "initiative_without_next_action", - "severity": "info", - "title": horizon.gate_title or "Aktiver Horizont", - "summary": "Keine ausführungsbereite Arbeit im aktiven Horizont", - "scope_type": "milestone", - "scope_id": horizon.gate_roadmap_item_id, - "initiative_id": initiative_id, - "reason_code": "horizon_no_ready_work", - "data_source": "steering_kernel", - } - ) - - for item in items: - item.setdefault("data_source", "steering_kernel") - - items.sort( - key=lambda x: {"critical": 0, "warning": 1, "info": 2}.get( - x.get("severity", "info"), 9 - ) - ) - return items[:limit] + return collect_attention_items(actx)[:limit] diff --git a/backend/steering/proposals/gate_next_actions.py b/backend/steering/proposals/gate_next_actions.py new file mode 100644 index 0000000..3820d33 --- /dev/null +++ b/backend/steering/proposals/gate_next_actions.py @@ -0,0 +1,127 @@ +"""Gate next actions proposal — sequential_dependency / program_delivery.""" + +from __future__ import annotations + +from typing import Any + +from steering.eval_context import SteeringEvalContext + +COMMITTABLE = frozenset({"new", "triaged", "accepted"}) +_PRIORITY = {"critical": 0, "high": 1, "normal": 2, "low": 3} + + +def _active_gate_id(ctx: SteeringEvalContext) -> str | None: + if ctx.horizon.kind == "gate" and ctx.horizon.gate_roadmap_item_id: + return str(ctx.horizon.gate_roadmap_item_id) + debts = [] + return None + + +def _sort_key(item: dict[str, Any]) -> tuple: + return ( + _PRIORITY.get(item.get("priority") or "normal", 9), + item.get("sort_order") if item.get("sort_order") is not None else 9999, + (item.get("title") or "").lower(), + ) + + +def propose_gate_next_actions( + ctx: SteeringEvalContext, + *, + read_models: dict[str, Any] | None = None, +) -> list[dict[str, Any]]: + read_models = read_models or {} + gate_id = _active_gate_id(ctx) + proposals: list[dict[str, Any]] = [] + rank = 0 + + for debt in read_models.get("planning_debt") or []: + rank += 1 + gid = str(debt.get("roadmap_item_id")) + proposals.append( + { + "proposal_key": "gate_next_actions", + "scope_type": "roadmap_item", + "scope_id": gid, + "rank": rank, + "reason_code": "planning_debt", + "summary": debt.get("message") or "Gate ohne Durchführungsplan", + "title": debt.get("title") or "Zielzustand", + "roadmap_item_id": gid, + "initiative_id": ctx.initiative_id, + "ranker_key": "heuristic_v0", + "confidence": "heuristic", + "factors": [{"code": "planning_debt", "weight": 100, "label": "Planning Debt"}], + "dependency_refs": [], + "dependency_blocked": False, + "data_source": "steering_kernel", + } + ) + + vocabulary = ctx.backlog_vocabulary + convertible = frozenset(vocabulary.get("convertible_kinds") or ["story", "bug", "issue"]) + candidates = [ + item + for item in ctx.backlog_items + if item.get("status") in COMMITTABLE + and item.get("item_kind") != "epic" + and (item.get("item_kind") or "story") in convertible + and (not gate_id or str(item.get("roadmap_item_id") or "") == gate_id) + ] + + for item in sorted(candidates, key=_sort_key)[:15]: + rank += 1 + proposals.append( + { + "proposal_key": "gate_next_actions", + "scope_type": "backlog_item", + "scope_id": str(item["id"]), + "rank": rank, + "reason_code": "gate_backlog_ready", + "summary": "Eingang-Item am aktiven Gate committen", + "title": item.get("title") or "Backlog-Item", + "item_kind": item.get("item_kind") or "story", + "priority": item.get("priority") or "normal", + "roadmap_item_id": ( + str(item["roadmap_item_id"]) if item.get("roadmap_item_id") else gate_id + ), + "initiative_id": ctx.initiative_id, + "ranker_key": "heuristic_v0", + "confidence": "heuristic", + "factors": [ + { + "code": "gate_alignment", + "weight": 50, + "label": "Gate-Zuordnung", + } + ], + "dependency_refs": [], + "dependency_blocked": False, + "data_source": "steering_kernel", + } + ) + + return proposals + + +def gate_next_actions_attention_items( + *, + initiative_id: str, + proposals: list[dict[str, Any]], +) -> list[dict[str, Any]]: + backlog_ready = [p for p in proposals if p.get("scope_type") == "backlog_item"] + if not backlog_ready: + return [] + return [ + { + "kind": "gate_commit_suggested", + "severity": "info", + "title": "Gate-Planung", + "summary": f"{len(backlog_ready)} Item(s) für Gate-Commit vorgeschlagen", + "scope_type": "initiative", + "scope_id": initiative_id, + "initiative_id": initiative_id, + "reason_code": "gate_commit_suggested", + "data_source": "gate_next_actions", + } + ] diff --git a/backend/steering/proposals/intake_triage.py b/backend/steering/proposals/intake_triage.py new file mode 100644 index 0000000..1332fa9 --- /dev/null +++ b/backend/steering/proposals/intake_triage.py @@ -0,0 +1,93 @@ +"""Intake triage proposal — queue_inbox / continuous backlog (no sprint).""" + +from __future__ import annotations + +from typing import Any + +from steering.eval_context import SteeringEvalContext + +COMMITTABLE = frozenset({"new", "triaged", "accepted"}) +_PRIORITY = {"critical": 0, "high": 1, "normal": 2, "low": 3} +_QUEUE_KIND_BIAS = {"bug": 0, "issue": 1, "incident": 1, "story": 2, "idea": 3} + + +def propose_intake_triage( + ctx: SteeringEvalContext, + *, + read_models: dict[str, Any] | None = None, +) -> list[dict[str, Any]]: + vocabulary = ctx.backlog_vocabulary + convertible = frozenset(vocabulary.get("convertible_kinds") or ["story", "bug", "issue"]) + queue_mode = "queue_inbox" in ctx.steering_elements + + candidates = [ + item + for item in ctx.backlog_items + if item.get("status") in COMMITTABLE + and item.get("item_kind") != "epic" + and (item.get("item_kind") or "story") in convertible + ] + if not candidates: + return [] + + def sort_key(item: dict[str, Any]) -> tuple: + kind = item.get("item_kind") or "story" + kind_bias = _QUEUE_KIND_BIAS.get(kind, 5) if queue_mode else 5 + return ( + _PRIORITY.get(item.get("priority") or "normal", 9), + kind_bias, + item.get("sort_order") if item.get("sort_order") is not None else 9999, + ) + + proposals: list[dict[str, Any]] = [] + for rank, item in enumerate(sorted(candidates, key=sort_key)[:20], start=1): + kind = item.get("item_kind") or "story" + proposals.append( + { + "proposal_key": "intake_triage", + "scope_type": "backlog_item", + "scope_id": str(item["id"]), + "rank": rank, + "reason_code": "intake_triage_candidate", + "summary": "Triage-Kandidat (Heuristik — Ranker austauschbar)", + "title": item.get("title") or "Backlog-Item", + "item_kind": kind, + "priority": item.get("priority") or "normal", + "initiative_id": ctx.initiative_id, + "ranker_key": "heuristic_v0", + "confidence": "heuristic", + "factors": [ + { + "code": "priority", + "weight": _PRIORITY.get(item.get("priority") or "normal", 9), + "label": "Priorität", + } + ], + "dependency_refs": [], + "dependency_blocked": False, + "data_source": "steering_kernel", + } + ) + return proposals + + +def intake_triage_attention_items( + *, + initiative_id: str, + proposals: list[dict[str, Any]], +) -> list[dict[str, Any]]: + if not proposals: + return [] + return [ + { + "kind": "intake_triage_suggested", + "severity": "info", + "title": "Eingang triagieren", + "summary": f"{len(proposals)} Item(s) — Kernel schlägt Triage-Reihenfolge vor", + "scope_type": "initiative", + "scope_id": initiative_id, + "initiative_id": initiative_id, + "reason_code": "intake_triage_suggested", + "data_source": "intake_triage", + } + ] diff --git a/backend/steering/proposals/registry.py b/backend/steering/proposals/registry.py index 4011629..76a102d 100644 --- a/backend/steering/proposals/registry.py +++ b/backend/steering/proposals/registry.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, Callable from steering.eval_context import SteeringEvalContext @@ -16,6 +16,7 @@ class ProposalProvider: requires_elements: frozenset[str] requires_data_slices: frozenset[str] compute: ProposalCompute + excludes_elements: frozenset[str] = field(default_factory=frozenset) _PROVIDERS: list[ProposalProvider] = [] @@ -37,6 +38,8 @@ def compute_proposals( for provider in _PROVIDERS: if provider.requires_elements and not provider.requires_elements <= elements: continue + if provider.excludes_elements and provider.excludes_elements & elements: + continue if provider.requires_data_slices and not provider.requires_data_slices <= slices: continue result[provider.key] = provider.compute(ctx, read_models) @@ -45,6 +48,8 @@ def compute_proposals( def _register_builtin_proposals() -> None: + from steering.proposals.gate_next_actions import propose_gate_next_actions + from steering.proposals.intake_triage import propose_intake_triage from steering.proposals.sprint_commit import propose_sprint_commit register_proposal( @@ -52,7 +57,30 @@ def _register_builtin_proposals() -> None: key="sprint_commit", requires_elements=frozenset({"work_cycle_scope"}), requires_data_slices=frozenset({"backlog", "work_cycles"}), - compute=lambda ctx, read_models: propose_sprint_commit(ctx, read_models=read_models), + compute=lambda ctx, read_models: propose_sprint_commit( + ctx, read_models=read_models + ), + ) + ) + register_proposal( + ProposalProvider( + key="gate_next_actions", + requires_elements=frozenset({"gate_fulfillment"}), + requires_data_slices=frozenset({"backlog", "roadmap", "actions"}), + compute=lambda ctx, read_models: propose_gate_next_actions( + ctx, read_models=read_models + ), + ) + ) + register_proposal( + ProposalProvider( + key="intake_triage", + requires_elements=frozenset(), + requires_data_slices=frozenset({"backlog"}), + excludes_elements=frozenset({"work_cycle_scope"}), + compute=lambda ctx, read_models: propose_intake_triage( + ctx, read_models=read_models + ), ) ) diff --git a/backend/steering/read_models/execution_graph.py b/backend/steering/read_models/execution_graph.py new file mode 100644 index 0000000..afbbe9e --- /dev/null +++ b/backend/steering/read_models/execution_graph.py @@ -0,0 +1,27 @@ +"""Execution graph read model — critical_path / gate_fulfillment.""" + +from __future__ import annotations + +from typing import Any + +from steering.eval_context import SteeringEvalContext +from steering.graph.execution_engine import compute_execution_graph_state + + +def compute_execution_graph_read_model(ctx: SteeringEvalContext) -> dict[str, Any]: + from services.execution_plan import list_dependencies_for_initiative + from steering.strategies.next_action.execution_ready import ( + gate_horizon_scope_for_method, + ) + + gate_scope = gate_horizon_scope_for_method( + ctx.binding.primary_method_key, ctx.tenant_ctx, ctx.initiative_id + ) + dependencies = list_dependencies_for_initiative( + tenant_id=ctx.tenant_ctx.tenant_id, initiative_id=ctx.initiative_id + ) + return compute_execution_graph_state( + actions=ctx.actions, + dependencies=dependencies, + scope_roadmap_item_id=gate_scope, + ) diff --git a/backend/steering/read_models/planning_debt.py b/backend/steering/read_models/planning_debt.py new file mode 100644 index 0000000..4032057 --- /dev/null +++ b/backend/steering/read_models/planning_debt.py @@ -0,0 +1,36 @@ +"""Planning debt read model — gate_fulfillment.""" + +from __future__ import annotations + +from typing import Any + +from steering.eval_context import SteeringEvalContext +from steering.graph.execution_engine import compute_planning_debt + + +def compute_planning_debt_read_model(ctx: SteeringEvalContext) -> list[dict[str, Any]]: + from services import roadmap as roadmap_service + from steering.strategies.next_action.execution_ready import ( + gate_horizon_scope_for_method, + ) + + gate_scope = gate_horizon_scope_for_method( + ctx.binding.primary_method_key, ctx.tenant_ctx, ctx.initiative_id + ) + roadmap_items = roadmap_service.list_roadmap_items_for_initiative( + tenant_id=ctx.tenant_ctx.tenant_id, initiative_id=ctx.initiative_id + ) + actions = ctx.actions + + scoped_roadmap = roadmap_items + scoped_actions = actions + if gate_scope: + scope = str(gate_scope) + scoped_roadmap = [ri for ri in roadmap_items if str(ri.get("id")) == scope] + scoped_actions = [ + a + for a in actions + if a.get("roadmap_item_id") and str(a["roadmap_item_id"]) == scope + ] + + return compute_planning_debt(actions=scoped_actions, roadmap_items=scoped_roadmap) diff --git a/backend/steering/read_models/registry.py b/backend/steering/read_models/registry.py index f26ad3c..c39b3f5 100644 --- a/backend/steering/read_models/registry.py +++ b/backend/steering/read_models/registry.py @@ -16,6 +16,7 @@ class ReadModelProvider: requires_elements: frozenset[str] requires_data_slices: frozenset[str] compute: ReadModelCompute + requires_any_element: frozenset[str] = frozenset() _PROVIDERS: list[ReadModelProvider] = [] @@ -34,6 +35,10 @@ def compute_read_models(ctx: SteeringEvalContext) -> dict[str, Any]: for provider in _PROVIDERS: if provider.requires_elements and not provider.requires_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 result[provider.key] = provider.compute(ctx) @@ -43,6 +48,8 @@ def compute_read_models(ctx: SteeringEvalContext) -> dict[str, Any]: 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 register_read_model( ReadModelProvider( @@ -55,6 +62,23 @@ def _register_builtin_read_models() -> None: ), ) ) + register_read_model( + ReadModelProvider( + key="planning_debt", + requires_elements=frozenset({"gate_fulfillment"}), + requires_data_slices=frozenset({"roadmap", "actions"}), + compute=compute_planning_debt_read_model, + ) + ) + register_read_model( + ReadModelProvider( + key="execution_graph", + requires_elements=frozenset(), + requires_any_element=frozenset({"critical_path", "gate_fulfillment"}), + requires_data_slices=frozenset({"actions"}), + compute=compute_execution_graph_read_model, + ) + ) _register_builtin_read_models() diff --git a/backend/tests/test_gate_next_actions_unit.py b/backend/tests/test_gate_next_actions_unit.py new file mode 100644 index 0000000..22e25be --- /dev/null +++ b/backend/tests/test_gate_next_actions_unit.py @@ -0,0 +1,93 @@ +"""Unit tests — gate_next_actions proposal.""" + +from __future__ import annotations + +from steering.eval_context import SteeringEvalContext +from steering.kernel.models import HorizonMarker, LifecycleContext, SteeringBinding +from steering.proposals.gate_next_actions import propose_gate_next_actions +from tenant_context import TenantContext + + +def _ctx(*, backlog_items, read_models=None, gate_id="gate-1"): + binding = SteeringBinding( + primary_method_key="sequential_dependency", + composition_modifier=None, + next_work_strategy_key="sequential_dependency", + ) + lifecycle = LifecycleContext( + current_state="execution", + current_state_label="Ausführung", + slot_map={}, + active_slots=(), + operational_slots=(), + ) + horizon = HorizonMarker( + kind="gate", + gate_roadmap_item_id=gate_id, + gate_title="Gate A", + ) + eval_ctx = SteeringEvalContext( + tenant_ctx=TenantContext( + user_id="u1", + email="t@example.com", + display_name="Test", + portal_role="user", + tenant_id="t1", + tenant_slug="t", + tenant_name="T", + tenant_role="admin", + actor_id=None, + actor_type=None, + session_token="test", + capabilities=frozenset(), + ), + initiative_id="init-1", + binding=binding, + horizon=horizon, + lifecycle=lifecycle, + operating_context={ + "steering_elements": ["gate_fulfillment", "critical_path"], + "data_slices": ["backlog", "roadmap", "actions"], + "backlog_vocabulary": {"convertible_kinds": ["story", "bug", "issue"]}, + }, + ) + eval_ctx._backlog_items = backlog_items + eval_ctx._actions = [] + eval_ctx._work_cycles = [] + return eval_ctx, read_models or {} + + +def test_gate_proposal_includes_planning_debt(): + eval_ctx, read_models = _ctx( + backlog_items=[], + read_models={ + "planning_debt": [ + { + "roadmap_item_id": "gate-1", + "title": "Gate A", + "message": "Kein Plan", + } + ] + }, + ) + result = propose_gate_next_actions(eval_ctx, read_models=read_models) + assert result[0]["scope_type"] == "roadmap_item" + assert result[0]["reason_code"] == "planning_debt" + + +def test_gate_proposal_backlog_at_gate(): + eval_ctx, read_models = _ctx( + backlog_items=[ + { + "id": "b1", + "title": "Story", + "item_kind": "story", + "status": "accepted", + "roadmap_item_id": "gate-1", + "priority": "normal", + "sort_order": 0, + } + ], + ) + result = propose_gate_next_actions(eval_ctx, read_models=read_models) + assert any(p["scope_type"] == "backlog_item" for p in result) diff --git a/backend/tests/test_intake_triage_unit.py b/backend/tests/test_intake_triage_unit.py new file mode 100644 index 0000000..48c6e4f --- /dev/null +++ b/backend/tests/test_intake_triage_unit.py @@ -0,0 +1,57 @@ +"""Unit tests — intake_triage proposal.""" + +from __future__ import annotations + +from steering.eval_context import SteeringEvalContext +from steering.kernel.models import HorizonMarker, LifecycleContext, SteeringBinding +from steering.proposals.intake_triage import propose_intake_triage +from tenant_context import TenantContext + + +def test_intake_triage_proposes_backlog_items(): + binding = SteeringBinding( + primary_method_key="sequential_dependency", + composition_modifier=None, + next_work_strategy_key="sequential_dependency", + ) + eval_ctx = SteeringEvalContext( + tenant_ctx=TenantContext( + user_id="u1", + email="t@example.com", + display_name="Test", + portal_role="user", + tenant_id="t1", + tenant_slug="t", + tenant_name="T", + tenant_role="admin", + actor_id=None, + actor_type=None, + session_token="test", + capabilities=frozenset(), + ), + initiative_id="init-1", + binding=binding, + horizon=HorizonMarker(kind="none"), + lifecycle=LifecycleContext( + current_state="execution", + current_state_label="Ausführung", + slot_map={}, + active_slots=(), + operational_slots=(), + ), + operating_context={ + "steering_elements": ["gate_fulfillment"], + "data_slices": ["backlog"], + "backlog_vocabulary": {"convertible_kinds": ["story", "bug", "issue"]}, + }, + ) + eval_ctx._backlog_items = [ + {"id": "b1", "title": "Item", "item_kind": "story", "status": "new", "priority": "normal", "sort_order": 0}, + ] + eval_ctx._actions = [] + eval_ctx._work_cycles = [] + + result = propose_intake_triage(eval_ctx, read_models={}) + assert len(result) == 1 + assert result[0]["proposal_key"] == "intake_triage" + assert result[0]["ranker_key"] == "heuristic_v0" diff --git a/docs/architecture/ADP_Steering_Kernel_Coding_Rules_v0.1.md b/docs/architecture/ADP_Steering_Kernel_Coding_Rules_v0.1.md new file mode 100644 index 0000000..be2a20d --- /dev/null +++ b/docs/architecture/ADP_Steering_Kernel_Coding_Rules_v0.1.md @@ -0,0 +1,134 @@ +# ADP — Steering Kernel Coding Rules v0.1 + +**Status:** PO-freigegeben — **verbindlich für alle Coding-Agenten** +**Stand:** 2026-07-27 +**Bezug:** ADP Steering Kernel Spine v0.1, ADP Steering Kernel Extension Model v0.1, ADP AP2.4, `.cursor/rules/kairo-steering-kernel.mdc` + +--- + +## 1. Geltungsbereich + +Diese Regeln gelten für **jede** Änderung an: + +- `backend/steering/**` (Kernel, Registries, Provider, Ranker, Contributors) +- `backend/data_layer/initiative_snapshot.py` (Steuerungs-Read-Models) +- Frontend-Steuerungs-UI (`SteeringProposalsPanel`, Operating Context, Snapshot-Konsum) + +**Ziel:** Ein gemeinsamer Steuerungskern für alle Methoden — keine Agile-Sonderlogik in Pages, kein Methoden-Fork im Kernel. + +--- + +## 2. Herzmaschine (Stop-the-line) + +| Regel | Detail | +|-------|--------| +| **SK-01** | Initiative-Steuerung (Next Work, Attention, Read Models, Proposals) **nur** über `steering.kernel.evaluate_steering()`. | +| **SK-02** | Snapshot/API spiegeln Kernel-Output — **keine** parallele Berechnung in `initiative_snapshot.py` (Ausnahme: Legacy-Signals bis Migration). | +| **SK-03** | Keine Steuerungsheuristik in Routers, Services außerhalb `backend/steering/`, oder React-Pages. | + +--- + +## 3. Extension Registry (Pflicht-Muster) + +Neue Steuerungsfähigkeit = **Provider registrieren**, nicht inline codieren. + +| Typ | Registry | Aktivierung | +|-----|----------|-------------| +| Read Model | `steering/read_models/registry.py` | `requires_elements`, optional `requires_any_element`, `requires_data_slices` | +| Proposal | `steering/proposals/registry.py` | wie oben + optional `excludes_elements` | +| Attention | `steering/attention/contributors/registry.py` | `requires_elements` / `requires_any_element` | +| Ranker (Proposal) | `register_sprint_commit_ranker()` o. Ä. | Operating Context `proposal_rankers.{key}` | + +**SK-04:** Provider-Schlüssel sind stabil (`sprint_commit`, `gate_next_actions`, `intake_triage`) — UI bindet an Keys, nicht an Archetyp. + +**SK-05:** Keine feste Produktpolitik in Rankern (z. B. „Bugs immer zuerst“). Heuristik = Fallback mit `ranker_key: heuristic_v0`; Agent = `agent_v1` nach Principle Gate. + +--- + +## 4. Proposal-DTO (Minimalvertrag) + +Jeder Proposal-Eintrag liefert mindestens: + +```python +{ + "proposal_key": str, + "scope_type": "backlog_item" | "roadmap_item" | "action" | ..., + "scope_id": str, + "rank": int, + "reason_code": str, + "summary": str, + "ranker_key": str, + "confidence": "heuristic" | "agent", + "factors": [{"code", "weight", "label"}], + "dependency_refs": [...], + "dependency_blocked": bool, + "data_source": "steering_kernel", +} +``` + +**SK-06:** Proposals **mutieren nie** OM — Accept erfolgt explizit in UI/API. + +**SK-07:** Abhängigkeiten (`parent_action_id`, Execution Graph, Epic) in `dependency_refs` / `dependency_blocked` — nicht stillschweigend ignorieren. + +--- + +## 5. Methoden-Parität + +| Methode / Element | Read Models | Proposals | +|-------------------|-------------|-----------| +| `work_cycle_scope` | epic_rollup (mit backlog_epic_hierarchy) | `sprint_commit` | +| `gate_fulfillment` | `planning_debt`, `execution_graph` | `gate_next_actions` | +| `backlog` ohne Sprint | — | `intake_triage` (`excludes work_cycle_scope`) | +| `queue_inbox` | — | `intake_triage` (Queue-Profil) | +| `critical_path` | `execution_graph` | (später path-basiert) | + +**SK-08:** Neue Methode = Elemente im `MethodDefinition` + Provider — **kein** `if method_key == …` in Kernel. + +**SK-09:** `agile_iteration` ist **Modifier**, keine Parallel-Welt — komponiert mit Primary (Product, Linear, …). + +--- + +## 6. Frontend + +**SK-10:** Steuerungs-UI liest `steeringSnapshot.steering_kernel.proposals` / `read_models` — nicht selbst sortieren. + +**SK-11:** `SteeringProposalsPanel` + `PROPOSAL_UI_CONFIG` erweitern — **keine** neue Page pro Proposal-Typ. + +**SK-12:** Capabilities aus `operatingContext.steering_elements` / `hasSteeringElement` — keine Archetyp-Ifs. + +--- + +## 7. KI / Agent (eingefroren bis Principle Gate) + +**SK-13:** Keine Prompts hardcodieren. Agent-Ranker implementieren als `ranker_key: agent_v1` + Actor-Slot — Kontext aus Snapshot/Operating Context. + +**SK-14:** Bis Freigabe: Fallback auf `heuristic_v0` mit transparentem `factors[]`. + +--- + +## 8. Tests & Abnahme + +**SK-15:** Jeder neue Provider: Unit-Test (reine Logik) + Integration über Snapshot wenn DB verfügbar. + +**SK-16:** Truth Table + ADP Extension Model §6 aktualisieren. + +--- + +## 9. Verboten (Anti-Patterns) + +- Bug-zuerst / Story-zuerst als globale Sortierregel ohne `ranker_key`-Kennzeichnung +- Proposal-Logik in `BacklogSection` / `PlanSprintPage` +- Duplicate Read Model (Snapshot + Kernel) +- Neue OM-Tabelle für Vorschläge +- Methoden-spezifische Kernel-Forks + +--- + +## 10. Referenzen + +| Dokument | Pfad | +|----------|------| +| Kernel Extension Model | `docs/architecture/ADP_Steering_Kernel_Extension_Model_v0.1.md` | +| Kernel Spine | `docs/architecture/ADP_Steering_Kernel_Spine_v0.1.md` | +| Cursor Rule | `.cursor/rules/kairo-steering-kernel.mdc` | +| Plugin-Architektur | `.cursor/rules/kairo-plugin-architecture.mdc` | 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 83dc04f..6a866e7 100644 --- a/docs/architecture/ADP_Steering_Kernel_Extension_Model_v0.1.md +++ b/docs/architecture/ADP_Steering_Kernel_Extension_Model_v0.1.md @@ -210,7 +210,9 @@ class AgentSlotProvider: | Extension | agile_iteration | continuous_product | sequential_dependency | program_delivery | care_navigation | |-----------|-----------------|--------------------|-----------------------|------------------|-----------------| | `epic_rollup` | ✓ | — | — | ◐ (Epic=Liefercontainer) | — | -| `sprint_commit` proposal | ✓ | — | — | — | — | +| `sprint_commit` proposal | ✓ (work_cycle_scope) | ✓ | — | ◐ | ◐ | +| `gate_next_actions` proposal | — | ◐ | ✓ | ✓ | — | +| `intake_triage` proposal | — | ✓ (excl. sprint) | ✓ | ◐ | ✓ | | `planning_debt` | ◐ | ◐ | ✓ | ✓ | — | | `execution_graph` | ◐ | ◐ | ✓ | ✓ | ◐ | | `intake_triage` proposal | ✓ | ✓ | ◐ | ◐ | ✓ | @@ -226,7 +228,7 @@ class AgentSlotProvider: | Phase | Inhalt | Entkoppelt | |-------|--------|------------| | **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 | offen | +| **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-5** | Portfolio-Ebene: Workspace aggregiert Attention/Proposals über Initiativen | Program Director multi-initiative | diff --git a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md index 4666cbf..f4aad2f 100644 --- a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md +++ b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md @@ -228,7 +228,8 @@ 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 ✓ · P5 Agent-Tasks offen · K-Ext-2…4 offen | +| ADP Backlog/Epic | P1–P4 ✓ · P6 Sprint-Vorschlag ✓ · K-Ext-1/2/3 ✓ · Agent-Slots offen | +| 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 ✗→✓ | | AP1.7b | Op-API Parität | diff --git a/frontend/src/components/SteeringProposalsPanel.jsx b/frontend/src/components/SteeringProposalsPanel.jsx new file mode 100644 index 0000000..d1c3d09 --- /dev/null +++ b/frontend/src/components/SteeringProposalsPanel.jsx @@ -0,0 +1,107 @@ +import { + PROPOSAL_UI_CONFIG, + filterSprintProposals, + listProposalKeys, + proposalReasonLabel, +} from '../utils/steeringProposals.js' +import { backlogKindLabel, resolveBacklogVocabulary } from '../utils/resolveBacklogVocabulary.js' +import { PriorityBadge } from './PriorityBadge.jsx' + +export function SteeringProposalsPanel({ + proposalsByKey = {}, + proposalKeys = null, + backlogVocabulary = null, + canManage = false, + onAcceptBacklogProposal, + busy = false, + filterContext = {}, +}) { + const vocabulary = resolveBacklogVocabulary(backlogVocabulary) + const keys = proposalKeys || listProposalKeys(proposalsByKey) + + if (!keys.length) { + return null + } + + return ( + <> + {keys.map((proposalKey) => { + const config = PROPOSAL_UI_CONFIG[proposalKey] || { + title: proposalKey, + lead: 'Steuerungsvorschlag', + acceptLabel: 'Übernehmen', + scopeTypes: ['backlog_item'], + } + let items = proposalsByKey[proposalKey] || [] + if (proposalKey === 'sprint_commit' && filterContext.workCycleId) { + items = filterSprintProposals(items, filterContext.workCycleId) + } + if (!items.length) return null + const ranker = items[0]?.ranker_key || 'heuristic_v0' + + return ( +
+
+
+

{config.title}

+

+ {config.lead} Ranker: {ranker}. +

+
+
+
    + {items.map((proposal) => ( +
  1. +
    + + {proposal.rank}. {proposal.title} + +

    {proposalReasonLabel(proposal)}

    + {proposal.dependency_blocked && ( +

    + Abhängigkeit offen +

    + )} + {proposal.scope_type === 'roadmap_item' && ( +

    + Gate ohne Plan — Arbeitspaket anlegen oder Eingang committen +

    + )} +
    +
    + {proposal.item_kind && ( + + {backlogKindLabel(vocabulary, proposal.item_kind)} + + )} + {proposal.priority && } + {canManage + && proposal.scope_type === 'backlog_item' + && config.scopeTypes.includes('backlog_item') && ( + + )} +
    +
  2. + ))} +
+
+ ) + })} + + ) +} diff --git a/frontend/src/pages/initiative/InitiativeInboxPage.jsx b/frontend/src/pages/initiative/InitiativeInboxPage.jsx index 32fc6ef..4ace657 100644 --- a/frontend/src/pages/initiative/InitiativeInboxPage.jsx +++ b/frontend/src/pages/initiative/InitiativeInboxPage.jsx @@ -1,5 +1,7 @@ +import { useEffect, useMemo } from 'react' import { useInitiativeOperations } from '../../context/InitiativeOperationsContext.jsx' import { BacklogSection } from '../../components/BacklogSection.jsx' +import { SteeringProposalsPanel } from '../../components/SteeringProposalsPanel.jsx' import { LoadingState } from '../../components/LoadingState.jsx' export function InitiativeInboxPage() { @@ -21,8 +23,20 @@ export function InitiativeInboxPage() { handleConvertBacklog, handleBulkConvertBacklog, handleDeleteBacklog, + reloadSlices, + steeringSnapshot, + operatingContext, } = useInitiativeOperations() + useEffect(() => { + reloadSlices(['steering_snapshot']) + }, [reloadSlices]) + + const kernelProposals = useMemo( + () => steeringSnapshot?.steering_kernel?.proposals || {}, + [steeringSnapshot], + ) + if (!capabilities.has('kairo.initiative.read')) { return null } @@ -34,6 +48,17 @@ export function InitiativeInboxPage() { return ( <> {error &&

{error}

} + { + await handleConvertBacklog(itemId, {}) + await reloadSlices(['steering_snapshot', 'actions', 'backlog']) + }} + busy={formBusy} + /> { + reloadSlices(['steering_snapshot']) + }, [reloadSlices]) + + const kernelProposals = useMemo( + () => steeringSnapshot?.steering_kernel?.proposals || {}, + [steeringSnapshot], + ) + if (!capabilities.has('kairo.initiative.read')) { return null } @@ -20,6 +35,17 @@ export function InitiativePlanPage() { return ( <> {error &&

{error}

} + { + await handleConvertBacklog(itemId, {}) + await reloadSlices(['steering_snapshot', 'actions', 'backlog']) + }} + busy={formBusy} + /> - steeringSnapshot?.sprint_commit_proposals || - steeringSnapshot?.steering_kernel?.proposals?.sprint_commit || - [], + const kernelProposals = useMemo( + () => steeringSnapshot?.steering_kernel?.proposals || {}, [steeringSnapshot], ) @@ -110,12 +107,13 @@ function PlanSprintInner() { busy={formBusy} /> {selectedWorkCycleId && ( - { + onAcceptBacklogProposal={async (itemId, options) => { await handleConvertBacklog(itemId, options) await reloadSlices(['steering_snapshot', 'actions', 'backlog']) }} diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index 2241b81..4da0840 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -1716,10 +1716,28 @@ gap: 1rem; } -.sprint-commit-proposal__blocked { +.sprint-commit-proposal__blocked, +.steering-proposal__blocked { color: var(--jk-warning-text, #7a4b08); } +.steering-proposals { + margin-top: 1rem; +} + +.steering-proposals__list { + list-style: none; + padding: 0; + margin: 0; +} + +.steering-proposal { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; +} + .archetype-badge { background: var(--jk-surface-muted, #eef2f6); color: var(--jk-text-secondary, #445); diff --git a/frontend/src/utils/steeringProposals.js b/frontend/src/utils/steeringProposals.js new file mode 100644 index 0000000..d6ae51b --- /dev/null +++ b/frontend/src/utils/steeringProposals.js @@ -0,0 +1,51 @@ +/** Generic steering proposal UI config (Kernel v0.3). */ + +export const PROPOSAL_UI_CONFIG = { + sprint_commit: { + title: 'Sprint-Vorschläge', + lead: 'Ranker-gestützte Commit-Vorschläge — Accept committet ins Sprint-Backlog.', + acceptLabel: 'In Sprint planen', + scopeTypes: ['backlog_item'], + }, + gate_next_actions: { + title: 'Gate-Vorschläge', + lead: 'Planning Debt und Eingang am aktiven Gate — committen oder AP planen.', + acceptLabel: 'Committen', + scopeTypes: ['backlog_item'], + }, + intake_triage: { + title: 'Triage-Vorschläge', + lead: 'Priorisierte Eingangs-Reihenfolge — Heuristik oder später Agent-Ranker.', + acceptLabel: 'Committen', + scopeTypes: ['backlog_item'], + }, +} + +export function listProposalKeys(proposalsByKey = {}) { + return Object.keys(PROPOSAL_UI_CONFIG).filter( + (key) => Array.isArray(proposalsByKey[key]) && proposalsByKey[key].length > 0, + ) +} + +export function proposalReasonLabel(proposal) { + const labels = { + priority_critical: 'Dringlichkeit critical', + priority_high: 'Hohe Priorität', + candidate_bug_or_issue: 'Kandidat (Heuristik)', + candidate_ready: 'Commit-bereit (Heuristik)', + waiting_on_feature_action: 'Wartet auf Feature-AP', + missing_parent_action: 'Feature-Referenz fehlt', + dependency_blocked: 'Abhängigkeit offen', + gate_backlog_ready: 'Gate-Eingang committen', + planning_debt: 'Planning Debt — Plan ergänzen', + intake_triage_candidate: 'Triage-Kandidat', + } + return labels[proposal?.reason_code] || proposal?.summary || 'Vorschlag' +} + +export function filterSprintProposals(proposals, workCycleId) { + if (!workCycleId) return proposals || [] + return (proposals || []).filter( + (p) => !p.work_cycle_id || String(p.work_cycle_id) === String(workCycleId), + ) +}