diff --git a/backend/data_layer/initiative_snapshot.py b/backend/data_layer/initiative_snapshot.py index 3035c0f..c722555 100644 --- a/backend/data_layer/initiative_snapshot.py +++ b/backend/data_layer/initiative_snapshot.py @@ -125,7 +125,8 @@ def get_initiative_steering_snapshot( cur.execute( """ - SELECT id, title, status, priority, converted_action_id, created_at, updated_at + SELECT id, title, status, priority, converted_action_id, item_kind, + parent_backlog_id, created_at, updated_at FROM backlog_items WHERE tenant_id = %s AND initiative_id = %s ORDER BY updated_at DESC @@ -297,6 +298,31 @@ def get_initiative_steering_snapshot( next_actions = evaluation.next_work kernel_attention = evaluation.attention + from services.operating_context import get_operating_context + from steering.read_models.epic_rollup import compute_epic_rollup + + operating = get_operating_context( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ) + epic_rollup: list[dict[str, Any]] = [] + if operating.get("backlog_vocabulary", {}).get("capabilities", {}).get( + "epic_hierarchy" + ): + epic_rollup = compute_epic_rollup( + backlog_items=[ + { + "id": _sid(b["id"]), + "title": b["title"], + "status": b["status"], + "item_kind": b.get("item_kind"), + "parent_backlog_id": _sid(b.get("parent_backlog_id")), + "converted_action_id": _sid(b.get("converted_action_id")), + } + for b in backlog + ], + actions=actions_raw, + ) + steering = get_steering_context_dto(ctx, initiative_id=initiative_id) metadata = steering.get("lifecycle_metadata") or {} if isinstance(metadata, str): @@ -355,6 +381,7 @@ def get_initiative_steering_snapshot( "upcoming_roadmap_items": upcoming_milestones, "next_actions": next_actions, "active_work_cycle": active_work_cycle, + "epic_rollup": epic_rollup, "counts": { "actions_open": open_actions, "actions_blocked": blocked_actions, @@ -384,6 +411,8 @@ def get_initiative_steering_snapshot( "id": _sid(b["id"]), "title": b["title"], "status": b["status"], + "item_kind": b.get("item_kind"), + "parent_backlog_id": _sid(b.get("parent_backlog_id")), "converted_action_id": _sid(b.get("converted_action_id")), } for b in backlog diff --git a/backend/steering/kernel/attention.py b/backend/steering/kernel/attention.py index d1c2c96..de8a30e 100644 --- a/backend/steering/kernel/attention.py +++ b/backend/steering/kernel/attention.py @@ -129,6 +129,27 @@ def evaluate_attention( ) ) + from services.operating_context import get_operating_context + from services import backlog as backlog_service + from steering.read_models.epic_rollup import ( + compute_epic_rollup, + epic_rollup_attention_items, + ) + + operating = get_operating_context( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ) + if operating.get("backlog_vocabulary", {}).get("capabilities", {}).get( + "epic_hierarchy" + ): + backlog_items = backlog_service.list_backlog_for_initiative( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ) + rollups = compute_epic_rollup(backlog_items=backlog_items, actions=actions) + items.extend( + epic_rollup_attention_items(initiative_id=initiative_id, rollups=rollups) + ) + if ( not next_work and binding.primary_method_key in _GATE_METHODS diff --git a/backend/steering/read_models/__init__.py b/backend/steering/read_models/__init__.py new file mode 100644 index 0000000..d629726 --- /dev/null +++ b/backend/steering/read_models/__init__.py @@ -0,0 +1 @@ +"""Steering read models — derived views, no OM mutation.""" diff --git a/backend/steering/read_models/epic_rollup.py b/backend/steering/read_models/epic_rollup.py new file mode 100644 index 0000000..306451f --- /dev/null +++ b/backend/steering/read_models/epic_rollup.py @@ -0,0 +1,170 @@ +"""Epic roll-up read model — ADP P4 / Backlog-Hierarchie. + +Fortschritt eines Epics = Aggregation committeter/erledigter Stories im Subbaum. +Kein Gate-Status; Leading bleibt Action. +""" + +from __future__ import annotations + +from typing import Any, Literal + +EpicRollupStatus = Literal["empty", "intake_only", "in_progress", "done"] + +_DONE_ACTION = frozenset({"done"}) +_ACTIVE_ACTION = frozenset({"open", "ready", "in_progress", "blocked", "review_required"}) +_CHILD_EXCLUDE = frozenset({"rejected"}) + + +def _action_status(actions_by_id: dict[str, dict[str, Any]], action_id: Any) -> str | None: + if not action_id: + return None + row = actions_by_id.get(str(action_id)) + return row.get("status") if row else None + + +def compute_epic_rollup( + *, + backlog_items: list[dict[str, Any]], + actions: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Roll-up pro Epic-BacklogItem (item_kind=epic, nicht rejected).""" + actions_by_id = {str(a["id"]): a for a in actions if a.get("id")} + epics = [ + b + for b in backlog_items + if b.get("item_kind") == "epic" and b.get("status") not in _CHILD_EXCLUDE + ] + + rollups: list[dict[str, Any]] = [] + for epic in epics: + epic_id = str(epic["id"]) + children = [ + b + for b in backlog_items + if b.get("parent_backlog_id") + and str(b["parent_backlog_id"]) == epic_id + and b.get("item_kind") != "epic" + and b.get("status") not in _CHILD_EXCLUDE + ] + + child_total = len(children) + committed: list[dict[str, Any]] = [] + uncommitted: list[dict[str, Any]] = [] + for child in children: + if child.get("status") == "converted" or child.get("converted_action_id"): + committed.append(child) + else: + uncommitted.append(child) + + actions_done = actions_in_progress = actions_open = 0 + for child in committed: + status = _action_status(actions_by_id, child.get("converted_action_id")) + if status in _DONE_ACTION: + actions_done += 1 + elif status in _ACTIVE_ACTION: + actions_in_progress += 1 + actions_open += 1 + elif status: + actions_open += 1 + + if child_total == 0: + rollup_status: EpicRollupStatus = "empty" + elif len(committed) == 0: + rollup_status = "intake_only" + elif actions_done == child_total: + rollup_status = "done" + else: + rollup_status = "in_progress" + + completion_ratio = actions_done / child_total if child_total else None + commit_ratio = len(committed) / child_total if child_total else None + + rollups.append( + { + "epic_backlog_id": epic_id, + "epic_title": epic.get("title") or "Epic", + "child_total": child_total, + "child_uncommitted": len(uncommitted), + "child_committed": len(committed), + "actions_done": actions_done, + "actions_in_progress": actions_in_progress, + "actions_open": actions_open, + "completion_ratio": completion_ratio, + "commit_ratio": commit_ratio, + "status": rollup_status, + "data_source": "epic_rollup_v0.1", + } + ) + + rollups.sort(key=lambda r: (r["status"] != "in_progress", r["epic_title"].lower())) + return rollups + + +def epic_rollup_attention_items( + *, + initiative_id: str, + rollups: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Attention aus Epic-Roll-up — Steuerung, nicht nur Anzeige.""" + items: list[dict[str, Any]] = [] + + for rollup in rollups: + epic_id = rollup["epic_backlog_id"] + title = rollup["epic_title"] + child_total = rollup["child_total"] + if child_total == 0: + continue + + if rollup["child_committed"] == 0: + items.append( + { + "kind": "epic_awaiting_commit", + "severity": "info", + "title": title, + "summary": ( + f"Epic hat {rollup['child_uncommitted']} Item(s) im Eingang — " + "noch keine committete Arbeit" + ), + "scope_type": "backlog", + "scope_id": epic_id, + "initiative_id": initiative_id, + "reason_code": "epic_awaiting_commit", + "data_source": "epic_rollup", + } + ) + continue + + if rollup["child_uncommitted"] > 0 and rollup["actions_in_progress"] > 0: + items.append( + { + "kind": "epic_uncommitted_during_execution", + "severity": "warning", + "title": title, + "summary": ( + f"{rollup['child_uncommitted']} Item(s) noch im Eingang, " + f"während {rollup['actions_in_progress']} Arbeitspaket(e) laufen" + ), + "scope_type": "backlog", + "scope_id": epic_id, + "initiative_id": initiative_id, + "reason_code": "epic_uncommitted_during_execution", + "data_source": "epic_rollup", + } + ) + + if rollup["status"] == "done": + items.append( + { + "kind": "epic_complete", + "severity": "info", + "title": title, + "summary": "Alle Stories im Epic erledigt", + "scope_type": "backlog", + "scope_id": epic_id, + "initiative_id": initiative_id, + "reason_code": "epic_complete", + "data_source": "epic_rollup", + } + ) + + return items diff --git a/backend/tests/test_ap22h_epic_rollup.py b/backend/tests/test_ap22h_epic_rollup.py new file mode 100644 index 0000000..b15916b --- /dev/null +++ b/backend/tests/test_ap22h_epic_rollup.py @@ -0,0 +1,141 @@ +"""AP2.2h — Epic Roll-up Read Model (ADP P4).""" + +from __future__ import annotations + +from tests.factories import provision_user_in_tenant +from tests.test_initiatives_actions import _auth, _create_initiative, _login + + +def _create_product_initiative(client, token, title="Epic Rollup"): + created = _create_initiative( + client, + token, + title=title, + archetype_key="initiative.product", + ) + assert created.status_code == 201 + return created.json()["id"] + + +def test_steering_snapshot_includes_epic_rollup(client): + user = provision_user_in_tenant(tenant_role="admin") + token = _login(client, user) + initiative_id = _create_product_initiative(client, token) + + epic = client.post( + f"/api/initiatives/{initiative_id}/backlog", + json={"title": "Checkout Epic", "item_kind": "epic", "status": "accepted"}, + headers=_auth(token), + ) + epic_id = epic.json()["id"] + + story_a = client.post( + f"/api/initiatives/{initiative_id}/backlog", + json={ + "title": "Login Story", + "item_kind": "story", + "parent_backlog_id": epic_id, + "status": "accepted", + }, + headers=_auth(token), + ) + story_a_id = story_a.json()["id"] + + client.post( + f"/api/initiatives/{initiative_id}/backlog", + json={ + "title": "Payment Story", + "item_kind": "story", + "parent_backlog_id": epic_id, + "status": "accepted", + }, + headers=_auth(token), + ) + + converted = client.post( + f"/api/backlog/{story_a_id}/convert-to-action", + json={}, + headers=_auth(token), + ) + assert converted.status_code == 201 + action_id = converted.json()["action"]["id"] + + done = client.patch( + f"/api/actions/{action_id}", + json={"status": "done"}, + headers=_auth(token), + ) + assert done.status_code == 200 + + snapshot = client.get( + f"/api/initiatives/{initiative_id}/steering-snapshot", + headers=_auth(token), + ) + assert snapshot.status_code == 200 + body = snapshot.json() + assert "epic_rollup" in body + assert len(body["epic_rollup"]) == 1 + + rollup = body["epic_rollup"][0] + assert rollup["epic_backlog_id"] == epic_id + assert rollup["child_total"] == 2 + assert rollup["child_uncommitted"] == 1 + assert rollup["child_committed"] == 1 + assert rollup["actions_done"] == 1 + assert rollup["status"] == "in_progress" + assert rollup["completion_ratio"] == 0.5 + + +def test_steering_snapshot_epic_attention_awaiting_commit(client): + user = provision_user_in_tenant(tenant_role="admin") + token = _login(client, user) + initiative_id = _create_product_initiative(client, token, title="Epic Attention") + + epic = client.post( + f"/api/initiatives/{initiative_id}/backlog", + json={"title": "Epic", "item_kind": "epic", "status": "accepted"}, + headers=_auth(token), + ) + epic_id = epic.json()["id"] + + client.post( + f"/api/initiatives/{initiative_id}/backlog", + json={ + "title": "Story", + "item_kind": "story", + "parent_backlog_id": epic_id, + "status": "accepted", + }, + headers=_auth(token), + ) + + snapshot = client.get( + f"/api/initiatives/{initiative_id}/steering-snapshot", + headers=_auth(token), + ) + assert snapshot.status_code == 200 + attention_codes = [ + item.get("code") or item.get("reason_code") + for item in snapshot.json().get("attention_items", []) + ] + assert "epic_awaiting_commit" in attention_codes + + +def test_linear_initiative_has_no_epic_rollup(client): + user = provision_user_in_tenant(tenant_role="admin") + token = _login(client, user) + + created = _create_initiative( + client, + token, + title="Linear Rollup", + archetype_key="initiative.linear_project", + ) + initiative_id = created.json()["id"] + + snapshot = client.get( + f"/api/initiatives/{initiative_id}/steering-snapshot", + headers=_auth(token), + ) + assert snapshot.status_code == 200 + assert snapshot.json().get("epic_rollup") == [] diff --git a/backend/tests/test_epic_rollup_unit.py b/backend/tests/test_epic_rollup_unit.py new file mode 100644 index 0000000..cce3899 --- /dev/null +++ b/backend/tests/test_epic_rollup_unit.py @@ -0,0 +1,125 @@ +"""Unit tests — Epic roll-up read model (P4).""" + +from __future__ import annotations + +from steering.read_models.epic_rollup import ( + compute_epic_rollup, + epic_rollup_attention_items, +) + + +def _item( + *, + id: str, + title: str, + item_kind: str = "story", + status: str = "accepted", + parent_backlog_id: str | None = None, + converted_action_id: str | None = None, +) -> dict: + return { + "id": id, + "title": title, + "item_kind": item_kind, + "status": status, + "parent_backlog_id": parent_backlog_id, + "converted_action_id": converted_action_id, + } + + +def test_epic_rollup_empty_epic(): + rollups = compute_epic_rollup( + backlog_items=[_item(id="e1", title="Epic", item_kind="epic")], + actions=[], + ) + assert len(rollups) == 1 + assert rollups[0]["status"] == "empty" + assert rollups[0]["child_total"] == 0 + + +def test_epic_rollup_intake_only(): + rollups = compute_epic_rollup( + backlog_items=[ + _item(id="e1", title="Checkout", item_kind="epic"), + _item(id="s1", title="Login", parent_backlog_id="e1"), + _item(id="s2", title="Pay", parent_backlog_id="e1"), + ], + actions=[], + ) + assert rollups[0]["status"] == "intake_only" + assert rollups[0]["child_total"] == 2 + assert rollups[0]["child_uncommitted"] == 2 + assert rollups[0]["completion_ratio"] == 0.0 + + +def test_epic_rollup_partial_and_done(): + rollups = compute_epic_rollup( + backlog_items=[ + _item(id="e1", title="Epic", item_kind="epic"), + _item( + id="s1", + title="Done story", + parent_backlog_id="e1", + status="converted", + converted_action_id="a1", + ), + _item(id="s2", title="Open story", parent_backlog_id="e1"), + ], + actions=[ + {"id": "a1", "status": "done"}, + {"id": "a2", "status": "in_progress"}, + ], + ) + assert rollups[0]["status"] == "in_progress" + assert rollups[0]["actions_done"] == 1 + assert rollups[0]["child_uncommitted"] == 1 + assert rollups[0]["completion_ratio"] == 0.5 + + +def test_epic_rollup_all_done(): + rollups = compute_epic_rollup( + backlog_items=[ + _item(id="e1", title="Epic", item_kind="epic"), + _item( + id="s1", + title="A", + parent_backlog_id="e1", + status="converted", + converted_action_id="a1", + ), + ], + actions=[{"id": "a1", "status": "done"}], + ) + assert rollups[0]["status"] == "done" + assert rollups[0]["completion_ratio"] == 1.0 + + +def test_epic_rollup_attention_awaiting_commit(): + rollups = compute_epic_rollup( + backlog_items=[ + _item(id="e1", title="Epic", item_kind="epic"), + _item(id="s1", title="Story", parent_backlog_id="e1"), + ], + actions=[], + ) + items = epic_rollup_attention_items(initiative_id="init-1", rollups=rollups) + assert any(i["reason_code"] == "epic_awaiting_commit" for i in items) + + +def test_epic_rollup_attention_split_focus(): + rollups = compute_epic_rollup( + backlog_items=[ + _item(id="e1", title="Epic", item_kind="epic"), + _item( + id="s1", + title="Running", + parent_backlog_id="e1", + status="converted", + converted_action_id="a1", + ), + _item(id="s2", title="Waiting", parent_backlog_id="e1"), + ], + actions=[{"id": "a1", "status": "in_progress"}], + ) + items = epic_rollup_attention_items(initiative_id="init-1", rollups=rollups) + assert any(i["reason_code"] == "epic_uncommitted_during_execution" for i in items) diff --git a/docs/architecture/ADP_Agile_Steering_Program_v0.1.md b/docs/architecture/ADP_Agile_Steering_Program_v0.1.md new file mode 100644 index 0000000..de26170 --- /dev/null +++ b/docs/architecture/ADP_Agile_Steering_Program_v0.1.md @@ -0,0 +1,98 @@ +# ADP — Agile Steering Program (jenseits P4) v0.1 + +**Status:** PO-Freigabe (Planung — nicht implementiert) +**Stand:** 2026-07-27 +**Bezug:** ADP Backlog/Epic P1–P4, SPEC-D `agile_iteration`, Steering Kernel Spine, Vision v0.2 (Program Director) + +--- + +## 1. Ausgangslage + +Mit **P4 (Epic Roll-up)** ist der **Backlog/Epic-Track** des Agile-Intake abgeschlossen: + +- Dual Commit Path (Direct AP + Intake) +- Epic-Baum, Profil-Vokabular, Roll-up + Attention + +Das reicht für **Dokumentation und Nachverfolgung**, aber noch nicht für **vorausschauendes Dirigieren** im Sinne der Product Vision: Kairo soll priorisieren, planen und steuern — nicht nur CRUD und Status anzeigen. + +--- + +## 2. Lücke — „Dirigieren“ vs. „Dokumentieren“ + +| Fähigkeit | Ist (nach P4) | Ziel (Program Director) | +|-----------|---------------|-------------------------| +| Epic-Fortschritt | Roll-up aus committeten Actions | ✓ | +| Sprint-Commit | Manuell aus Eingang | Vorschlag nächster Sprint-Inhalt | +| Priorisierung | Manuell (Priority-Feld) | Kernel-Vorschlag (Stories, Bugs, Debt) | +| Architekturschuld | Nicht modelliert | Führen, abbauen, Attention | +| Guardrail-/Architektur-Reviews | Review-Entität vorhanden | Regelmäßig, automatisiert, KI-unterstützt | +| Agent-Steuerung | Leading = Action | Task-Baum + Operating Context (P5) | + +**Fazit:** Agile **Intake/Hierarchie** ist fertig; Agile **Steuerungsprogramm** (Planung, Debt, Reviews) ist ein **eigenes Programm** — nicht in P4 mischen. + +--- + +## 3. Entscheidung — Phasen P5–P8 (Agile Steering Program) + +| Phase | Inhalt | Schicht | Abhängigkeit | +|-------|--------|---------|--------------| +| **P5** | Agent-Task-Baum unter Action | Ist / Recursive Tasks | ADP Recursive (AP1.5d) | +| **P6** | **Sprint-Vorschlag** — Read Model: committbare Items + Prioritätsranking | Kernel + Snapshot | P4, work_cycles | +| **P7** | **Tech-/Architekturschuld** — Backlog-Typ oder Tag + Attention-Regeln + Abbau-Ziele | Plan + Kernel | Evidence, Review | +| **P8** | **Automatisierte Reviews** — Guardrails, Architektur, Zielerreichung (Actor + optional KI) | Recurring + Review + Agent-Slot | Principle Gate, MCP-Freigabe | + +Kein Phase-Sprung. **KI/Prompt/MCP** für P8 erst nach Principle Gate und stabilem Read-Model-Kern (vgl. Architecture Rules — eingefroren bis OM trägt). + +--- + +## 4. P6 — Sprint-Vorschlag (Skizze) + +**Read Model** `sprint_planning_proposal` (kein Pflicht-Commit): + +- Input: offene Backlog-Items (Epic-Subbaum optional), aktiver/geplanter Sprint, Action-Ist, Blocker +- Output: ranked Liste `{ backlog_item_id, reason_code, score_hint }` +- Attention wenn: Sprint startet in N Tagen und Commit leer; Bugs ohne Owner; Epic intake_only trotz aktivem Sprint + +**UI:** Plan → Sprint — „Vorgeschlagene Items“ (Accept/Adjust), nicht Auto-Commit. + +**Steuerung:** `evaluate_next_work` / eigene Strategy-Hook — **keine** Page-Ifs. + +--- + +## 5. P7 — Architekturschuld (Skizze) + +| Option | Beschreibung | Empfehlung | +|--------|--------------|------------| +| A | `item_kind=tech_debt` im Backlog | Profilgebunden, Commit wie Story | +| B | `action_kind=tech_debt` Direct AP | Continuous-Pfad | +| C | Review + Evidence verknüpft mit Gate | Für Guardrail-Reviews | + +**Empfehlung:** A + B (Dual Path analog Features/Bugs) + Attention `architecture_debt_stale` wenn Debt älter als Schwellwert ohne Commit. + +**Abbau:** nicht als Gate — als committete Actions mit Review-Evidence (Guardrail-Checkliste). + +--- + +## 6. P8 — Automatisierte Reviews (Skizze) + +- **RecurringElement** pro Initiative: „Architektur-Guardrail-Review“, „Sprint-Retro“, „Ziel-Check“ +- **Review**-Entität mit Checkliste (config, nicht hardcoded in UI) +- **Actor-Slot:** Agent führt Review aus → Evidence + Decision-Vorschlag +- **KI:** nur über auditierten Agent-Actor; Kontext aus Snapshot/Operating Context — nicht Prompt-Drift + +--- + +## 7. Abgrenzung zu P4 + +P4 liefert **Ist-Fortschritt auf Epic-Ebene**. P6–P8 liefern **Vorausschau und Korrektur** — eigenes ADP-Implementierungsprogramm, gleicher Kernel-Einstieg (`evaluate_steering`). + +--- + +## 8. Referenzen + +| Dokument | Pfad | +|----------|------| +| ADP Backlog/Epic | `docs/architecture/ADP_Backlog_Hierarchy_and_Dual_Commit_Path_v0.1.md` | +| SPEC agile_iteration | `docs/architecture/methods/SPEC_D_agile_iteration_v0.1.md` | +| Recursive Tasks | AP1.5d / ADP Recursive Containers | +| Implementation Truth Table | `docs/product/Kairo_Implementation_Truth_Table_v0.1.md` | diff --git a/docs/architecture/ADP_Backlog_Hierarchy_and_Dual_Commit_Path_v0.1.md b/docs/architecture/ADP_Backlog_Hierarchy_and_Dual_Commit_Path_v0.1.md index fe63a3b..7f6f202 100644 --- a/docs/architecture/ADP_Backlog_Hierarchy_and_Dual_Commit_Path_v0.1.md +++ b/docs/architecture/ADP_Backlog_Hierarchy_and_Dual_Commit_Path_v0.1.md @@ -164,7 +164,7 @@ Product `planOutlineKeys` (Ziel): `profile`, `inbox`, `work`, `sprint`, `gates` | **P1 — Direct AP** | Plan → Arbeit im Product-Profil; Product-Labels | UI only | | **P2 — Backlog-Baum** | `parent_backlog_id`, `item_kind=epic` | Migration | | **P3 — Profil-Vokabular** | Operating Context liefert erlaubte `backlog_item_kinds` | AP2.4 Erweiterung | -| **P4 — Epic Roll-up** | Read Model Fortschritt / Attention | Steering Kernel | +| **P4 — Epic Roll-up** | Read Model Fortschritt / Attention | Steering Kernel | ✓ AP2.2h | | **P5 — Agent-Tasks** | Task-Baum unter AP (ADP Recursive) | AP1.5d | Kein Phase-Sprung ohne ADP/Spec-Referenz. diff --git a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md index be49e1d..f6fc943 100644 --- a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md +++ b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md @@ -49,7 +49,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden. | `actions.sort_order` | ◐ | Migration 023 | | BacklogItem | ✓ | Gate-Zuordnung AP1.6, `sort_order` | | BacklogItem kinds (MVP) | ◐ | `story`/`bug`/`issue` AP2.2e — global, nicht profilgebunden | -| Backlog Epic-Hierarchie | ◐ | AP2.2f: `epic` + `parent_backlog_id`, Baum-UI, Convert-Guard — Roll-up (P4) offen | +| Backlog Epic-Hierarchie | ✓ | AP2.2f/h: Epic-Baum + P4 Roll-up Read Model (`epic_rollup`, Attention) | | Dual Commit (Direct AP) | ◐ | ADP P1: Plan→Arbeit im Product-Outline (2026-07-26) | | Profil-Vokabular Backlog | ◐ | AP2.2g: `backlog_vocabulary` + `backlog_epic_hierarchy` via effektive `steering_elements` | | Blocker | ✓ | `action_id` optional | @@ -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 Direct AP · P2 Epic-Baum · P3 Profil-Vokabular · P4 Roll-up (2026-07-26 Doku) | +| ADP Backlog/Epic | P1–P4 ✓ (Roll-up AP2.2h) · P5 Agent-Tasks offen | | AP2.2b–e | Referenz-Archetypen End-to-End ◐→✓ | | AP2.1 | MVP-Abnahfe ✗→✓ | | AP1.7b | Op-API Parität | diff --git a/frontend/src/components/BacklogSection.jsx b/frontend/src/components/BacklogSection.jsx index 969a4d6..548679e 100644 --- a/frontend/src/components/BacklogSection.jsx +++ b/frontend/src/components/BacklogSection.jsx @@ -14,6 +14,11 @@ import { useInitiativeOperations } from '../context/InitiativeOperationsContext. import { hasSteeringElement } from '../registry/steeringElementRegistry.js' import { BacklogIntakeGuide } from './BacklogIntakeGuide.jsx' import { actionTitleById } from '../utils/actionReferences.js' +import { + epicRollupById, + epicRollupProgressPercent, + formatEpicRollupSummary, +} from '../utils/epicRollup.js' const PLANNING_STATUSES = new Set(['planned', 'active', 'at_risk']) /** Backend erlaubt Convert für diese Status — UI war nur bei „Freigegeben“ sichtbar. */ @@ -38,7 +43,7 @@ export function BacklogSection({ sectionTitle = 'Product Backlog', sectionLead = 'Eingang vor dem Commit — triagieren, dann in den Sprint planen.', }) { - const { operatingContext, steeringElements } = useInitiativeOperations() + const { operatingContext, steeringElements, steeringSnapshot } = useInitiativeOperations() const [modalMode, setModalMode] = useState(null) const [dragItemId, setDragItemId] = useState('') const [dropTargetId, setDropTargetId] = useState('') @@ -107,6 +112,12 @@ export function BacklogSection({ return map }, [epicItems]) + const rollupByEpicId = useMemo( + () => epicRollupById(steeringSnapshot?.epic_rollup), + [steeringSnapshot?.epic_rollup], + ) + const showEpicRollup = Boolean(vocabulary.capabilities?.epic_hierarchy) + const defaultKind = vocabulary.default_kind || 'story' const hasConvertedItems = items.some((item) => item.status === 'converted') const directWorkEnabled = Boolean( @@ -326,6 +337,7 @@ export function BacklogSection({ const isDropTarget = dropTargetId === item.id && dragItemId && dragItemId !== item.id const reorderable = canReorder && item.status !== 'converted' const isEpic = item.item_kind === 'epic' + const epicRollup = isEpic && showEpicRollup ? rollupByEpicId[item.id] : null const flatIndex = sortedItems.findIndex((row) => row.id === item.id) return ( @@ -376,6 +388,24 @@ export function BacklogSection({ {item.description && (

{item.description}

)} + {epicRollup && ( +
+
+ +
+

{formatEpicRollupSummary(epicRollup)}

+
+ )} {item.parent_backlog_id && (

Epic: {epicTitleById.get(item.parent_backlog_id) || '…'} diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index a1dec12..88824e9 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -1678,6 +1678,27 @@ opacity: 0.75; } +.epic-rollup-summary { + margin-top: 0.35rem; + max-width: 28rem; +} + +.epic-rollup-summary__bar { + height: 0.35rem; + border-radius: 999px; + background: color-mix(in srgb, var(--jk-primary) 10%, var(--jk-surface)); + overflow: hidden; + margin-bottom: 0.25rem; +} + +.epic-rollup-summary__fill { + display: block; + height: 100%; + border-radius: inherit; + background: var(--jk-primary); + transition: width 0.2s ease; +} + .archetype-badge { background: var(--jk-surface-muted, #eef2f6); color: var(--jk-text-secondary, #445); diff --git a/frontend/src/utils/epicRollup.js b/frontend/src/utils/epicRollup.js new file mode 100644 index 0000000..60871fd --- /dev/null +++ b/frontend/src/utils/epicRollup.js @@ -0,0 +1,35 @@ +/** Epic roll-up labels for Backlog UI (P4). */ + +export function epicRollupById(epicRollup = []) { + return Object.fromEntries( + (epicRollup || []).map((row) => [row.epic_backlog_id, row]), + ) +} + +export function formatEpicRollupSummary(rollup) { + if (!rollup || rollup.child_total === 0) { + return 'Noch keine Stories unter diesem Epic' + } + const done = rollup.actions_done ?? 0 + const total = rollup.child_total + const uncommitted = rollup.child_uncommitted ?? 0 + if (rollup.status === 'done') { + return `${done}/${total} erledigt — Epic abgeschlossen` + } + if (rollup.status === 'intake_only') { + return `${total} Item(s) im Eingang — noch nicht committet` + } + let text = `${done}/${total} erledigt` + if (uncommitted > 0) { + text += `, ${uncommitted} noch im Eingang` + } + if (rollup.actions_in_progress > 0) { + text += `, ${rollup.actions_in_progress} in Arbeit` + } + return text +} + +export function epicRollupProgressPercent(rollup) { + if (!rollup?.child_total) return 0 + return Math.round((rollup.completion_ratio ?? 0) * 100) +} diff --git a/frontend/src/utils/epicRollup.test.js b/frontend/src/utils/epicRollup.test.js new file mode 100644 index 0000000..63a86dc --- /dev/null +++ b/frontend/src/utils/epicRollup.test.js @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import { + epicRollupById, + epicRollupProgressPercent, + formatEpicRollupSummary, +} from './epicRollup.js' + +describe('epicRollup', () => { + const rollup = { + epic_backlog_id: 'e1', + child_total: 4, + child_uncommitted: 1, + actions_done: 2, + actions_in_progress: 1, + completion_ratio: 0.5, + status: 'in_progress', + } + + it('maps rollup by epic id', () => { + const map = epicRollupById([rollup]) + expect(map.e1).toEqual(rollup) + }) + + it('formats in-progress summary', () => { + expect(formatEpicRollupSummary(rollup)).toContain('2/4 erledigt') + expect(formatEpicRollupSummary(rollup)).toContain('1 noch im Eingang') + }) + + it('computes progress percent', () => { + expect(epicRollupProgressPercent(rollup)).toBe(50) + }) +})