feat(steering): Backlog-Vokabular aus Methoden-Vertrag (ADP P3)
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 3m56s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 19s
Test Suite / playwright-smoke (push) Successful in 13s

backlog_epic_hierarchy als Steuerungselement, effektive steering_elements im Operating Context, Backend-Guards und UI ohne Slice-Ifs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-27 10:44:38 +02:00
parent f33135b87f
commit a3b14d9c00
18 changed files with 489 additions and 35 deletions

View File

@ -10,6 +10,7 @@ from db import get_connection
from services.actions import create_action from services.actions import create_action
from services.audit import log_audit from services.audit import log_audit
from services.initiatives import PRIORITIES, get_initiative 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 from services.plan_ist import validate_roadmap_item_in_initiative
BacklogStatus = Literal["new", "triaged", "accepted", "rejected", "converted"] 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}") 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: def _item_kind_to_action_kind(item_kind: str) -> str:
if item_kind == "bug": if item_kind == "bug":
return "bug" return "bug"
@ -103,7 +126,11 @@ def _validate_backlog_hierarchy(
parent_backlog_id: Optional[str], parent_backlog_id: Optional[str],
parent_action_id: Optional[str], parent_action_id: Optional[str],
backlog_item_id: Optional[str] = None, backlog_item_id: Optional[str] = None,
epic_hierarchy: bool = True,
) -> None: ) -> 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 item_kind == "epic":
if parent_backlog_id: if parent_backlog_id:
raise ValueError("Epic kann keinem übergeordneten Backlog-Item zugeordnet werden") raise ValueError("Epic kann keinem übergeordneten Backlog-Item zugeordnet werden")
@ -114,6 +141,9 @@ def _validate_backlog_hierarchy(
if not parent_backlog_id: if not parent_backlog_id:
return 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: if backlog_item_id and parent_backlog_id == backlog_item_id:
raise ValueError("Item kann nicht sich selbst als Epic referenzieren") raise ValueError("Item kann nicht sich selbst als Epic referenzieren")
@ -156,7 +186,10 @@ def create_backlog_item(
raise ValueError("Titel ist erforderlich") raise ValueError("Titel ist erforderlich")
_validate_status(status) _validate_status(status)
_validate_priority(priority) _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): if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden") raise ValueError("Initiative nicht gefunden")
@ -182,6 +215,7 @@ def create_backlog_item(
item_kind=item_kind, item_kind=item_kind,
parent_backlog_id=parent_backlog_id, parent_backlog_id=parent_backlog_id,
parent_action_id=parent_action_id, parent_action_id=parent_action_id,
epic_hierarchy=epic_hierarchy,
) )
cur.execute( cur.execute(
""" """
@ -336,6 +370,15 @@ def update_backlog_item(
_validate_item_kind(item_kind) _validate_item_kind(item_kind)
updates.append("item_kind = %s") updates.append("item_kind = %s")
params.append(item_kind) 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: if clear_parent_action:
updates.append("parent_action_id = NULL") updates.append("parent_action_id = NULL")
elif parent_action_id is not None: elif parent_action_id is not None:
@ -350,7 +393,6 @@ def update_backlog_item(
if not updates: if not updates:
return existing return existing
effective_kind = item_kind if item_kind is not None else existing.get("item_kind") or "story"
effective_parent_backlog = ( effective_parent_backlog = (
None None
if clear_parent_backlog if clear_parent_backlog
@ -380,6 +422,7 @@ def update_backlog_item(
parent_backlog_id=effective_parent_backlog, parent_backlog_id=effective_parent_backlog,
parent_action_id=effective_parent_action, parent_action_id=effective_parent_action,
backlog_item_id=backlog_item_id, backlog_item_id=backlog_item_id,
epic_hierarchy=epic_hierarchy,
) )
if parent_action_id is not None and not clear_parent_action: if parent_action_id is not None and not clear_parent_action:
_validate_parent_action_in_initiative( _validate_parent_action_in_initiative(

View File

@ -15,6 +15,8 @@ from entity_archetypes.registry import (
from services.initiatives import get_initiative from services.initiatives import get_initiative
from services.steering_context import ensure_steering_context, get_steering_context from services.steering_context import ensure_steering_context, get_steering_context
from steering.graph.profiles import LIGHT, graph_profile_as_dict 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 ( from steering.methods.registry import (
get_method, get_method,
list_compatible_methods, list_compatible_methods,
@ -169,6 +171,25 @@ def get_operating_context(
method_key=method_key, method_key=method_key,
) )
method_contract = _resolve_method_contract(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 { return {
"initiative_id": initiative_id, "initiative_id": initiative_id,
@ -179,7 +200,8 @@ def get_operating_context(
"om_capabilities": sorted(om_capabilities), "om_capabilities": sorted(om_capabilities),
"data_slices": data_slices, "data_slices": data_slices,
"method_capabilities": _method_capabilities(method_key), "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"], "ui_features": method_contract["ui_features"],
"graph_profile": method_contract["graph_profile"], "graph_profile": method_contract["graph_profile"],
"compatible_methods": _compatible_methods_payload( "compatible_methods": _compatible_methods_payload(
@ -191,15 +213,8 @@ def get_operating_context(
method_to_dict(m, include_compatibility=False) method_to_dict(m, include_compatibility=False)
for m in list_composable_modifiers(method_key) for m in list_composable_modifiers(method_key)
], ],
"active_composition_modifier": ( "active_composition_modifier": active_composition_modifier,
"agile_iteration" "backlog_vocabulary": backlog_vocabulary,
if _resolve_active_agile_composition(
tenant_id=tenant_id,
initiative_id=initiative_id,
primary_method_key=method_key,
)
else None
),
} }

View File

@ -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)

View File

@ -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)

View File

@ -41,6 +41,12 @@ STEERING_ELEMENTS: dict[str, SteeringElementDefinition] = {
description="Aktive work_cycle und Sprint-Backlog", description="Aktive work_cycle und Sprint-Backlog",
modes=("work", "plan"), 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( "recurring_rhythm": SteeringElementDefinition(
key="recurring_rhythm", key="recurring_rhythm",
label="Rhythmen", label="Rhythmen",

View File

@ -76,7 +76,8 @@ def register() -> None:
"initiative.recurring_program", "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"}), ui_features=frozenset({"steeringSnapshotOnWorkSprint"}),
graph_profile=STRICT, graph_profile=STRICT,
method_role="modifier", method_role="modifier",

View File

@ -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"]

View File

@ -30,6 +30,7 @@ def test_operating_context_product(client):
assert body["initiative_id"] == initiative_id assert body["initiative_id"] == initiative_id
assert body["archetype_key"] == "initiative.product" assert body["archetype_key"] == "initiative.product"
assert body["method_key"] == "continuous_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"]["planDefaultRoute"] == "/plan/inbox"
assert body["ui_profile"]["workDefaultRoute"] == "/work/sprint" assert body["ui_profile"]["workDefaultRoute"] == "/work/sprint"
assert "work_cycles" in body["data_slices"] assert "work_cycles" in body["data_slices"]

View File

@ -49,6 +49,7 @@ def test_agile_iteration_composes_with_matrix():
} }
) )
assert "program_delivery" not in method.composes_with assert "program_delivery" not in method.composes_with
assert "backlog_epic_hierarchy" in method.steering_elements
def test_list_composable_modifiers_for_linear(): def test_list_composable_modifiers_for_linear():

View File

@ -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"] == []

View File

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

View File

@ -51,7 +51,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| BacklogItem kinds (MVP) | ◐ | `story`/`bug`/`issue` AP2.2e — global, nicht profilgebunden | | 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: `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) | | 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 | | Blocker | ✓ | `action_id` optional |
| Milestone | ◐ | Legacy-Tabelle MVP-Brücke; RoadmapItem bevorzugt | | Milestone | ◐ | Legacy-Tabelle MVP-Brücke; RoadmapItem bevorzugt |
| Evidence | ✓ | `roadmap_item_id`, `action_id`; Gitea-Links in `description` (manuell) | | Evidence | ✓ | `roadmap_item_id`, `action_id`; Gitea-Links in `description` (manuell) |

View File

@ -1,32 +1,31 @@
import { useMemo } from 'react'
import { import {
BACKLOG_STATUSES, BACKLOG_STATUSES,
BACKLOG_STATUS_LABELS, BACKLOG_STATUS_LABELS,
BACKLOG_ITEM_KINDS,
BACKLOG_ITEM_KINDS_WITH_EPIC,
BACKLOG_ITEM_KIND_LABELS,
PRIORITIES, PRIORITIES,
PRIORITY_LABELS, PRIORITY_LABELS,
} from '../constants/status.js' } from '../constants/status.js'
import { GateSelect } from './GateSelect.jsx' import { GateSelect } from './GateSelect.jsx'
import { FeatureParentSelect } from './FeatureParentSelect.jsx' import { FeatureParentSelect } from './FeatureParentSelect.jsx'
import { EpicParentSelect } from './EpicParentSelect.jsx' import { EpicParentSelect } from './EpicParentSelect.jsx'
import { resolveBacklogVocabulary } from '../utils/resolveBacklogVocabulary.js'
export function BacklogItemForm({ export function BacklogItemForm({
initial = {}, initial = {},
roadmapItems = [], roadmapItems = [],
featureActions = [], featureActions = [],
epicItems = [], epicItems = [],
epicHierarchyEnabled = false, backlogVocabulary = null,
onSubmit, onSubmit,
onCancel, onCancel,
busy = false, busy = false,
submitLabel = 'Speichern', submitLabel = 'Speichern',
allowStatus = true, allowStatus = true,
}) { }) {
const itemKinds = epicHierarchyEnabled ? BACKLOG_ITEM_KINDS_WITH_EPIC : BACKLOG_ITEM_KINDS const vocabulary = resolveBacklogVocabulary(backlogVocabulary)
const initialKind = initial.item_kind || 'story' const itemKinds = vocabulary.kinds
const initialKind = initial.item_kind || vocabulary.default_kind || 'story'
const isEpic = initialKind === 'epic' const isEpic = initialKind === 'epic'
const epicHierarchyEnabled = vocabulary.capabilities?.epic_hierarchy ?? vocabulary.epic_hierarchy
async function handleSubmit(e) { async function handleSubmit(e) {
e.preventDefault() e.preventDefault()
@ -34,7 +33,7 @@ export function BacklogItemForm({
const gateValue = form.roadmap_item_id?.value ?? '' const gateValue = form.roadmap_item_id?.value ?? ''
const parentValue = form.parent_action_id?.value ?? '' const parentValue = form.parent_action_id?.value ?? ''
const epicValue = form.parent_backlog_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({ await onSubmit({
title: form.title.value.trim(), title: form.title.value.trim(),
description: form.description.value, description: form.description.value,
@ -53,7 +52,8 @@ export function BacklogItemForm({
const statusLocked = initial.status === 'converted' const statusLocked = initial.status === 'converted'
const showFeatureRef = const showFeatureRef =
!isEpic && !isEpic &&
((initial.item_kind || 'story') !== 'story' || featureActions.length > 0) ((initial.item_kind || vocabulary.default_kind || 'story') !== 'story' ||
featureActions.length > 0)
return ( return (
<form className="form workspace-form backlog-item-form" onSubmit={handleSubmit}> <form className="form workspace-form backlog-item-form" onSubmit={handleSubmit}>
@ -86,7 +86,7 @@ export function BacklogItemForm({
> >
{itemKinds.map((kind) => ( {itemKinds.map((kind) => (
<option key={kind} value={kind}> <option key={kind} value={kind}>
{BACKLOG_ITEM_KIND_LABELS[kind]} {vocabulary.labels[kind] || kind}
</option> </option>
))} ))}
</select> </select>

View File

@ -8,8 +8,10 @@ import { ReorderControls } from './ReorderControls.jsx'
import { gateTitleById } from './GateSelect.jsx' import { gateTitleById } from './GateSelect.jsx'
import { computeDropPatches, computeMovePatches, sortByOrder } from '../utils/reorder.js' import { computeDropPatches, computeMovePatches, sortByOrder } from '../utils/reorder.js'
import { buildBacklogDisplayRows, listEpicBacklogItems } from '../utils/backlogTree.js' import { buildBacklogDisplayRows, listEpicBacklogItems } from '../utils/backlogTree.js'
import { backlogKindLabel, resolveBacklogVocabulary } from '../utils/resolveBacklogVocabulary.js'
import { useMinWidth } from '../hooks/useMinWidth.js' import { useMinWidth } from '../hooks/useMinWidth.js'
import { BACKLOG_ITEM_KIND_LABELS } from '../constants/status.js' import { useInitiativeOperations } from '../context/InitiativeOperationsContext.jsx'
import { hasSteeringElement } from '../registry/steeringElementRegistry.js'
import { actionTitleById } from '../utils/actionReferences.js' import { actionTitleById } from '../utils/actionReferences.js'
const PLANNING_STATUSES = new Set(['planned', 'active', 'at_risk']) const PLANNING_STATUSES = new Set(['planned', 'active', 'at_risk'])
@ -24,7 +26,6 @@ export function BacklogSection({
activeWorkCycle = null, activeWorkCycle = null,
featureActions = [], featureActions = [],
initiativeId = '', initiativeId = '',
sprintPlanningEnabled = false,
canManage, canManage,
onCreate, onCreate,
onUpdate, onUpdate,
@ -36,6 +37,7 @@ export function BacklogSection({
sectionTitle = 'Product Backlog', sectionTitle = 'Product Backlog',
sectionLead = 'Eingang vor dem Commit — triagieren, dann in den Sprint planen.', sectionLead = 'Eingang vor dem Commit — triagieren, dann in den Sprint planen.',
}) { }) {
const { operatingContext, steeringElements } = useInitiativeOperations()
const [modalMode, setModalMode] = useState(null) const [modalMode, setModalMode] = useState(null)
const [dragItemId, setDragItemId] = useState('') const [dragItemId, setDragItemId] = useState('')
const [dropTargetId, setDropTargetId] = useState('') const [dropTargetId, setDropTargetId] = useState('')
@ -44,12 +46,23 @@ export function BacklogSection({
const isDesktop = useMinWidth(1024) const isDesktop = useMinWidth(1024)
const canReorder = canManage && typeof onReorder === 'function' const canReorder = canManage && typeof onReorder === 'function'
const vocabulary = useMemo(
() => resolveBacklogVocabulary(operatingContext?.backlog_vocabulary),
[operatingContext?.backlog_vocabulary],
)
const sprintCommitEnabled = useMemo(
() =>
vocabulary.capabilities?.sprint_commit ??
hasSteeringElement(steeringElements, 'work_cycle_scope'),
[vocabulary, steeringElements],
)
const planableSprints = useMemo( const planableSprints = useMemo(
() => workCycles.filter((cycle) => PLANNING_STATUSES.has(cycle.status)), () => workCycles.filter((cycle) => PLANNING_STATUSES.has(cycle.status)),
[workCycles], [workCycles],
) )
const showSprintPlanning = sprintPlanningEnabled && planableSprints.length > 0 const showSprintPlanning = sprintCommitEnabled && planableSprints.length > 0
useEffect(() => { useEffect(() => {
if (!showSprintPlanning || !initiativeId) { if (!showSprintPlanning || !initiativeId) {
@ -93,7 +106,7 @@ export function BacklogSection({
return map return map
}, [epicItems]) }, [epicItems])
const epicHierarchyEnabled = sprintPlanningEnabled const defaultKind = vocabulary.default_kind || 'story'
function closeModal() { function closeModal() {
setModalMode(null) setModalMode(null)
@ -234,7 +247,7 @@ export function BacklogSection({
)} )}
</div> </div>
{sprintPlanningEnabled && canManage && !showSprintPlanning && ( {sprintCommitEnabled && canManage && !showSprintPlanning && (
<div className="backlog-sprint-setup card-list-item"> <div className="backlog-sprint-setup card-list-item">
<p className="backlog-sprint-setup__title"> <p className="backlog-sprint-setup__title">
<strong>Sprint-Planung:</strong> Lege zuerst einen Sprint an. <strong>Sprint-Planung:</strong> Lege zuerst einen Sprint an.
@ -332,9 +345,9 @@ export function BacklogSection({
disabled={!canManage} disabled={!canManage}
> >
<strong>{item.title}</strong> <strong>{item.title}</strong>
{item.item_kind && item.item_kind !== 'story' && ( {item.item_kind && item.item_kind !== defaultKind && (
<span className="badge badge--kind muted"> <span className="badge badge--kind muted">
{BACKLOG_ITEM_KIND_LABELS[item.item_kind] || item.item_kind} {backlogKindLabel(vocabulary, item.item_kind)}
</span> </span>
)} )}
{item.description && ( {item.description && (
@ -421,7 +434,7 @@ export function BacklogSection({
roadmapItems={roadmapItems} roadmapItems={roadmapItems}
featureActions={featureActions} featureActions={featureActions}
epicItems={epicItems} epicItems={epicItems}
epicHierarchyEnabled={epicHierarchyEnabled} backlogVocabulary={vocabulary}
onSubmit={handleCreateSubmit} onSubmit={handleCreateSubmit}
onCancel={closeModal} onCancel={closeModal}
busy={busy} busy={busy}
@ -434,7 +447,7 @@ export function BacklogSection({
roadmapItems={roadmapItems} roadmapItems={roadmapItems}
featureActions={featureActions} featureActions={featureActions}
epicItems={epicItems.filter((epic) => epic.id !== modalMode.item.id)} epicItems={epicItems.filter((epic) => epic.id !== modalMode.item.id)}
epicHierarchyEnabled={epicHierarchyEnabled} backlogVocabulary={vocabulary}
onSubmit={handleEditSubmit} onSubmit={handleEditSubmit}
onCancel={closeModal} onCancel={closeModal}
busy={busy} busy={busy}

View File

@ -7,7 +7,6 @@ export function InitiativeInboxPage() {
initiative, initiative,
initiativeId, initiativeId,
backlogItems, backlogItems,
dataSlices,
roadmapItems, roadmapItems,
featureParentActions, featureParentActions,
workCycles, workCycles,
@ -42,7 +41,6 @@ export function InitiativeInboxPage() {
featureActions={featureParentActions} featureActions={featureParentActions}
workCycles={workCycles} workCycles={workCycles}
activeWorkCycle={activeWorkCycle} activeWorkCycle={activeWorkCycle}
sprintPlanningEnabled={dataSlices.includes('work_cycles')}
canManage={capabilities.has('kairo.backlog.manage')} canManage={capabilities.has('kairo.backlog.manage')}
onCreate={handleCreateBacklog} onCreate={handleCreateBacklog}
onUpdate={handleUpdateBacklog} onUpdate={handleUpdateBacklog}

View File

@ -13,6 +13,9 @@ export const STEERING_ELEMENT_UI = {
nextActionTitle: 'Nächster Schritt aus der Queue', nextActionTitle: 'Nächster Schritt aus der Queue',
nextActionSubtitle: 'Pull-Empfehlung aus dem Eingang.', nextActionSubtitle: 'Pull-Empfehlung aus dem Eingang.',
}, },
backlog_epic_hierarchy: {
planInboxHint: 'Epic-Baum für Sprint-Planung und Refinement.',
},
} }
/** /**

View File

@ -0,0 +1,50 @@
/** @typedef {import('../api/types.js').BacklogVocabulary} BacklogVocabulary */
export const DEFAULT_BACKLOG_VOCABULARY = {
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'],
capabilities: {
sprint_commit: false,
epic_hierarchy: false,
},
}
/**
* @param {Partial<BacklogVocabulary> | null | undefined} vocabulary
* @returns {BacklogVocabulary}
*/
export function resolveBacklogVocabulary(vocabulary) {
if (!vocabulary?.kinds?.length) {
return { ...DEFAULT_BACKLOG_VOCABULARY }
}
return {
...DEFAULT_BACKLOG_VOCABULARY,
...vocabulary,
labels: {
...DEFAULT_BACKLOG_VOCABULARY.labels,
...(vocabulary.labels || {}),
},
capabilities: {
...DEFAULT_BACKLOG_VOCABULARY.capabilities,
...(vocabulary.capabilities || {}),
},
}
}
/**
* @param {Partial<BacklogVocabulary> | null | undefined} vocabulary
* @param {string | null | undefined} itemKind
*/
export function backlogKindLabel(vocabulary, itemKind) {
if (!itemKind) return ''
const resolved = resolveBacklogVocabulary(vocabulary)
return resolved.labels[itemKind] || itemKind
}

View File

@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import {
backlogKindLabel,
DEFAULT_BACKLOG_VOCABULARY,
resolveBacklogVocabulary,
} from './resolveBacklogVocabulary.js'
describe('resolveBacklogVocabulary', () => {
it('liefert Fallback bei leerem Input', () => {
expect(resolveBacklogVocabulary(null)).toEqual(DEFAULT_BACKLOG_VOCABULARY)
})
it('merged Continuous-Labels', () => {
const vocab = resolveBacklogVocabulary({
profile_key: 'continuous_intake',
kinds: ['story', 'bug', 'issue'],
labels: { story: 'Idee', issue: 'Störung' },
epic_hierarchy: false,
})
expect(vocab.labels.story).toBe('Idee')
expect(vocab.labels.issue).toBe('Störung')
})
it('backlogKindLabel nutzt Profil-Label', () => {
expect(
backlogKindLabel(
{ labels: { story: 'Idee' }, kinds: ['story'] },
'story',
),
).toBe('Idee')
})
})