From 02b6469c2dbd75626605d4256cb2776a9241daa4 Mon Sep 17 00:00:00 2001 From: Lars Date: Mon, 27 Jul 2026 15:59:18 +0200 Subject: [PATCH] feat(AP2.2c): A1 Reifegrad E2E, Stufen-/Rhythmus-Panels und Composition-Kernel Co-authored-by: Cursor --- backend/tests/test_ap22c_maturity_e2e.py | 179 ++++++++++++++++++ .../Kairo_Implementation_Truth_Table_v0.1.md | 3 +- docs/product/Kairo_MVP_Execution_Plan_v0.2.md | 2 +- .../src/components/MaturityStagePanel.jsx | 72 +++++++ .../src/components/RecurringRhythmPanel.jsx | 84 ++++++++ .../InitiativeCompositionSurface.jsx | 4 + .../src/composition/compositionProviders.js | 20 ++ .../src/composition/providerComponents.jsx | 4 + .../composition/resolveSteeringComposition.js | 19 +- .../resolveSteeringComposition.test.js | 28 ++- .../src/registry/steeringElementRegistry.js | 9 + 11 files changed, 420 insertions(+), 4 deletions(-) create mode 100644 backend/tests/test_ap22c_maturity_e2e.py create mode 100644 frontend/src/components/MaturityStagePanel.jsx create mode 100644 frontend/src/components/RecurringRhythmPanel.jsx diff --git a/backend/tests/test_ap22c_maturity_e2e.py b/backend/tests/test_ap22c_maturity_e2e.py new file mode 100644 index 0000000..2bfa5ae --- /dev/null +++ b/backend/tests/test_ap22c_maturity_e2e.py @@ -0,0 +1,179 @@ +"""AP2.2c — A1 maturity journey End-to-End (Spagat Happy Path).""" + +from __future__ import annotations + +from tests.factories import provision_user_in_tenant +from tests.test_initiatives_actions import _auth, _create_initiative, _login + +_STAGE1 = "Stufe 1 — Basis" +_STARTER_RECURRING = "Tägliche Übung" + + +def _create_spagat(client, token): + created = _create_initiative( + client, + token, + title="Spagat können", + archetype_key="initiative.maturity_journey", + ) + assert created.status_code == 201 + body = created.json() + assert body["starter_kit"]["applied"] is True + return body["id"] + + +def test_a1_starter_kit_spagat_structure(client): + """Starter-Kit: Stufen, Training-Project, tägliche Übung, Operating Context.""" + user = provision_user_in_tenant(tenant_role="admin") + token = _login(client, user) + initiative_id = _create_spagat(client, token) + + ctx = client.get( + f"/api/initiatives/{initiative_id}/operating-context", + headers=_auth(token), + ) + assert ctx.status_code == 200 + body = ctx.json() + assert body["method_key"] == "maturity_progression" + assert "maturity_stage" in body["steering_elements"] + assert "recurring_rhythm" in body["steering_elements"] + assert body["ui_profile"]["controlDefaultRoute"] == "/control/status" + + roadmap = client.get( + f"/api/initiatives/{initiative_id}/roadmap/items", + headers=_auth(token), + ) + assert roadmap.status_code == 200 + stages = [i for i in roadmap.json() if i.get("item_type") == "maturity_stage"] + assert len(stages) == 3 + assert sum(1 for s in stages if s["status"] == "active") == 1 + assert any(s["title"] == _STAGE1 and s["status"] == "active" for s in stages) + + recurring = client.get( + f"/api/initiatives/{initiative_id}/recurring", + headers=_auth(token), + ) + assert recurring.status_code == 200 + assert any(r["title"] == _STARTER_RECURRING and r["status"] == "active" for r in recurring.json()) + + projects = client.get( + f"/api/initiatives/{initiative_id}/projects", + headers=_auth(token), + ) + assert projects.status_code == 200 + assert any(p["title"] == "Training" for p in projects.json()) + + snap = client.get( + f"/api/initiatives/{initiative_id}/steering-snapshot", + headers=_auth(token), + ) + assert snap.status_code == 200 + snap_body = snap.json() + assert snap_body["steering_kernel"]["primary_method_key"] == "maturity_progression" + assert snap_body.get("steering_guidance") + next_actions = snap_body.get("next_actions") or [] + assert next_actions + top = next_actions[0] + assert top.get("kind") in ("recurring_due", "action", "review_milestone") + if top.get("kind") == "recurring_due": + assert top.get("reason_code") == "recurring_due" + + +def test_a1_stage_verify_rotates_recurring_and_next_action(client): + """AP2.0e: Stufe 1 reached → alte Übung pausiert, Stufe 2 aktiv, neue Routine.""" + user = provision_user_in_tenant(tenant_role="admin") + token = _login(client, user) + initiative_id = _create_spagat(client, token) + + items = client.get( + f"/api/initiatives/{initiative_id}/roadmap/items", + headers=_auth(token), + ).json() + stage1 = next(i for i in items if i["title"] == _STAGE1) + + snap_before = client.get( + f"/api/initiatives/{initiative_id}/steering-snapshot", + headers=_auth(token), + ) + assert snap_before.status_code == 200 + + client.post( + f"/api/initiatives/{initiative_id}/evidence", + json={ + "title": "Stufe 1 geschafft", + "roadmap_item_id": stage1["id"], + "status": "accepted", + }, + headers=_auth(token), + ) + + verify = client.post( + f"/api/roadmap-items/{stage1['id']}/verify-reached", + headers=_auth(token), + ) + assert verify.status_code == 200 + body = verify.json() + assert body["status"] == "reached" + transition = body.get("maturity_transition") or {} + assert transition.get("new_recurring_id") + + recurring = client.get( + f"/api/initiatives/{initiative_id}/recurring", + headers=_auth(token), + ).json() + titles = {r["title"]: r["status"] for r in recurring} + assert titles.get(_STARTER_RECURRING) == "paused" + assert any(t.startswith("Übung — Stufe 2") and titles[t] == "active" for t in titles) + + items_after = client.get( + f"/api/initiatives/{initiative_id}/roadmap/items", + headers=_auth(token), + ).json() + stage2 = next(i for i in items_after if i["title"] == "Stufe 2 — Aufbau") + assert stage2["status"] == "active" + + snap_after = client.get( + f"/api/initiatives/{initiative_id}/steering-snapshot", + headers=_auth(token), + ) + assert snap_after.status_code == 200 + next_after = snap_after.json().get("next_actions") or [] + assert next_after + assert next_after[0].get("kind") == "recurring_due" or next_after[0].get("title", "").startswith( + "Übung —" + ) + + +def test_a1_journey_lists_stage_reached_event(client): + """Journey enthält nach Verify mindestens ein Ereignis.""" + user = provision_user_in_tenant(tenant_role="admin") + token = _login(client, user) + initiative_id = _create_spagat(client, token) + + items = client.get( + f"/api/initiatives/{initiative_id}/roadmap/items", + headers=_auth(token), + ).json() + stage1 = next(i for i in items if i["title"] == _STAGE1) + + client.post( + f"/api/initiatives/{initiative_id}/evidence", + json={ + "title": "Nachweis Stufe 1", + "roadmap_item_id": stage1["id"], + "status": "accepted", + }, + headers=_auth(token), + ) + client.post( + f"/api/roadmap-items/{stage1['id']}/verify-reached", + headers=_auth(token), + ) + + journey = client.get( + f"/api/initiatives/{initiative_id}/journey", + headers=_auth(token), + ) + assert journey.status_code == 200 + events = journey.json().get("events") or [] + assert len(events) >= 1 diff --git a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md index 4cc3b2c..e90aea6 100644 --- a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md +++ b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md @@ -156,7 +156,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden. | Archetyp | Anlage+Kit | Struktur pflegen | Kontrolle | Op-API | Paket | |----------|------------|------------------|-----------|--------|-------| -| A1 Reifegrad | ◐ AP2.2a | ◐ | ◐ | ◐ | AP2.2c + AP2.0e | +| A1 Reifegrad | ✓ AP2.2a | ✓ | ✓ | ◐ | AP2.2c ✓ | | A2 Linear | ✓ AP2.2a | ✓ | ✓ | ◐ | AP2.2b ✓ | | B2b Product | ◐ AP2.2a | ◐ | ◐ | ◐ | AP2.2d ✓ | | B3 Sprint-Profil | ◐ | ◐ | ◐ | ◐ | AP2.2d ✓ | @@ -231,6 +231,7 @@ Details: `Kairo_Status_Review_and_Next_Steps_v0.1.md` §2.1 | ADP Backlog/Epic | P1–P8 ✓ · K-Ext-1/2/3/4/5 ✓ | | Steering Kernel Proposals | ✓ | sprint_commit · gate_next_actions · intake_triage (Kernel v0.4) | | AP2.2b | A2 Linear E2E ✓ (2026-07-27) | +| AP2.2c | A1 Reifegrad E2E ✓ (2026-07-27) | | AP2.2c–e | Referenz-Archetypen End-to-End ◐→✓ | | AP2.1 | MVP-Abnahfe ✗→✓ | | AP1.7b | Op-API Parität | diff --git a/docs/product/Kairo_MVP_Execution_Plan_v0.2.md b/docs/product/Kairo_MVP_Execution_Plan_v0.2.md index c3680c4..4dfbe59 100644 --- a/docs/product/Kairo_MVP_Execution_Plan_v0.2.md +++ b/docs/product/Kairo_MVP_Execution_Plan_v0.2.md @@ -80,7 +80,7 @@ Phase 1 AP2.2a Archetyp-geführte Anlage + Starter-Kits ✓ Phase 1b Specs Welle 1 finalisieren (A1,A2,B2a,B2b,B3) ◐ Phase 2 AP1.9d Methoden-Default-Ansichten (Anti-Todo-Wand) ◐ (Hints + Recurring) Phase 3 AP2.2b A2 Linear End-to-End ✓ - AP2.2c A1 Reifegrad + AP2.0e ◐ Code + AP2.2c A1 Reifegrad + AP2.0e ✓ AP2.2d B2b Product + B3 Sprint ✓ AP2.2e B2a Programm (optional vor AP2.1) Phase 4 AP1.7b Operational API — Pflege-Parität diff --git a/frontend/src/components/MaturityStagePanel.jsx b/frontend/src/components/MaturityStagePanel.jsx new file mode 100644 index 0000000..97a1a34 --- /dev/null +++ b/frontend/src/components/MaturityStagePanel.jsx @@ -0,0 +1,72 @@ +import { Link } from 'react-router-dom' +import { MILESTONE_STATUS_LABELS } from '../constants/status.js' +import { EmptyState } from './EmptyState.jsx' +import { scopedPath } from '../utils/routes.js' + +export function MaturityStagePanel({ + initiativeId, + roadmapItems = [], + embedded = true, +}) { + const stages = [...roadmapItems] + .filter((item) => item.item_type === 'maturity_stage') + .sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)) + + const activeStage = stages.find((s) => s.status === 'active') + + const body = + stages.length === 0 ? ( + + ) : ( + <> + {activeStage && ( +

+ Aktive Stufe: {activeStage.title} +

+ )} +
    + {stages.map((stage, index) => ( +
  1. + {index + 1} +
    + {stage.title} + + {MILESTONE_STATUS_LABELS[stage.status] || stage.status} + +
    +
  2. + ))} +
+

+ Stufen und Übungen unter{' '} + + Kontrolle → Rhythmen & Journey + + . +

+ + ) + + if (!embedded) return body + + return ( +
+
+
+

Reifegrad-Stufen

+

+ Aktive Stufe und Fortschritt — Steuerungshorizont für Reifegrad-Vorhaben (A1). +

+
+
+ {body} +
+ ) +} diff --git a/frontend/src/components/RecurringRhythmPanel.jsx b/frontend/src/components/RecurringRhythmPanel.jsx new file mode 100644 index 0000000..fbdf8be --- /dev/null +++ b/frontend/src/components/RecurringRhythmPanel.jsx @@ -0,0 +1,84 @@ +import { Link } from 'react-router-dom' +import { RECURRING_STATUS_LABELS } from '../constants/status.js' +import { EmptyState } from './EmptyState.jsx' +import { scopedPath } from '../utils/routes.js' + +function formatDueAt(iso) { + if (!iso) return null + try { + return new Date(iso).toLocaleString('de-DE') + } catch { + return iso + } +} + +export function RecurringRhythmPanel({ + initiativeId, + recurringItems = [], + embedded = true, +}) { + const sorted = [...recurringItems].sort((a, b) => { + const statusOrder = { active: 0, paused: 1, archived: 2 } + const diff = (statusOrder[a.status] ?? 9) - (statusOrder[b.status] ?? 9) + if (diff !== 0) return diff + return (a.title || '').localeCompare(b.title || '', 'de') + }) + + const active = sorted.filter((item) => item.status === 'active') + + const body = + sorted.length === 0 ? ( + + ) : ( + <> + {active.length > 0 && ( +

+ {active.length} aktive Routine{active.length === 1 ? '' : 'n'} — fällige Übung steuert + Next Action. +

+ )} +
    + {sorted.map((item) => ( +
  • +
    + {item.title} + + {RECURRING_STATUS_LABELS[item.status] || item.status} + {item.next_due_at ? ` · Fällig: ${formatDueAt(item.next_due_at)}` : ''} + +
    +
  • + ))} +
+

+ Rhythmen pflegen unter{' '} + + Kontrolle → Journey + + . +

+ + ) + + if (!embedded) return body + + return ( +
+
+
+

Rhythmen & Übungen

+

+ Aktive Routinen am Reifegrad-Pfad — Leading Next aus fälliger Übung (A1). +

+
+
+ {body} +
+ ) +} diff --git a/frontend/src/composition/InitiativeCompositionSurface.jsx b/frontend/src/composition/InitiativeCompositionSurface.jsx index 43bb295..768427c 100644 --- a/frontend/src/composition/InitiativeCompositionSurface.jsx +++ b/frontend/src/composition/InitiativeCompositionSurface.jsx @@ -25,6 +25,8 @@ export function InitiativeCompositionSurface({ () => ({ initiativeId: ops.initiativeId, actions: ops.actions, + roadmapItems: ops.roadmapItems, + recurringItems: ops.recurringItems, steeringSnapshotLoading: ops.steeringSnapshotLoading, steeringSnapshotError: ops.steeringSnapshotError, steeringMethods: ops.steeringMethods, @@ -36,6 +38,8 @@ export function InitiativeCompositionSurface({ [ ops.initiativeId, ops.actions, + ops.roadmapItems, + ops.recurringItems, ops.steeringSnapshotLoading, ops.steeringSnapshotError, ops.steeringMethods, diff --git a/frontend/src/composition/compositionProviders.js b/frontend/src/composition/compositionProviders.js index 98b5244..edb39e2 100644 --- a/frontend/src/composition/compositionProviders.js +++ b/frontend/src/composition/compositionProviders.js @@ -57,6 +57,26 @@ export const COMPOSITION_PROVIDERS = [ scopeTypes: ['initiative'], order: 10, }, + { + key: 'steering.maturity_stage', + kind: 'steering_element', + steeringElement: 'maturity_stage', + slotKeys: ['control.status.steering'], + componentKey: 'MaturityStagePanel', + requiresCapability: 'kairo.initiative.read', + scopeTypes: ['initiative'], + order: 15, + }, + { + key: 'steering.recurring_rhythm', + kind: 'steering_element', + steeringElement: 'recurring_rhythm', + slotKeys: ['control.status.steering'], + componentKey: 'RecurringRhythmPanel', + requiresCapability: 'kairo.initiative.read', + scopeTypes: ['initiative'], + order: 16, + }, { key: 'steering.next_action', kind: 'steering_element', diff --git a/frontend/src/composition/providerComponents.jsx b/frontend/src/composition/providerComponents.jsx index b827c8a..2d449c7 100644 --- a/frontend/src/composition/providerComponents.jsx +++ b/frontend/src/composition/providerComponents.jsx @@ -2,6 +2,8 @@ import { Link } from 'react-router-dom' import { scopedPath } from '../utils/routes.js' import { SteeringSnapshotPanel } from '../components/SteeringSnapshotPanel.jsx' import { CriticalPathPanel } from '../components/CriticalPathPanel.jsx' +import { MaturityStagePanel } from '../components/MaturityStagePanel.jsx' +import { RecurringRhythmPanel } from '../components/RecurringRhythmPanel.jsx' import { AgentSlotsPanel } from '../components/AgentSlotsPanel.jsx' import { SteeringProposalsPanel } from '../components/SteeringProposalsPanel.jsx' import { NextActionWidget } from '../widgets/NextActionWidget.jsx' @@ -34,6 +36,8 @@ export function WidgetHost({ widget }) { export const PROVIDER_COMPONENTS = { SteeringSnapshotPanel, CriticalPathPanel, + MaturityStagePanel, + RecurringRhythmPanel, NextActionWidget, AgentSlotsPanel, SteeringProposalsPanel, diff --git a/frontend/src/composition/resolveSteeringComposition.js b/frontend/src/composition/resolveSteeringComposition.js index 52aeec8..b5b314f 100644 --- a/frontend/src/composition/resolveSteeringComposition.js +++ b/frontend/src/composition/resolveSteeringComposition.js @@ -77,7 +77,14 @@ function resolveAgentSlots(input) { * @param {object | null | undefined} steeringSnapshot */ export function resolveNextActionUi(elements, steeringSnapshot) { - const priority = ['critical_path', 'work_cycle_scope', 'gate_fulfillment', 'queue_inbox'] + const priority = [ + 'critical_path', + 'recurring_rhythm', + 'work_cycle_scope', + 'maturity_stage', + 'gate_fulfillment', + 'queue_inbox', + ] const hasActiveSprint = Boolean(steeringSnapshot?.active_work_cycle) for (const key of priority) { if (key === 'work_cycle_scope' && !hasActiveSprint) continue @@ -165,6 +172,16 @@ export function buildProviderProps(provider, input) { scopeRoadmapItemId: kernel?.horizon?.gate_roadmap_item_id ?? null, } } + case 'MaturityStagePanel': + return { + initiativeId: ops.initiativeId, + roadmapItems: ops.roadmapItems || [], + } + case 'RecurringRhythmPanel': + return { + initiativeId: ops.initiativeId, + recurringItems: ops.recurringItems || [], + } case 'NextActionWidget': { const nextActionUi = resolveNextActionUi(elements, input.steeringSnapshot) return { diff --git a/frontend/src/composition/resolveSteeringComposition.test.js b/frontend/src/composition/resolveSteeringComposition.test.js index 1a832da..a3b1fde 100644 --- a/frontend/src/composition/resolveSteeringComposition.test.js +++ b/frontend/src/composition/resolveSteeringComposition.test.js @@ -215,7 +215,33 @@ describe('resolveSteeringComposition', () => { expect(props.scopeRoadmapItemId).toBe('gate-1') }) - it('buildProviderProps gates milestone horizon in snapshot panel', () => { + it('activates maturity panels for A1 on control.status', () => { + const result = resolveSteeringComposition({ + surfaceKey: 'control.status', + scope: 'initiative', + capabilities: CAPS, + steeringElements: ['next_action_primary', 'maturity_stage', 'recurring_rhythm'], + steeringSnapshot: { counts: {}, next_actions: [] }, + opsContext: { initiativeId: 'init-1', roadmapItems: [], recurringItems: [] }, + }) + const steering = result.slots['control.status.steering'] || [] + expect(steering.some((p) => p.key === 'steering.maturity_stage')).toBe(true) + expect(steering.some((p) => p.key === 'steering.recurring_rhythm')).toBe(true) + expect(steering.some((p) => p.key === 'steering.critical_path')).toBe(false) + }) + + it('buildProviderProps uses recurring rhythm next-action copy', () => { + const provider = COMPOSITION_PROVIDERS.find((p) => p.key === 'steering.next_action') + const props = buildProviderProps(provider, { + steeringElements: ['next_action_primary', 'recurring_rhythm', 'maturity_stage'], + steeringSnapshot: { next_actions: [] }, + opsContext: { initiativeId: 'x', steeringSnapshotLoading: false }, + capabilities: CAPS, + }) + expect(props.title).toContain('Rhythmus') + }) + + it('buildProviderProps toggles gate horizon on steering snapshot', () => { const provider = COMPOSITION_PROVIDERS.find((p) => p.key === 'core.steering_snapshot') const withGate = buildProviderProps(provider, { steeringElements: ['gate_fulfillment', 'next_action_primary'], diff --git a/frontend/src/registry/steeringElementRegistry.js b/frontend/src/registry/steeringElementRegistry.js index 935883b..f319ea4 100644 --- a/frontend/src/registry/steeringElementRegistry.js +++ b/frontend/src/registry/steeringElementRegistry.js @@ -14,6 +14,15 @@ export const STEERING_ELEMENT_UI = { nextActionSubtitle: 'Empfehlung aus dem Sprint-Backlog — Continuous Next außerhalb des Sprints.', }, + recurring_rhythm: { + nextActionTitle: 'Heutige Übung / Rhythmus', + nextActionSubtitle: + 'Reifegrad — aktive Routine steuert den nächsten Schritt, nicht die Gesamtliste.', + }, + maturity_stage: { + nextActionTitle: 'Nächster Schritt in der Reifegrad-Entwicklung', + nextActionSubtitle: 'Aktive Stufe und fällige Übung bestimmen die Empfehlung.', + }, gate_fulfillment: { nextActionTitle: 'Nächster sinnvoller Schritt', nextActionSubtitle: