diff --git a/backend/services/backlog.py b/backend/services/backlog.py index 5d2f7b8..95f014b 100644 --- a/backend/services/backlog.py +++ b/backend/services/backlog.py @@ -10,6 +10,7 @@ from db import get_connection from services.actions import create_action from services.audit import log_audit from services.initiatives import PRIORITIES, get_initiative +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"] @@ -61,6 +62,28 @@ def _validate_item_kind(item_kind: str) -> None: raise ValueError(f"Ungültiger Backlog-Typ: {item_kind}") +def _backlog_vocabulary_for_initiative(*, tenant_id: str, initiative_id: str) -> dict[str, Any]: + context = get_operating_context(tenant_id=tenant_id, initiative_id=initiative_id) + if not context: + raise ValueError("Initiative nicht gefunden") + return context.get("backlog_vocabulary") or {} + + +def _validate_item_kind_for_initiative( + *, tenant_id: str, initiative_id: str, item_kind: str +) -> dict[str, Any]: + _validate_item_kind(item_kind) + vocabulary = _backlog_vocabulary_for_initiative( + tenant_id=tenant_id, initiative_id=initiative_id + ) + allowed = vocabulary.get("kinds") or [] + if allowed and item_kind not in allowed: + raise ValueError( + f"Backlog-Typ '{item_kind}' ist für dieses Vorhaben nicht erlaubt" + ) + return vocabulary + + def _item_kind_to_action_kind(item_kind: str) -> str: if item_kind == "bug": return "bug" @@ -103,7 +126,11 @@ def _validate_backlog_hierarchy( parent_backlog_id: Optional[str], parent_action_id: Optional[str], backlog_item_id: Optional[str] = None, + epic_hierarchy: bool = True, ) -> None: + if item_kind == "epic" and not epic_hierarchy: + raise ValueError("Epic ist für dieses Vorhaben nicht verfügbar") + if item_kind == "epic": if parent_backlog_id: raise ValueError("Epic kann keinem übergeordneten Backlog-Item zugeordnet werden") @@ -114,6 +141,9 @@ def _validate_backlog_hierarchy( if not parent_backlog_id: return + if not epic_hierarchy: + raise ValueError("Epic-Zuordnung ist für dieses Vorhaben nicht verfügbar") + if backlog_item_id and parent_backlog_id == backlog_item_id: raise ValueError("Item kann nicht sich selbst als Epic referenzieren") @@ -156,7 +186,10 @@ def create_backlog_item( raise ValueError("Titel ist erforderlich") _validate_status(status) _validate_priority(priority) - _validate_item_kind(item_kind) + vocabulary = _validate_item_kind_for_initiative( + tenant_id=tenant_id, initiative_id=initiative_id, item_kind=item_kind + ) + epic_hierarchy = bool((vocabulary.get("capabilities") or {}).get("epic_hierarchy")) if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): raise ValueError("Initiative nicht gefunden") @@ -182,6 +215,7 @@ def create_backlog_item( item_kind=item_kind, parent_backlog_id=parent_backlog_id, parent_action_id=parent_action_id, + epic_hierarchy=epic_hierarchy, ) cur.execute( """ @@ -336,6 +370,15 @@ def update_backlog_item( _validate_item_kind(item_kind) updates.append("item_kind = %s") params.append(item_kind) + + effective_kind = item_kind if item_kind is not None else existing.get("item_kind") or "story" + vocabulary = _validate_item_kind_for_initiative( + tenant_id=tenant_id, + initiative_id=existing["initiative_id"], + item_kind=effective_kind, + ) + epic_hierarchy = bool((vocabulary.get("capabilities") or {}).get("epic_hierarchy")) + if clear_parent_action: updates.append("parent_action_id = NULL") elif parent_action_id is not None: @@ -350,7 +393,6 @@ def update_backlog_item( if not updates: return existing - effective_kind = item_kind if item_kind is not None else existing.get("item_kind") or "story" effective_parent_backlog = ( None if clear_parent_backlog @@ -380,6 +422,7 @@ def update_backlog_item( parent_backlog_id=effective_parent_backlog, parent_action_id=effective_parent_action, backlog_item_id=backlog_item_id, + epic_hierarchy=epic_hierarchy, ) if parent_action_id is not None and not clear_parent_action: _validate_parent_action_in_initiative( diff --git a/backend/services/operating_context.py b/backend/services/operating_context.py index ede67fd..63494d6 100644 --- a/backend/services/operating_context.py +++ b/backend/services/operating_context.py @@ -15,6 +15,8 @@ from entity_archetypes.registry import ( from services.initiatives import get_initiative from services.steering_context import ensure_steering_context, get_steering_context from steering.graph.profiles import LIGHT, graph_profile_as_dict +from steering.backlog_vocabulary import resolve_backlog_vocabulary +from steering.effective_contract import resolve_effective_steering_elements from steering.methods.registry import ( get_method, list_compatible_methods, @@ -169,6 +171,25 @@ def get_operating_context( method_key=method_key, ) method_contract = _resolve_method_contract(method_key) + active_composition_modifier = ( + "agile_iteration" + if _resolve_active_agile_composition( + tenant_id=tenant_id, + initiative_id=initiative_id, + primary_method_key=method_key, + ) + else None + ) + effective_steering_elements = resolve_effective_steering_elements( + method_key, + data_slices=data_slices, + active_composition_modifier=active_composition_modifier, + ) + backlog_vocabulary = resolve_backlog_vocabulary( + data_slices=data_slices, + method_key=method_key, + steering_elements=effective_steering_elements, + ) return { "initiative_id": initiative_id, @@ -179,7 +200,8 @@ def get_operating_context( "om_capabilities": sorted(om_capabilities), "data_slices": data_slices, "method_capabilities": _method_capabilities(method_key), - "steering_elements": method_contract["steering_elements"], + "steering_elements": sorted(effective_steering_elements), + "primary_steering_elements": method_contract["steering_elements"], "ui_features": method_contract["ui_features"], "graph_profile": method_contract["graph_profile"], "compatible_methods": _compatible_methods_payload( @@ -191,15 +213,8 @@ def get_operating_context( method_to_dict(m, include_compatibility=False) for m in list_composable_modifiers(method_key) ], - "active_composition_modifier": ( - "agile_iteration" - if _resolve_active_agile_composition( - tenant_id=tenant_id, - initiative_id=initiative_id, - primary_method_key=method_key, - ) - else None - ), + "active_composition_modifier": active_composition_modifier, + "backlog_vocabulary": backlog_vocabulary, } diff --git a/backend/steering/backlog_vocabulary.py b/backend/steering/backlog_vocabulary.py new file mode 100644 index 0000000..567f561 --- /dev/null +++ b/backend/steering/backlog_vocabulary.py @@ -0,0 +1,95 @@ +"""Profilgebundenes Backlog-Vokabular — ADP P3 / AP2.4 Methoden-Vertrag.""" + +from __future__ import annotations + +from typing import Any + +_EMPTY: dict[str, Any] = { + "profile_key": "none", + "kinds": [], + "labels": {}, + "default_kind": "story", + "epic_hierarchy": False, + "convertible_kinds": [], + "capabilities": { + "sprint_commit": False, + "epic_hierarchy": False, + }, +} + +_AGILE_INTAKE: dict[str, Any] = { + "profile_key": "agile_intake", + "kinds": ["epic", "story", "bug", "issue"], + "labels": { + "epic": "Epic", + "story": "Story", + "bug": "Bug", + "issue": "Issue", + }, + "default_kind": "story", + "epic_hierarchy": True, + "convertible_kinds": ["story", "bug", "issue"], +} + +_CONTINUOUS_INTAKE: dict[str, Any] = { + "profile_key": "continuous_intake", + "kinds": ["story", "bug", "issue"], + "labels": { + "story": "Idee", + "bug": "Bug", + "issue": "Störung", + }, + "default_kind": "story", + "epic_hierarchy": False, + "convertible_kinds": ["story", "bug", "issue"], +} + +_STANDARD_INTAKE: dict[str, Any] = { + "profile_key": "standard_intake", + "kinds": ["story", "bug", "issue"], + "labels": { + "story": "Story", + "bug": "Bug", + "issue": "Issue", + }, + "default_kind": "story", + "epic_hierarchy": False, + "convertible_kinds": ["story", "bug", "issue"], +} + + +def _with_capabilities( + vocabulary: dict[str, Any], + *, + steering_elements: frozenset[str], +) -> dict[str, Any]: + result = dict(vocabulary) + sprint_commit = "work_cycle_scope" in steering_elements + epic_hierarchy = "backlog_epic_hierarchy" in steering_elements + result["epic_hierarchy"] = epic_hierarchy + result["capabilities"] = { + "sprint_commit": sprint_commit, + "epic_hierarchy": epic_hierarchy, + } + return result + + +def resolve_backlog_vocabulary( + *, + data_slices: list[str], + method_key: str, + steering_elements: frozenset[str], +) -> dict[str, Any]: + """Backlog-Typen/Labels aus effektivem Methoden-Vertrag — nicht aus Page-Ifs.""" + if "backlog" not in data_slices: + return dict(_EMPTY) + + if "backlog_epic_hierarchy" in steering_elements: + return _with_capabilities(dict(_AGILE_INTAKE), steering_elements=steering_elements) + + if method_key == "continuous_product": + return _with_capabilities( + dict(_CONTINUOUS_INTAKE), steering_elements=steering_elements + ) + + return _with_capabilities(dict(_STANDARD_INTAKE), steering_elements=steering_elements) diff --git a/backend/steering/effective_contract.py b/backend/steering/effective_contract.py new file mode 100644 index 0000000..4ccc84b --- /dev/null +++ b/backend/steering/effective_contract.py @@ -0,0 +1,36 @@ +"""Effective method contract — primary + composition modifier (AP2.4).""" + +from __future__ import annotations + +from steering.methods.registry import get_method + +PLAN_PHASE_MODIFIER_KEY = "agile_iteration" + + +def resolve_effective_steering_elements( + primary_method_key: str, + *, + data_slices: list[str], + active_composition_modifier: str | None, +) -> frozenset[str]: + """ + Primärmethode + aktiver Modifier; Plan-Phase: agile_iteration wenn work_cycles + Slice aktiv und Methode komponierbar (Epic-Planung vor aktivem Sprint). + """ + primary = get_method(primary_method_key) + elements: set[str] = set(primary.steering_elements if primary else []) + + modifier_keys: list[str] = [] + if active_composition_modifier: + modifier_keys.append(active_composition_modifier) + elif "work_cycles" in data_slices: + agile = get_method(PLAN_PHASE_MODIFIER_KEY) + if agile and primary_method_key in agile.composes_with: + modifier_keys.append(PLAN_PHASE_MODIFIER_KEY) + + for modifier_key in modifier_keys: + modifier = get_method(modifier_key) + if modifier: + elements.update(modifier.steering_elements) + + return frozenset(elements) diff --git a/backend/steering/elements/registry.py b/backend/steering/elements/registry.py index 58a0ab1..8e8add2 100644 --- a/backend/steering/elements/registry.py +++ b/backend/steering/elements/registry.py @@ -41,6 +41,12 @@ STEERING_ELEMENTS: dict[str, SteeringElementDefinition] = { description="Aktive work_cycle und Sprint-Backlog", modes=("work", "plan"), ), + "backlog_epic_hierarchy": SteeringElementDefinition( + key="backlog_epic_hierarchy", + label="Epic-Hierarchie", + description="Epic-Container und Story-Baum im Plan-Eingang (Agile-Modifier)", + modes=("plan",), + ), "recurring_rhythm": SteeringElementDefinition( key="recurring_rhythm", label="Rhythmen", diff --git a/backend/steering/methods/registrations/ap20_method_stubs.py b/backend/steering/methods/registrations/ap20_method_stubs.py index 88f073e..f681d1b 100644 --- a/backend/steering/methods/registrations/ap20_method_stubs.py +++ b/backend/steering/methods/registrations/ap20_method_stubs.py @@ -76,7 +76,8 @@ def register() -> None: "initiative.recurring_program", } ), - steering_elements=ELEM_NEXT | frozenset({"work_cycle_scope"}), + steering_elements=ELEM_NEXT + | frozenset({"work_cycle_scope", "backlog_epic_hierarchy"}), ui_features=frozenset({"steeringSnapshotOnWorkSprint"}), graph_profile=STRICT, method_role="modifier", diff --git a/backend/tests/test_ap22g_backlog_vocabulary.py b/backend/tests/test_ap22g_backlog_vocabulary.py new file mode 100644 index 0000000..886b167 --- /dev/null +++ b/backend/tests/test_ap22g_backlog_vocabulary.py @@ -0,0 +1,77 @@ +"""AP2.2g — Profilgebundenes Backlog-Vokabular (ADP P3).""" + +from __future__ import annotations + +from tests.factories import provision_user_in_tenant +from tests.test_initiatives_actions import _auth, _create_initiative, _login + + +def test_operating_context_product_has_agile_backlog_vocabulary(client): + user = provision_user_in_tenant(tenant_role="admin") + token = _login(client, user) + + created = _create_initiative( + client, + token, + title="Product Vocab", + archetype_key="initiative.product", + ) + initiative_id = created.json()["id"] + + res = client.get( + f"/api/initiatives/{initiative_id}/operating-context", + headers=_auth(token), + ) + assert res.status_code == 200 + vocab = res.json()["backlog_vocabulary"] + assert vocab["profile_key"] == "agile_intake" + assert "epic" in vocab["kinds"] + assert vocab["labels"]["story"] == "Story" + assert vocab["capabilities"]["epic_hierarchy"] is True + assert vocab["capabilities"]["sprint_commit"] is True + assert "backlog_epic_hierarchy" in res.json()["steering_elements"] + + +def test_operating_context_linear_has_standard_backlog_vocabulary(client): + user = provision_user_in_tenant(tenant_role="admin") + token = _login(client, user) + + created = _create_initiative( + client, + token, + title="Linear Vocab", + archetype_key="initiative.linear_project", + ) + initiative_id = created.json()["id"] + + res = client.get( + f"/api/initiatives/{initiative_id}/operating-context", + headers=_auth(token), + ) + vocab = res.json()["backlog_vocabulary"] + assert vocab["profile_key"] == "standard_intake" + assert vocab["kinds"] == ["story", "bug", "issue"] + assert "epic" not in vocab["kinds"] + assert vocab["capabilities"]["epic_hierarchy"] is False + assert "backlog_epic_hierarchy" not in res.json()["steering_elements"] + + +def test_linear_initiative_rejects_epic_backlog_item(client): + user = provision_user_in_tenant(tenant_role="admin") + token = _login(client, user) + + created = _create_initiative( + client, + token, + title="Linear Epic Block", + archetype_key="initiative.linear_project", + ) + initiative_id = created.json()["id"] + + epic = client.post( + f"/api/initiatives/{initiative_id}/backlog", + json={"title": "Not allowed", "item_kind": "epic", "status": "new"}, + headers=_auth(token), + ) + assert epic.status_code == 400 + assert "nicht erlaubt" in epic.json()["detail"] diff --git a/backend/tests/test_ap23a_operating_context.py b/backend/tests/test_ap23a_operating_context.py index 4b3c922..fa873a4 100644 --- a/backend/tests/test_ap23a_operating_context.py +++ b/backend/tests/test_ap23a_operating_context.py @@ -30,6 +30,7 @@ def test_operating_context_product(client): assert body["initiative_id"] == initiative_id assert body["archetype_key"] == "initiative.product" assert body["method_key"] == "continuous_product" + assert "backlog_epic_hierarchy" in body["steering_elements"] assert body["ui_profile"]["planDefaultRoute"] == "/plan/inbox" assert body["ui_profile"]["workDefaultRoute"] == "/work/sprint" assert "work_cycles" in body["data_slices"] diff --git a/backend/tests/test_ap24_steering_elements.py b/backend/tests/test_ap24_steering_elements.py index 4b1dab7..565fc34 100644 --- a/backend/tests/test_ap24_steering_elements.py +++ b/backend/tests/test_ap24_steering_elements.py @@ -49,6 +49,7 @@ def test_agile_iteration_composes_with_matrix(): } ) assert "program_delivery" not in method.composes_with + assert "backlog_epic_hierarchy" in method.steering_elements def test_list_composable_modifiers_for_linear(): diff --git a/backend/tests/test_backlog_vocabulary_unit.py b/backend/tests/test_backlog_vocabulary_unit.py new file mode 100644 index 0000000..b7bbe33 --- /dev/null +++ b/backend/tests/test_backlog_vocabulary_unit.py @@ -0,0 +1,49 @@ +"""Unit tests for backlog vocabulary resolution.""" + +from __future__ import annotations + +from steering.backlog_vocabulary import resolve_backlog_vocabulary + + +def test_resolve_agile_from_steering_element(): + vocab = resolve_backlog_vocabulary( + data_slices=["backlog", "actions", "work_cycles"], + method_key="continuous_product", + steering_elements=frozenset( + {"work_cycle_scope", "backlog_epic_hierarchy", "next_action_primary"} + ), + ) + assert vocab["profile_key"] == "agile_intake" + assert vocab["capabilities"]["epic_hierarchy"] is True + assert vocab["capabilities"]["sprint_commit"] is True + assert "epic" in vocab["kinds"] + + +def test_resolve_continuous_without_epic_element(): + vocab = resolve_backlog_vocabulary( + data_slices=["backlog", "actions"], + method_key="continuous_product", + steering_elements=frozenset({"next_action_primary"}), + ) + assert vocab["profile_key"] == "continuous_intake" + assert vocab["labels"]["story"] == "Idee" + assert vocab["capabilities"]["epic_hierarchy"] is False + + +def test_resolve_standard_for_generic_backlog(): + vocab = resolve_backlog_vocabulary( + data_slices=["backlog", "actions"], + method_key="sequential_dependency", + steering_elements=frozenset({"critical_path", "next_action_primary"}), + ) + assert vocab["profile_key"] == "standard_intake" + assert vocab["labels"]["story"] == "Story" + + +def test_resolve_empty_without_backlog_slice(): + vocab = resolve_backlog_vocabulary( + data_slices=["actions", "roadmap"], + method_key="maturity_progression", + steering_elements=frozenset({"next_action_primary"}), + ) + assert vocab["kinds"] == [] diff --git a/backend/tests/test_effective_contract_unit.py b/backend/tests/test_effective_contract_unit.py new file mode 100644 index 0000000..971395a --- /dev/null +++ b/backend/tests/test_effective_contract_unit.py @@ -0,0 +1,34 @@ +"""Unit tests for effective method contract resolution.""" + +from __future__ import annotations + +from steering.effective_contract import resolve_effective_steering_elements + + +def test_product_plan_phase_includes_agile_backlog_elements(): + elements = resolve_effective_steering_elements( + "continuous_product", + data_slices=["backlog", "actions", "work_cycles"], + active_composition_modifier=None, + ) + assert "work_cycle_scope" in elements + assert "backlog_epic_hierarchy" in elements + + +def test_linear_without_work_cycles_excludes_agile_backlog(): + elements = resolve_effective_steering_elements( + "sequential_dependency", + data_slices=["backlog", "actions", "roadmap"], + active_composition_modifier=None, + ) + assert "critical_path" in elements + assert "backlog_epic_hierarchy" not in elements + + +def test_active_modifier_merges_agile_elements(): + elements = resolve_effective_steering_elements( + "sequential_dependency", + data_slices=["backlog", "actions"], + active_composition_modifier="agile_iteration", + ) + assert "backlog_epic_hierarchy" in elements diff --git a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md index 6ef9519..be49e1d 100644 --- a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md +++ b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md @@ -51,7 +51,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden. | 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 | | Dual Commit (Direct AP) | ◐ | ADP P1: Plan→Arbeit im Product-Outline (2026-07-26) | -| Profil-Vokabular Backlog | ✗ | ADP P3: Continuous vs. Agile labels via Operating Context | +| Profil-Vokabular Backlog | ◐ | AP2.2g: `backlog_vocabulary` + `backlog_epic_hierarchy` via effektive `steering_elements` | | Blocker | ✓ | `action_id` optional | | Milestone | ◐ | Legacy-Tabelle MVP-Brücke; RoadmapItem bevorzugt | | Evidence | ✓ | `roadmap_item_id`, `action_id`; Gitea-Links in `description` (manuell) | diff --git a/frontend/src/components/BacklogItemForm.jsx b/frontend/src/components/BacklogItemForm.jsx index d0e7528..ef7dab5 100644 --- a/frontend/src/components/BacklogItemForm.jsx +++ b/frontend/src/components/BacklogItemForm.jsx @@ -1,32 +1,31 @@ -import { useMemo } from 'react' import { BACKLOG_STATUSES, BACKLOG_STATUS_LABELS, - BACKLOG_ITEM_KINDS, - BACKLOG_ITEM_KINDS_WITH_EPIC, - BACKLOG_ITEM_KIND_LABELS, PRIORITIES, PRIORITY_LABELS, } from '../constants/status.js' import { GateSelect } from './GateSelect.jsx' import { FeatureParentSelect } from './FeatureParentSelect.jsx' import { EpicParentSelect } from './EpicParentSelect.jsx' +import { resolveBacklogVocabulary } from '../utils/resolveBacklogVocabulary.js' export function BacklogItemForm({ initial = {}, roadmapItems = [], featureActions = [], epicItems = [], - epicHierarchyEnabled = false, + backlogVocabulary = null, onSubmit, onCancel, busy = false, submitLabel = 'Speichern', allowStatus = true, }) { - const itemKinds = epicHierarchyEnabled ? BACKLOG_ITEM_KINDS_WITH_EPIC : BACKLOG_ITEM_KINDS - const initialKind = initial.item_kind || 'story' + const vocabulary = resolveBacklogVocabulary(backlogVocabulary) + const itemKinds = vocabulary.kinds + const initialKind = initial.item_kind || vocabulary.default_kind || 'story' const isEpic = initialKind === 'epic' + const epicHierarchyEnabled = vocabulary.capabilities?.epic_hierarchy ?? vocabulary.epic_hierarchy async function handleSubmit(e) { e.preventDefault() @@ -34,7 +33,7 @@ export function BacklogItemForm({ const gateValue = form.roadmap_item_id?.value ?? '' const parentValue = form.parent_action_id?.value ?? '' const epicValue = form.parent_backlog_id?.value ?? '' - const itemKind = form.item_kind?.value || 'story' + const itemKind = form.item_kind?.value || vocabulary.default_kind || 'story' await onSubmit({ title: form.title.value.trim(), description: form.description.value, @@ -53,7 +52,8 @@ export function BacklogItemForm({ const statusLocked = initial.status === 'converted' const showFeatureRef = !isEpic && - ((initial.item_kind || 'story') !== 'story' || featureActions.length > 0) + ((initial.item_kind || vocabulary.default_kind || 'story') !== 'story' || + featureActions.length > 0) return (