From e25ff86420aa0dd1fc3017f8aa7fc35f36379be6 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 5 Jul 2026 15:32:33 +0200 Subject: [PATCH] =?UTF-8?q?AP0.10a:=20OM-Integration=20=E2=80=94=20Steerin?= =?UTF-8?q?g-Snapshot,=20Transitions,=20Verkn=C3=BCpfungen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Beantwortet die Lücke zwischen parallelen Tabellen und späterem Steering Core: Graph-Read-Model, minimale Flows, sichtbarer Steuerungszustand. Co-authored-by: Cursor --- README.md | 12 +- backend/data_layer/attention.py | 37 ++ backend/data_layer/initiative_snapshot.py | 349 ++++++++++++++++++ backend/routers/initiatives.py | 14 + backend/services/blockers.py | 10 + backend/services/operating_transitions.py | 147 ++++++++ backend/services/reviews.py | 10 + backend/tests/test_ap10_integration.py | 90 +++++ backend/version.py | 2 +- ...ro_Operating_Model_Steering_Bridge_v0.1.md | 146 ++++++++ .../sprints/Sprint0_AP0_10_Assignment_v0.1.md | 50 +++ frontend/package.json | 2 +- frontend/src/api/initiatives.js | 4 + .../src/components/SteeringSnapshotPanel.jsx | 51 +++ frontend/src/constants/operating.js | 23 ++ frontend/src/pages/InitiativeDetailPage.jsx | 57 ++- frontend/src/styles/components.css | 35 ++ 17 files changed, 1034 insertions(+), 5 deletions(-) create mode 100644 backend/data_layer/initiative_snapshot.py create mode 100644 backend/services/operating_transitions.py create mode 100644 backend/tests/test_ap10_integration.py create mode 100644 docs/architecture/Kairo_Operating_Model_Steering_Bridge_v0.1.md create mode 100644 docs/sprints/Sprint0_AP0_10_Assignment_v0.1.md create mode 100644 frontend/src/components/SteeringSnapshotPanel.jsx create mode 100644 frontend/src/constants/operating.js diff --git a/README.md b/README.md index 3e5c567..fbac34a 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,17 @@ Operating Model Extension II (AP0.9, Schema `008`): Capabilities gesamt nach AP0.9: **33**. -Zielarchitektur-Einordnung: AP0.9 ist Schritt 7 der Evolutionslinie (Evidence/Review/Decision/Recurring) — siehe `docs/sprints/Sprint0_AP0_9_Assignment_v0.1.md`. +Operating Model Integration (AP0.10, Schema weiter `008`): + +| Endpoint | Methode | Capability | Beschreibung | +|----------|---------|------------|--------------| +| `/api/initiatives/{id}/steering-snapshot` | GET | `kairo.initiative.read` | Verknüpfter Graph + Operating Phase | + +**Operating-Transitions:** Blocker gelöst → Maßnahme entblocken; Review abgeschlossen → Maßnahme `review_required` → `done` + +**Attention Regel 10:** `action_review_required` — Maßnahme ohne geplantes Review + +Version nach AP0.10: **`0.10.0-ap0.10`**. Brücke zum Steering Core: `docs/architecture/Kairo_Operating_Model_Steering_Bridge_v0.1.md` Tenant-Invarianten: `docs/architecture/Kairo_Tenant_Invariants_v0.1.md` `docs/architecture/Kairo_Tenant_Invariants_v0.1.md` diff --git a/backend/data_layer/attention.py b/backend/data_layer/attention.py index 6f217f1..2b043f5 100644 --- a/backend/data_layer/attention.py +++ b/backend/data_layer/attention.py @@ -20,6 +20,7 @@ AttentionKind = Literal[ "overdue_action", "review_due", "recurring_due", + "action_review_required", ] NextActionKind = Literal[ @@ -275,6 +276,41 @@ def _milestones_at_risk(cur, ctx: TenantContext) -> list[dict[str, Any]]: return [_serialize_attention(dict(r)) for r in cur.fetchall()] +def _actions_review_required(cur, ctx: TenantContext) -> list[dict[str, Any]]: + cur.execute( + """ + SELECT + 'action_review_required' AS kind, + 'warning' AS severity, + a.title AS title, + 'Maßnahme wartet auf Review — kein geplantes Review verknüpft' AS summary, + 'action' AS scope_type, + a.id AS scope_id, + a.initiative_id, + a.id AS action_id, + NULL::uuid AS blocker_id, + NULL::uuid AS milestone_id, + NULL::uuid AS review_id, + NULL::uuid AS recurring_element_id, + 'action_review_required_no_review' AS reason_code, + 'actions' AS data_source + FROM actions a + WHERE a.tenant_id = %s + AND a.status = 'review_required' + AND NOT EXISTS ( + SELECT 1 FROM reviews r + WHERE r.tenant_id = a.tenant_id + AND r.action_id = a.id + AND r.status = 'planned' + ) + ORDER BY a.updated_at DESC + LIMIT 20 + """, + (ctx.tenant_id,), + ) + return [_serialize_attention(dict(r)) for r in cur.fetchall()] + + def _overdue_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]: cur.execute( """ @@ -389,6 +425,7 @@ def get_attention_items(ctx: TenantContext) -> list[dict[str, Any]]: items.extend(_stale_initiatives(cur, ctx)) items.extend(_milestones_at_risk(cur, ctx)) items.extend(_overdue_actions(cur, ctx)) + items.extend(_actions_review_required(cur, ctx)) items.extend(_reviews_due(cur, ctx)) items.extend(_recurring_due(cur, ctx)) diff --git a/backend/data_layer/initiative_snapshot.py b/backend/data_layer/initiative_snapshot.py new file mode 100644 index 0000000..e7bbcd9 --- /dev/null +++ b/backend/data_layer/initiative_snapshot.py @@ -0,0 +1,349 @@ +"""Initiative steering snapshot — verknüpftes Read-Model (AP0.10a). + +Baut aus flachen OM-Tabellen einen erklärbaren Graph für UI und spätere Steering Core. +""" + +from __future__ import annotations + +from typing import Any, Literal, Optional + +from psycopg2.extras import RealDictCursor + +from db import get_connection +from services.initiatives import get_initiative +from tenant_context import TenantContext + +OperatingPhase = Literal[ + "capture", + "triage", + "structure", + "commit", + "execute", + "verify", + "review", + "adapt", + "closure", +] + +OPEN_BLOCKER = ("open", "in_progress") +OPEN_ACTION = ("open", "ready", "in_progress", "blocked", "review_required") +ACTIVE_INITIATIVE = ("active", "paused") + + +def _sid(value: Any) -> Optional[str]: + return str(value) if value else None + + +def _iso(value: Any) -> Optional[str]: + return value.isoformat() if value else None + + +def _derive_operating_phase( + *, + initiative_status: str, + open_actions: int, + blocked_actions: int, + review_required_actions: int, + open_blockers: int, + backlog_new: int, + planned_reviews_due: int, +) -> tuple[OperatingPhase, list[str]]: + """Heuristische, erklärbare Phase — kein Lifecycle-State-Machine-Ersatz.""" + signals: list[str] = [] + + if initiative_status in ("completed", "archived"): + return "closure", ["initiative_terminal"] + + if open_actions == 0 and backlog_new > 0: + signals.append("backlog_awaiting_commit") + return "triage", signals + + if open_actions == 0 and backlog_new == 0: + signals.append("no_open_work") + return "structure", signals + + if blocked_actions > 0 or open_blockers > 0: + if blocked_actions: + signals.append("actions_blocked") + if open_blockers: + signals.append("open_blockers") + return "execute", signals + + if review_required_actions > 0 or planned_reviews_due > 0: + if review_required_actions: + signals.append("actions_need_review") + if planned_reviews_due: + signals.append("reviews_due") + return "review", signals + + if open_actions > 0: + signals.append("work_in_progress") + return "execute", signals + + return "adapt", signals + + +def get_initiative_steering_snapshot( + ctx: TenantContext, *, initiative_id: str +) -> Optional[dict[str, Any]]: + """Tenant-scoped Graph-Snapshot eines Vorhabens.""" + initiative = get_initiative(tenant_id=ctx.tenant_id, initiative_id=initiative_id) + if not initiative: + return None + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT id, title, description, status, priority, due_at, + created_at, updated_at + FROM actions + WHERE tenant_id = %s AND initiative_id = %s + ORDER BY updated_at DESC + """, + (ctx.tenant_id, initiative_id), + ) + actions_raw = [dict(r) for r in cur.fetchall()] + + cur.execute( + """ + SELECT id, action_id, milestone_id, title, status, created_at, updated_at + FROM blockers + WHERE tenant_id = %s AND initiative_id = %s + ORDER BY updated_at DESC + """, + (ctx.tenant_id, initiative_id), + ) + blockers = [dict(r) for r in cur.fetchall()] + + cur.execute( + """ + SELECT id, action_id, milestone_id, title, status, created_at, updated_at + FROM evidence + WHERE tenant_id = %s AND initiative_id = %s + ORDER BY updated_at DESC + """, + (ctx.tenant_id, initiative_id), + ) + evidence = [dict(r) for r in cur.fetchall()] + + cur.execute( + """ + SELECT id, action_id, milestone_id, title, status, due_at, + created_at, updated_at + FROM reviews + WHERE tenant_id = %s AND initiative_id = %s + ORDER BY updated_at DESC + """, + (ctx.tenant_id, initiative_id), + ) + reviews = [dict(r) for r in cur.fetchall()] + + cur.execute( + """ + SELECT id, title, status, priority, converted_action_id, created_at, updated_at + FROM backlog_items + WHERE tenant_id = %s AND initiative_id = %s + ORDER BY updated_at DESC + """, + (ctx.tenant_id, initiative_id), + ) + backlog = [dict(r) for r in cur.fetchall()] + + cur.execute( + """ + SELECT id, title, status, target_date, created_at, updated_at + FROM milestones + WHERE tenant_id = %s AND initiative_id = %s + ORDER BY updated_at DESC + """, + (ctx.tenant_id, initiative_id), + ) + milestones_raw = [dict(r) for r in cur.fetchall()] + + cur.execute( + """ + SELECT id, title, status, outcome, created_at, updated_at + FROM decisions + WHERE tenant_id = %s AND initiative_id = %s + ORDER BY updated_at DESC + """, + (ctx.tenant_id, initiative_id), + ) + decisions = [dict(r) for r in cur.fetchall()] + + cur.execute( + """ + SELECT id, title, status, next_due_at, created_at, updated_at + FROM recurring_elements + WHERE tenant_id = %s AND initiative_id = %s + ORDER BY updated_at DESC + """, + (ctx.tenant_id, initiative_id), + ) + recurring = [dict(r) for r in cur.fetchall()] + finally: + conn.close() + + blockers_by_action: dict[str, list[dict]] = {} + unlinked_blockers: list[dict] = [] + for b in blockers: + item = { + "id": _sid(b["id"]), + "title": b["title"], + "status": b["status"], + "action_id": _sid(b.get("action_id")), + "milestone_id": _sid(b.get("milestone_id")), + } + aid = item["action_id"] + if aid: + blockers_by_action.setdefault(aid, []).append(item) + else: + unlinked_blockers.append(item) + + evidence_by_action: dict[str, list[dict]] = {} + unlinked_evidence: list[dict] = [] + for e in evidence: + item = { + "id": _sid(e["id"]), + "title": e["title"], + "status": e["status"], + "action_id": _sid(e.get("action_id")), + "milestone_id": _sid(e.get("milestone_id")), + } + aid = item["action_id"] + if aid: + evidence_by_action.setdefault(aid, []).append(item) + else: + unlinked_evidence.append(item) + + reviews_by_action: dict[str, list[dict]] = {} + reviews_by_milestone: dict[str, list[dict]] = {} + unlinked_reviews: list[dict] = [] + planned_reviews_due = 0 + for r in reviews: + item = { + "id": _sid(r["id"]), + "title": r["title"], + "status": r["status"], + "due_at": _iso(r.get("due_at")), + "action_id": _sid(r.get("action_id")), + "milestone_id": _sid(r.get("milestone_id")), + } + if r["status"] == "planned" and r.get("due_at"): + planned_reviews_due += 1 + aid, mid = item["action_id"], item["milestone_id"] + if aid: + reviews_by_action.setdefault(aid, []).append(item) + elif mid: + reviews_by_milestone.setdefault(mid, []).append(item) + else: + unlinked_reviews.append(item) + + actions: list[dict[str, Any]] = [] + open_actions = blocked_actions = review_required_actions = 0 + for a in actions_raw: + aid = _sid(a["id"]) + status = a["status"] + if status in OPEN_ACTION: + open_actions += 1 + if status == "blocked": + blocked_actions += 1 + if status == "review_required": + review_required_actions += 1 + action_blockers = blockers_by_action.get(aid, []) + open_blocker_count = sum(1 for b in action_blockers if b["status"] in OPEN_BLOCKER) + actions.append( + { + "id": aid, + "title": a["title"], + "status": status, + "priority": a["priority"], + "due_at": _iso(a.get("due_at")), + "blockers": action_blockers, + "evidence": evidence_by_action.get(aid, []), + "reviews": reviews_by_action.get(aid, []), + "open_blocker_count": open_blocker_count, + "has_open_blocker": open_blocker_count > 0, + } + ) + + milestones = [] + for m in milestones_raw: + mid = _sid(m["id"]) + milestones.append( + { + "id": mid, + "title": m["title"], + "status": m["status"], + "target_date": m["target_date"].isoformat() if m.get("target_date") else None, + "reviews": reviews_by_milestone.get(mid, []), + } + ) + + open_blockers = sum( + 1 for b in blockers if b["status"] in OPEN_BLOCKER + ) + backlog_new = sum(1 for bi in backlog if bi["status"] in ("new", "triaged", "accepted")) + + phase, phase_signals = _derive_operating_phase( + initiative_status=initiative["status"], + open_actions=open_actions, + blocked_actions=blocked_actions, + review_required_actions=review_required_actions, + open_blockers=open_blockers, + backlog_new=backlog_new, + planned_reviews_due=planned_reviews_due, + ) + + return { + "initiative_id": initiative_id, + "initiative_title": initiative["title"], + "initiative_status": initiative["status"], + "operating_phase": phase, + "phase_signals": phase_signals, + "counts": { + "actions_open": open_actions, + "actions_blocked": blocked_actions, + "actions_review_required": review_required_actions, + "blockers_open": open_blockers, + "backlog_open": backlog_new, + "decisions": len(decisions), + "reviews_planned_due": planned_reviews_due, + "recurring_active": sum(1 for r in recurring if r["status"] == "active"), + "unlinked_blockers": len(unlinked_blockers), + "unlinked_evidence": len(unlinked_evidence), + "unlinked_reviews": len(unlinked_reviews), + }, + "actions": actions, + "milestones": milestones, + "initiative_level": { + "blockers": unlinked_blockers, + "evidence": unlinked_evidence, + "reviews": unlinked_reviews, + "decisions": [ + {"id": _sid(d["id"]), "title": d["title"], "status": d["status"]} + for d in decisions + ], + "backlog": [ + { + "id": _sid(b["id"]), + "title": b["title"], + "status": b["status"], + "converted_action_id": _sid(b.get("converted_action_id")), + } + for b in backlog + ], + "recurring": [ + { + "id": _sid(r["id"]), + "title": r["title"], + "status": r["status"], + "next_due_at": _iso(r.get("next_due_at")), + } + for r in recurring + ], + }, + "data_source": "initiative_steering_snapshot", + } diff --git a/backend/routers/initiatives.py b/backend/routers/initiatives.py index 2377434..1ecb834 100644 --- a/backend/routers/initiatives.py +++ b/backend/routers/initiatives.py @@ -6,6 +6,7 @@ from datetime import datetime from typing import Literal, Optional from capabilities import require_capability +from data_layer import initiative_snapshot as dl_initiative_snapshot from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from services import actions as action_service @@ -154,6 +155,19 @@ def get_initiative( return item +@router.get("/{initiative_id}/steering-snapshot") +def get_initiative_steering_snapshot( + initiative_id: str, + ctx: TenantContext = Depends(require_capability("kairo.initiative.read")), +): + snapshot = dl_initiative_snapshot.get_initiative_steering_snapshot( + ctx, initiative_id=initiative_id + ) + if not snapshot: + raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden") + return snapshot + + @router.patch("/{initiative_id}") def update_initiative( initiative_id: str, diff --git a/backend/services/blockers.py b/backend/services/blockers.py index 20c67fb..83ab22b 100644 --- a/backend/services/blockers.py +++ b/backend/services/blockers.py @@ -264,6 +264,16 @@ def update_blocker( "to_status": status, }, ) + from services.operating_transitions import after_blocker_status_change + + after_blocker_status_change( + tenant_id=tenant_id, + blocker_id=blocker_id, + action_id=result.get("action_id"), + old_status=old_status, + new_status=status, + user_id=user_id, + ) return result diff --git a/backend/services/operating_transitions.py b/backend/services/operating_transitions.py new file mode 100644 index 0000000..4c37f8f --- /dev/null +++ b/backend/services/operating_transitions.py @@ -0,0 +1,147 @@ +"""Minimale Operating-Transitions — Vorstufe zum Steering Orchestrator (AP0.10a). + +Side Effects bei Statusänderungen. Später: Hook Registry + Method Strategies. +""" + +from __future__ import annotations + +from typing import Any, Optional + +from psycopg2.extras import RealDictCursor + +from db import get_connection + +RESOLVED_BLOCKER_STATUSES = frozenset({"resolved", "accepted_risk", "dismissed"}) + + +def after_blocker_status_change( + *, + tenant_id: str, + blocker_id: str, + action_id: Optional[str], + old_status: str, + new_status: str, + user_id: Optional[str] = None, +) -> list[dict[str, Any]]: + """Blocker geschlossen → verknüpfte blockierte Maßnahme entblocken.""" + effects: list[dict[str, Any]] = [] + if new_status == old_status: + return effects + if new_status not in RESOLVED_BLOCKER_STATUSES or not action_id: + return effects + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + SELECT id, status FROM actions + WHERE id = %s AND tenant_id = %s + """, + (action_id, tenant_id), + ) + action = cur.fetchone() + if not action or action["status"] != "blocked": + return effects + + cur.execute( + """ + SELECT COUNT(*) FROM blockers + WHERE tenant_id = %s AND action_id = %s + AND id != %s AND status IN ('open', 'in_progress') + """, + (tenant_id, action_id, blocker_id), + ) + remaining = cur.fetchone()[0] + if remaining > 0: + effects.append( + { + "kind": "action_still_blocked", + "action_id": str(action_id), + "reason": "other_open_blockers", + } + ) + return effects + + cur.execute( + """ + UPDATE actions SET status = 'open', updated_at = NOW() + WHERE id = %s AND tenant_id = %s AND status = 'blocked' + RETURNING id + """, + (action_id, tenant_id), + ) + if cur.fetchone(): + effects.append( + { + "kind": "action_unblocked", + "action_id": str(action_id), + "trigger": "blocker_resolved", + "blocker_id": blocker_id, + } + ) + conn.commit() + finally: + conn.close() + + if effects and user_id: + from services.audit import log_audit + + log_audit( + "operating_transition.action_unblocked", + user_id=user_id, + tenant_id=tenant_id, + details={"effects": effects, "blocker_id": blocker_id}, + ) + return effects + + +def after_review_status_change( + *, + tenant_id: str, + review_id: str, + action_id: Optional[str], + old_status: str, + new_status: str, + user_id: Optional[str] = None, +) -> list[dict[str, Any]]: + """Review abgeschlossen → Maßnahme aus review_required weiterführen.""" + effects: list[dict[str, Any]] = [] + if new_status == old_status or new_status != "completed" or not action_id: + return effects + + conn = get_connection() + try: + with conn.cursor(cursor_factory=RealDictCursor) as cur: + cur.execute( + """ + UPDATE actions + SET status = 'done', updated_at = NOW() + WHERE id = %s AND tenant_id = %s AND status = 'review_required' + RETURNING id + """, + (action_id, tenant_id), + ) + if cur.fetchone(): + effects.append( + { + "kind": "action_completed_after_review", + "action_id": str(action_id), + "trigger": "review_completed", + "review_id": review_id, + } + ) + conn.commit() + finally: + conn.close() + + if effects and user_id: + from services.audit import log_audit + + log_audit( + "operating_transition.action_completed_after_review", + user_id=user_id, + tenant_id=tenant_id, + details={"effects": effects, "review_id": review_id}, + ) + return effects diff --git a/backend/services/reviews.py b/backend/services/reviews.py index e7f2a6d..d16f954 100644 --- a/backend/services/reviews.py +++ b/backend/services/reviews.py @@ -309,6 +309,16 @@ def update_review( "to_status": status, }, ) + from services.operating_transitions import after_review_status_change + + after_review_status_change( + tenant_id=tenant_id, + review_id=review_id, + action_id=result.get("action_id"), + old_status=old_status, + new_status=status, + user_id=user_id, + ) return result diff --git a/backend/tests/test_ap10_integration.py b/backend/tests/test_ap10_integration.py new file mode 100644 index 0000000..d896172 --- /dev/null +++ b/backend/tests/test_ap10_integration.py @@ -0,0 +1,90 @@ +"""AP0.10 — Integration: Snapshot, Transitions, Attention.""" + +from __future__ import annotations + +from tests.factories import provision_user_in_tenant +from tests.test_initiatives_actions import ( + _auth, + _create_action, + _create_initiative, + _login, +) + + +def test_steering_snapshot_graph(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + action = _create_action(client, token, initiative_id, title="Linked Action").json() + + blocker = client.post( + f"/api/initiatives/{initiative_id}/blockers", + json={"title": "B", "action_id": action["id"]}, + headers=_auth(token), + ) + assert blocker.status_code == 201 + + snap = client.get( + f"/api/initiatives/{initiative_id}/steering-snapshot", + headers=_auth(token), + ) + assert snap.status_code == 200 + data = snap.json() + assert data["operating_phase"] in ( + "execute", + "triage", + "structure", + "review", + "adapt", + "commit", + "capture", + "verify", + "closure", + ) + linked = next(a for a in data["actions"] if a["id"] == action["id"]) + assert len(linked["blockers"]) == 1 + assert linked["open_blocker_count"] == 1 + + +def test_blocker_resolve_unblocks_action(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + action = _create_action(client, token, initiative_id).json()["id"] + + created = client.post( + f"/api/initiatives/{initiative_id}/blockers", + json={ + "title": "Block", + "action_id": action, + "set_action_blocked": True, + }, + headers=_auth(token), + ) + blocker_id = created.json()["id"] + blocked = client.get(f"/api/actions/{action}", headers=_auth(token)).json() + assert blocked["status"] == "blocked" + + client.patch( + f"/api/blockers/{blocker_id}", + json={"status": "resolved"}, + headers=_auth(token), + ) + unblocked = client.get(f"/api/actions/{action}", headers=_auth(token)).json() + assert unblocked["status"] == "open" + + +def test_attention_action_review_required_without_review(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + action = _create_action(client, token, initiative_id).json() + client.patch( + f"/api/actions/{action['id']}", + json={"status": "review_required"}, + headers=_auth(token), + ) + + res = client.get("/api/workspace/attention", headers=_auth(token)) + assert res.status_code == 200 + assert any(i["kind"] == "action_review_required" for i in res.json()) diff --git a/backend/version.py b/backend/version.py index 2d6adbe..8fdd795 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "0.9.0-ap0.9" +APP_VERSION = "0.10.0-ap0.10" DB_SCHEMA_VERSION = "008" APP_NAME = "jinkendo-kairo" diff --git a/docs/architecture/Kairo_Operating_Model_Steering_Bridge_v0.1.md b/docs/architecture/Kairo_Operating_Model_Steering_Bridge_v0.1.md new file mode 100644 index 0000000..f21ea3c --- /dev/null +++ b/docs/architecture/Kairo_Operating_Model_Steering_Bridge_v0.1.md @@ -0,0 +1,146 @@ +# Kairo — Operating Model → Steering Bridge + +**Status:** Architektur-Einordnung +**Stand:** 2026-07-05 +**Zweck:** Ehrliche Antwort auf die Frage, ob AP0.8/0.9 auf spätere Steuerungslogik einzahlen — und was noch fehlt. + +--- + +## 1. Kurzantwort + +**Ja, aber indirekt.** AP0.8/0.9 haben das **Vokabular und die Persistenzschicht** des kanonischen Operating Models geliefert — nicht den **Steuerungsgraph**, nicht den **Lifecycle-Orchestrator** und nicht die **Method Registry**. + +Das ist kein Versehen, sondern die dokumentierte Reihenfolge: + +```text +Foundation (AP0.1–0.7) + → Operating Model Entitäten (AP0.8–0.9) ← wir sind hier + → Integration & Validation (AP0.10) + → Steering Core Foundation (Sprint 1 / AP1.x) + → Method Registry, Hooks, Lifecycle Runtime +``` + +Ohne die Entitäten hätte Steering nichts zu lesen und zu steuern. +Mit nur den Entitäten **ohne Verbindungslogik** wirkt Kairo zu Recht wie eine Sammlung paralleler Tabellen. + +--- + +## 2. Was AP0.8/0.9 tatsächlich vorbereitet + +| Baustein | Beitrag für späteres Steering | Noch nicht da | +|----------|------------------------------|---------------| +| **Entitäten** | Blocker, Backlog, Milestone, Evidence, Decision, Review, Recurring | SteeringContext, Project, Dependency | +| **Schwache FKs** | `blocker.action_id`, `evidence.action_id`, `review.action_id` | Pflicht-Struktur, Graph-Traversierung in UI | +| **Attention (Regeln 1–9)** | Proto-Signal-Engine, erklärbare DTOs | Methoden-spezifische Signal Rules, Ranking | +| **NextActionCandidates** | Regelbasierte Empfehlungen | Hook `on_next_action_requested`, Strategien | +| **Audit-Events** | Nachvollziehbarkeit für Agenten/Steering | Lifecycle-Audit-Kette | +| **Actor-Assignments** | Wer handelt | Waiting/Reminder/Escalation | +| **Status-Enums** | Anknüpfpunkte für Lifecycle-Transitions | State Machine + Guards | + +**Fazit:** AP0.8/0.9 = **System of Record für OM-Objekte** + **regelbasierte Signale**. +Das ist Schritt 5–7 der Evolutionslinie (`Kairo_System_Target_State_v0.1.md` §24), **nicht** Schritt 2–4 (Steering Core, Method Registry, Lifecycle). + +--- + +## 3. Kanonisches Modell vs. Ist-Implementierung + +### Soll (Canonical Operating Model) + +```text +Initiative + ├── Milestone + ├── BacklogItem + ├── Action + │ ├── Assignment + │ ├── Blocker ← primär an Maßnahme + │ └── Evidence ← primär an Maßnahme + ├── Decision + ├── Review + └── RecurringElement +``` + +### Ist (AP0.9) + +```text +Initiative + ├── [parallele Sektionen in UI] + ├── Action (Liste) + ├── Blocker (Liste, action_id optional) + ├── Evidence (Liste, action_id optional) + ├── … +``` + +**Lücke:** Objekte existieren, aber **Struktur und Flow sind nicht sichtbar**. +Der Nutzer sieht Listen, keinen **Steuerungszustand** eines Vorhabens. + +--- + +## 4. Was „Steuerungslogik“ später bedeutet + +Aus `Kairo_Target_Architecture_Method_Driven_Adaptive_Steering_Core_v0.1.md`: + +| Komponente | Rolle | +|------------|-------| +| **SteeringContext** | Bindet Initiative an Methode, Lifecycle, Domain | +| **Method Registry** | Liefert Structure Builder, Signal Rules, Strategien | +| **Standard Lifecycle** | intake → … → closure mit Guards | +| **Hook Registry** | `on_review_due`, `on_blocker_open`, `on_next_action_requested` | +| **Signal Engine** | Attention + NextAction aus Methode + Kontext | +| **Transition Orchestrator** | Statusänderung → Side Effects (unblock, review anstoßen) | + +AP0.8/0.9 **ersetzen** das nicht — sie liefern die **Daten**, an die Hooks und Regeln andocken. + +--- + +## 5. AP0.10 — Integration statt nur Validation + +AP0.10 wird daher **zweigeteilt**: + +### A) Operating Model Integration (technisch) + +- **Steering-Snapshot** Read-Model: Initiative als verknüpfter Graph (Actions mit Blockern/Evidence/Reviews) +- **Operating Cycle Phase** (heuristisch, erklärbar): triage / execute / verify / review / adapt +- **Transition-Orchestrierung minimal:** z. B. Blocker gelöst → verknüpfte Maßnahme entblocken; Review abgeschlossen → Maßnahme weiterführen +- **UI:** Verknüpfungen sichtbar (Maßnahme ↔ Blocker/Evidence), nicht nur parallele Sektionen +- **Attention:** Regeln, die **Beziehungen** nutzen (Maßnahme blockiert + offener Blocker auf derselben Maßnahme) + +### B) MVP Validation (fachlich) + +Testvorhaben aus Roadmap (Kairo, Gewaltschutzkurs, …) — Prüffragen: Hilft es bei täglicher Steuerung? Fehlt was **wirklich** als Nächstes? + +--- + +## 6. Evolutionspfad (konkret) + +```text +AP0.10 Integration + Validation ← nächster Schritt +AP1.0 backend/steering/ Foundation (SteeringContext, Lifecycle stub) +AP1.1 Method Registry minimal + Hook Registry +AP1.2 Signal Engine aus attention.py → steering/signals/ +AP1.3 Transition Orchestrator (ersetzt operating_transitions.py) +Sprint 2+ Dependencies, Waiting, Method Profiles +``` + +**Regel:** Keine neuen parallelen Tabellen ohne OM-Bezug. +Neue Arbeit muss either **verbinden**, **orchestratieren** oder **validieren**. + +--- + +## 7. Leitfrage-Check + +> Zahlt jede Entität auf „Welcher nächste Schritt bringt das Vorhaben voran?“ ein? + +| Entität | Heute | Nach AP0.10 | Nach Steering Core | +|---------|-------|-------------|-------------------| +| Action | ✓ Commit/Execute | ✓ mit Kontext | ✓ NextAction-Ziel | +| Blocker | △ Signal | ✓ an Maßnahme | ✓ Hook on_blocker_open | +| Backlog | ✓ Triage | ✓ Convert-Flow | ✓ Structure Builder | +| Milestone | △ Signal at_risk | ✓ mit Reviews | ✓ Roadmap-Lane | +| Evidence | △ passive | ✓ an Maßnahme | ✓ Verify-Phase | +| Review | △ due signal | ✓ schließt Execute | ✓ Review Strategy | +| Decision | △ passive | △ Adapt-Hinweis | ✓ Adapt-Hook | +| Recurring | △ due signal | △ Routine-Hinweis | ✓ routine_control | + +--- + +*v0.1 — Referenz für AP0.10 und Sprint-1-Planung* diff --git a/docs/sprints/Sprint0_AP0_10_Assignment_v0.1.md b/docs/sprints/Sprint0_AP0_10_Assignment_v0.1.md new file mode 100644 index 0000000..fa73323 --- /dev/null +++ b/docs/sprints/Sprint0_AP0_10_Assignment_v0.1.md @@ -0,0 +1,50 @@ +# AP0.10 — Operating Model Integration & MVP Validation +## Implementierungsauftrag v0.1 + +**Status:** freigegeben +**Stand:** 2026-07-05 +**Vorgänger:** AP0.9 ✓ +**Zielversion:** `0.10.0-ap0.10` (keine Schema-Migration) + +--- + +## Warum AP0.10 anders ist als AP0.8/0.9 + +AP0.8/0.9 lieferten **Entitäten + Signale**. +AP0.10 liefert **Verknüpfung + erklärbaren Zustand + minimale Flows** — Brücke zum Steering Core. + +Referenz: `docs/architecture/Kairo_Operating_Model_Steering_Bridge_v0.1.md` + +--- + +## Teil A — Integration (technisch) + +| Slice | Inhalt | +|-------|--------| +| 10a | Steering-Snapshot Read-Model + API | +| 10b | Operating-Transitions (Blocker→Action, Review→Action) | +| 10c | Attention Regel: review_required ohne Review | +| 10d | UI: Steuerungszustand + Verknüpfungen an Maßnahmen | + +## Teil B — Validation (fachlich) + +Testvorhaben aus Roadmap durchspielen, Lücken dokumentieren, AP1.0-Prioritäten ableiten. + +## Nicht-Scope + +- SteeringContext, Method Registry, Dependencies, Sprints +- UI-Redesign +- KI/MCP/Workflow + +--- + +## Abnahme Teil A + +- `GET /api/initiatives/{id}/steering-snapshot` liefert Graph + operating_phase +- Blocker resolved → verknüpfte Maßnahme entblockt (wenn keine weiteren offenen Blocker) +- Review completed → Maßnahme in review_required → done +- UI zeigt Phase + Maßnahmen-Kontext (Blocker/Evidence) + +--- + +*Freigegeben — Integration vor Validation.* diff --git a/frontend/package.json b/frontend/package.json index 8a8075d..c47ccd0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "kairo-jinkendo-frontend", - "version": "0.9.0-ap0.9", + "version": "0.10.0-ap0.10", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/api/initiatives.js b/frontend/src/api/initiatives.js index f5e79fb..dea76de 100644 --- a/frontend/src/api/initiatives.js +++ b/frontend/src/api/initiatives.js @@ -8,6 +8,10 @@ export function getInitiative(id) { return apiFetch(`/api/initiatives/${id}`) } +export function getInitiativeSteeringSnapshot(id) { + return apiFetch(`/api/initiatives/${id}/steering-snapshot`) +} + export function createInitiative(payload) { return apiFetch('/api/initiatives', { method: 'POST', diff --git a/frontend/src/components/SteeringSnapshotPanel.jsx b/frontend/src/components/SteeringSnapshotPanel.jsx new file mode 100644 index 0000000..7119172 --- /dev/null +++ b/frontend/src/components/SteeringSnapshotPanel.jsx @@ -0,0 +1,51 @@ +import { PHASE_LABELS } from '../constants/operating.js' + +export function SteeringSnapshotPanel({ snapshot }) { + if (!snapshot) return null + + const { operating_phase, phase_signals, counts } = snapshot + const phaseLabel = PHASE_LABELS[operating_phase] || operating_phase + + return ( +
+
+

Steuerungszustand

+ {phaseLabel} +
+ {phase_signals?.length > 0 && ( +

+ Signale: {phase_signals.join(' · ')} +

+ )} +
+
+
Offene Maßnahmen
+
{counts.actions_open}
+
+
+
Blockiert
+
{counts.actions_blocked}
+
+
+
Review nötig
+
{counts.actions_review_required}
+
+
+
Offene Blocker
+
{counts.blockers_open}
+
+ {(counts.unlinked_blockers > 0 || counts.unlinked_evidence > 0) && ( +
+
Unverknüpft
+
+ {counts.unlinked_blockers} Blocker, {counts.unlinked_evidence} Evidence +
+
+ )} +
+

+ Kanonischer Operating Cycle — noch heuristisch, später methodengeführt über Steering Core. +

+
+ ) +} diff --git a/frontend/src/constants/operating.js b/frontend/src/constants/operating.js new file mode 100644 index 0000000..cb6284d --- /dev/null +++ b/frontend/src/constants/operating.js @@ -0,0 +1,23 @@ +export const OPERATING_PHASES = [ + 'capture', + 'triage', + 'structure', + 'commit', + 'execute', + 'verify', + 'review', + 'adapt', + 'closure', +] + +export const PHASE_LABELS = { + capture: 'Erfassung', + triage: 'Einordnung', + structure: 'Struktur', + commit: 'Commit', + execute: 'Umsetzung', + verify: 'Nachweis', + review: 'Review', + adapt: 'Anpassung', + closure: 'Abschluss', +} diff --git a/frontend/src/pages/InitiativeDetailPage.jsx b/frontend/src/pages/InitiativeDetailPage.jsx index 3075dbc..cf6fc0c 100644 --- a/frontend/src/pages/InitiativeDetailPage.jsx +++ b/frontend/src/pages/InitiativeDetailPage.jsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react' import { Link, useParams } from 'react-router-dom' import { getInitiative, + getInitiativeSteeringSnapshot, listInitiativeActions, createInitiativeAction, } from '../api/initiatives.js' @@ -60,6 +61,7 @@ import { EvidenceSection } from '../components/EvidenceSection.jsx' import { DecisionsSection } from '../components/DecisionsSection.jsx' import { ReviewsSection } from '../components/ReviewsSection.jsx' import { RecurringSection } from '../components/RecurringSection.jsx' +import { SteeringSnapshotPanel } from '../components/SteeringSnapshotPanel.jsx' import { EmptyState } from '../components/EmptyState.jsx' import { ErrorState } from '../components/ErrorState.jsx' import { LoadingState } from '../components/LoadingState.jsx' @@ -88,6 +90,7 @@ export function InitiativeDetailPage() { const [decisions, setDecisions] = useState([]) const [reviews, setReviews] = useState([]) const [recurringItems, setRecurringItems] = useState([]) + const [steeringSnapshot, setSteeringSnapshot] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [showActionForm, setShowActionForm] = useState(false) @@ -119,6 +122,11 @@ export function InitiativeDetailPage() { loads.push(listInitiativeDecisions(id).then(setDecisions).catch(() => setDecisions([]))) loads.push(listInitiativeReviews(id).then(setReviews).catch(() => setReviews([]))) loads.push(listInitiativeRecurring(id).then(setRecurringItems).catch(() => setRecurringItems([]))) + loads.push( + getInitiativeSteeringSnapshot(id) + .then(setSteeringSnapshot) + .catch(() => setSteeringSnapshot(null)) + ) } await Promise.all(loads) } catch (err) { @@ -136,6 +144,10 @@ export function InitiativeDetailPage() { ? actions.filter((a) => a.status !== 'done' && a.status !== 'discarded') : actions + const actionContextById = Object.fromEntries( + (steeringSnapshot?.actions || []).map((a) => [a.id, a]) + ) + async function handleCreateAction(payload) { setFormBusy(true) try { @@ -200,6 +212,23 @@ export function InitiativeDetailPage() { } } + async function handleCreateBlockerForAction(actionId) { + if (!capabilities.has('kairo.blocker.manage')) return + setFormBusy(true) + try { + await createInitiativeBlocker(id, { + title: 'Blocker', + action_id: actionId, + set_action_blocked: true, + }) + await load() + } catch (err) { + setError(err.message) + } finally { + setFormBusy(false) + } + } + async function handleBlockerStatus(blockerId, status) { try { await updateBlocker(blockerId, { status }) @@ -453,6 +482,10 @@ export function InitiativeDetailPage() { {error &&

{error}

} + {capabilities.has('kairo.initiative.read') && ( + + )} +

Maßnahmen

@@ -498,7 +531,9 @@ export function InitiativeDetailPage() { )}
    - {visibleActions.map((action) => ( + {visibleActions.map((action) => { + const ctx = actionContextById[action.id] + return (
  • {editingAction === action.id ? ( )} + {ctx && (ctx.open_blocker_count > 0 || ctx.evidence?.length > 0 || ctx.reviews?.length > 0) && ( +

    + {ctx.open_blocker_count > 0 && `${ctx.open_blocker_count} Blocker · `} + {ctx.evidence?.length > 0 && `${ctx.evidence.length} Evidence · `} + {ctx.reviews?.length > 0 && `${ctx.reviews.length} Review(s)`} +

    + )}
@@ -548,6 +590,16 @@ export function InitiativeDetailPage() { ))} + {capabilities.has('kairo.blocker.manage') && action.status !== 'done' && ( + + )}
diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index 5a7ddf8..97d6ebd 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -597,3 +597,38 @@ font-size: 9px; } } + +.steering-snapshot .snapshot-counts { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr)); + gap: 0.75rem 1rem; + margin: 0.75rem 0 0; +} + +.steering-snapshot .snapshot-counts div { + margin: 0; +} + +.steering-snapshot .snapshot-counts dt { + font-size: 0.75rem; + color: var(--jk-muted, #666); + margin: 0; +} + +.steering-snapshot .snapshot-counts dd { + margin: 0.15rem 0 0; + font-weight: 600; +} + +.steering-snapshot .snapshot-warn dd { + color: var(--jk-warning, #b45309); +} + +.steering-snapshot .snapshot-hint { + font-size: 0.8rem; + margin: 0.75rem 0 0; +} + +.action-links { + font-size: 0.85rem; +}