feat(AP2.2c): A1 Reifegrad E2E, Stufen-/Rhythmus-Panels und Composition-Kernel

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-27 15:59:18 +02:00
parent 43d33d0edb
commit 02b6469c2d
11 changed files with 420 additions and 4 deletions

View File

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

View File

@ -156,7 +156,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
| Archetyp | Anlage+Kit | Struktur pflegen | Kontrolle | Op-API | Paket | | 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 ✓ | | A2 Linear | ✓ AP2.2a | ✓ | ✓ | ◐ | AP2.2b ✓ |
| B2b Product | ◐ AP2.2a | ◐ | ◐ | ◐ | AP2.2d ✓ | | B2b Product | ◐ AP2.2a | ◐ | ◐ | ◐ | AP2.2d ✓ |
| B3 Sprint-Profil | ◐ | ◐ | ◐ | ◐ | 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 | P1P8 ✓ · K-Ext-1/2/3/4/5 ✓ | | ADP Backlog/Epic | P1P8 ✓ · K-Ext-1/2/3/4/5 ✓ |
| Steering Kernel Proposals | ✓ | sprint_commit · gate_next_actions · intake_triage (Kernel v0.4) | | Steering Kernel Proposals | ✓ | sprint_commit · gate_next_actions · intake_triage (Kernel v0.4) |
| AP2.2b | A2 Linear E2E ✓ (2026-07-27) | | AP2.2b | A2 Linear E2E ✓ (2026-07-27) |
| AP2.2c | A1 Reifegrad E2E ✓ (2026-07-27) |
| AP2.2ce | Referenz-Archetypen End-to-End ◐→✓ | | AP2.2ce | Referenz-Archetypen End-to-End ◐→✓ |
| AP2.1 | MVP-Abnahfe ✗→✓ | | AP2.1 | MVP-Abnahfe ✗→✓ |
| AP1.7b | Op-API Parität | | AP1.7b | Op-API Parität |

View File

@ -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 1b Specs Welle 1 finalisieren (A1,A2,B2a,B2b,B3) ◐
Phase 2 AP1.9d Methoden-Default-Ansichten (Anti-Todo-Wand) ◐ (Hints + Recurring) Phase 2 AP1.9d Methoden-Default-Ansichten (Anti-Todo-Wand) ◐ (Hints + Recurring)
Phase 3 AP2.2b A2 Linear End-to-End ✓ 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.2d B2b Product + B3 Sprint ✓
AP2.2e B2a Programm (optional vor AP2.1) AP2.2e B2a Programm (optional vor AP2.1)
Phase 4 AP1.7b Operational API — Pflege-Parität Phase 4 AP1.7b Operational API — Pflege-Parität

View File

@ -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 ? (
<EmptyState message="Noch keine Reifegrad-Stufen — unter Plan anlegen oder Starter-Kit nutzen." />
) : (
<>
{activeStage && (
<p className="maturity-stage-active muted">
Aktive Stufe: <strong>{activeStage.title}</strong>
</p>
)}
<ol className="item-list maturity-stage-list">
{stages.map((stage, index) => (
<li
key={stage.id}
className={
'list-item card-list-item maturity-stage-item' +
(stage.status === 'active' ? ' maturity-stage-item--active' : '') +
(stage.status === 'reached' ? ' maturity-stage-item--reached' : '')
}
>
<span className="maturity-stage-item__index muted">{index + 1}</span>
<div className="list-item-main">
<strong>{stage.title}</strong>
<span className="muted list-item-sub">
{MILESTONE_STATUS_LABELS[stage.status] || stage.status}
</span>
</div>
</li>
))}
</ol>
<p className="muted maturity-stage-hint">
Stufen und Übungen unter{' '}
<Link to={scopedPath('/control/journey', { initiativeId })} className="link-inline">
Kontrolle Rhythmen & Journey
</Link>
.
</p>
</>
)
if (!embedded) return body
return (
<section className="card maturity-stage-panel">
<div className="section-header">
<div>
<h2>Reifegrad-Stufen</h2>
<p className="section-lead muted">
Aktive Stufe und Fortschritt Steuerungshorizont für Reifegrad-Vorhaben (A1).
</p>
</div>
</div>
{body}
</section>
)
}

View File

@ -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 ? (
<EmptyState message="Noch keine Rhythmen — Starter-Kit oder Journey legt Übungen an." />
) : (
<>
{active.length > 0 && (
<p className="recurring-rhythm-summary muted">
{active.length} aktive Routine{active.length === 1 ? '' : 'n'} fällige Übung steuert
Next Action.
</p>
)}
<ul className="item-list recurring-rhythm-list">
{sorted.map((item) => (
<li
key={item.id}
className={
'list-item card-list-item recurring-rhythm-item' +
(item.status === 'active' ? ' recurring-rhythm-item--active' : '')
}
>
<div className="list-item-main">
<strong>{item.title}</strong>
<span className="muted list-item-sub">
{RECURRING_STATUS_LABELS[item.status] || item.status}
{item.next_due_at ? ` · Fällig: ${formatDueAt(item.next_due_at)}` : ''}
</span>
</div>
</li>
))}
</ul>
<p className="muted recurring-rhythm-hint">
Rhythmen pflegen unter{' '}
<Link to={scopedPath('/control/journey', { initiativeId })} className="link-inline">
Kontrolle Journey
</Link>
.
</p>
</>
)
if (!embedded) return body
return (
<section className="card recurring-rhythm-panel">
<div className="section-header">
<div>
<h2>Rhythmen & Übungen</h2>
<p className="section-lead muted">
Aktive Routinen am Reifegrad-Pfad Leading Next aus fälliger Übung (A1).
</p>
</div>
</div>
{body}
</section>
)
}

View File

@ -25,6 +25,8 @@ export function InitiativeCompositionSurface({
() => ({ () => ({
initiativeId: ops.initiativeId, initiativeId: ops.initiativeId,
actions: ops.actions, actions: ops.actions,
roadmapItems: ops.roadmapItems,
recurringItems: ops.recurringItems,
steeringSnapshotLoading: ops.steeringSnapshotLoading, steeringSnapshotLoading: ops.steeringSnapshotLoading,
steeringSnapshotError: ops.steeringSnapshotError, steeringSnapshotError: ops.steeringSnapshotError,
steeringMethods: ops.steeringMethods, steeringMethods: ops.steeringMethods,
@ -36,6 +38,8 @@ export function InitiativeCompositionSurface({
[ [
ops.initiativeId, ops.initiativeId,
ops.actions, ops.actions,
ops.roadmapItems,
ops.recurringItems,
ops.steeringSnapshotLoading, ops.steeringSnapshotLoading,
ops.steeringSnapshotError, ops.steeringSnapshotError,
ops.steeringMethods, ops.steeringMethods,

View File

@ -57,6 +57,26 @@ export const COMPOSITION_PROVIDERS = [
scopeTypes: ['initiative'], scopeTypes: ['initiative'],
order: 10, 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', key: 'steering.next_action',
kind: 'steering_element', kind: 'steering_element',

View File

@ -2,6 +2,8 @@ import { Link } from 'react-router-dom'
import { scopedPath } from '../utils/routes.js' import { scopedPath } from '../utils/routes.js'
import { SteeringSnapshotPanel } from '../components/SteeringSnapshotPanel.jsx' import { SteeringSnapshotPanel } from '../components/SteeringSnapshotPanel.jsx'
import { CriticalPathPanel } from '../components/CriticalPathPanel.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 { AgentSlotsPanel } from '../components/AgentSlotsPanel.jsx'
import { SteeringProposalsPanel } from '../components/SteeringProposalsPanel.jsx' import { SteeringProposalsPanel } from '../components/SteeringProposalsPanel.jsx'
import { NextActionWidget } from '../widgets/NextActionWidget.jsx' import { NextActionWidget } from '../widgets/NextActionWidget.jsx'
@ -34,6 +36,8 @@ export function WidgetHost({ widget }) {
export const PROVIDER_COMPONENTS = { export const PROVIDER_COMPONENTS = {
SteeringSnapshotPanel, SteeringSnapshotPanel,
CriticalPathPanel, CriticalPathPanel,
MaturityStagePanel,
RecurringRhythmPanel,
NextActionWidget, NextActionWidget,
AgentSlotsPanel, AgentSlotsPanel,
SteeringProposalsPanel, SteeringProposalsPanel,

View File

@ -77,7 +77,14 @@ function resolveAgentSlots(input) {
* @param {object | null | undefined} steeringSnapshot * @param {object | null | undefined} steeringSnapshot
*/ */
export function resolveNextActionUi(elements, 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) const hasActiveSprint = Boolean(steeringSnapshot?.active_work_cycle)
for (const key of priority) { for (const key of priority) {
if (key === 'work_cycle_scope' && !hasActiveSprint) continue if (key === 'work_cycle_scope' && !hasActiveSprint) continue
@ -165,6 +172,16 @@ export function buildProviderProps(provider, input) {
scopeRoadmapItemId: kernel?.horizon?.gate_roadmap_item_id ?? null, 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': { case 'NextActionWidget': {
const nextActionUi = resolveNextActionUi(elements, input.steeringSnapshot) const nextActionUi = resolveNextActionUi(elements, input.steeringSnapshot)
return { return {

View File

@ -215,7 +215,33 @@ describe('resolveSteeringComposition', () => {
expect(props.scopeRoadmapItemId).toBe('gate-1') 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 provider = COMPOSITION_PROVIDERS.find((p) => p.key === 'core.steering_snapshot')
const withGate = buildProviderProps(provider, { const withGate = buildProviderProps(provider, {
steeringElements: ['gate_fulfillment', 'next_action_primary'], steeringElements: ['gate_fulfillment', 'next_action_primary'],

View File

@ -14,6 +14,15 @@ export const STEERING_ELEMENT_UI = {
nextActionSubtitle: nextActionSubtitle:
'Empfehlung aus dem Sprint-Backlog — Continuous Next außerhalb des Sprints.', '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: { gate_fulfillment: {
nextActionTitle: 'Nächster sinnvoller Schritt', nextActionTitle: 'Nächster sinnvoller Schritt',
nextActionSubtitle: nextActionSubtitle: