AP0.8: Operating Model Extension I (Attention, Blocker, Backlog, Milestone)
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 58s
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 18s
Test Suite / playwright-smoke (push) Successful in 12s

Schema 007, regelbasierte Attention/NextAction im Data Layer, CRUD fuer Blocker/Backlog/Meilensteine, Workspace-Widget und Initiative-Detail-Sektionen. 25 Capabilities. Tests fuer Remote-Pytest auf Pi angepasst (conftest Session-Guard).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-07-05 15:11:15 +02:00
parent 6dd9dd4f62
commit fe3bc4f057
39 changed files with 4333 additions and 22 deletions

View File

@ -0,0 +1,27 @@
---
description: Deployment und Tests — kein lokaler Lauf; Verifikation über develop auf Raspberry
alwaysApply: true
---
# Kairo — Deployment & Tests
## Kein lokaler Test-/DB-Lauf
Backend-Tests (pytest), Migrationen und Integrationstests **können auf der Entwicklungsmaschine in der Regel nicht ausgeführt werden** — PostgreSQL/Docker ist lokal oft nicht verfügbar.
Versuche nicht wiederholt, lokale pytest-/Migration-Läufe als Abnahme zu erzwingen, wenn die DB nicht erreichbar ist.
## Verifikation über Remote-Deploy
Praktische Verifikation erfolgt durch **Deployment auf ein entferntes System (Raspberry Pi)**:
1. Änderungen **committen** und auf Branch **`develop`** pushen
2. Deployment wird dadurch ausgelöst (CI/Deploy-Pipeline auf dem Raspberry)
3. Dort laufen Migrationen, Backend-Tests und Smoke-Checks gegen echte Infrastruktur
## Konsequenz für Agenten
- Code und Tests **schreiben** und dokumentieren — aber fehlende lokale DB nicht als Implementierungsblocker behandeln
- Frontend: `npm test` / `vite build` lokal weiterhin sinnvoll, wenn Node verfügbar
- Backend: nach AP-Umsetzung Abschlussbericht mit „Remote-Verifikation nach Push auf develop“ kennzeichnen
- **Nicht** proaktiv committen/pushen, es sei denn, der Nutzer bittet explizit darum

View File

@ -189,7 +189,32 @@ Actor Directory und Workspace Data Layer (AP0.7):
| `/api/workspace/initiatives/active` | GET | `kairo.workspace.read` | Aktive/pausierte Vorhaben (`?limit=`) |
| `/api/workspace/actors/workload` | GET | `kairo.workspace.read` | Actor-Workload (Basic) |
Operating Model Extension I (AP0.8, Schema `007`):
| Endpoint | Methode | Capability | Beschreibung |
|----------|---------|------------|--------------|
| `/api/workspace/attention` | GET | `kairo.attention.read` | Regelbasierte Attention-Items |
| `/api/workspace/next-actions` | GET | `kairo.attention.read` | NextActionCandidates (`?limit=`) |
| `/api/initiatives/{id}/blockers` | GET/POST | `kairo.blocker.read/manage` | Blocker eines Vorhabens |
| `/api/blockers/{id}` | GET/PATCH/DELETE | `kairo.blocker.read/manage` | Einzelner Blocker |
| `/api/initiatives/{id}/backlog` | GET/POST | `kairo.backlog.read/manage` | Backlog-Items |
| `/api/backlog/{id}` | GET/PATCH/DELETE | `kairo.backlog.read/manage` | Einzelnes Backlog-Item |
| `/api/backlog/{id}/convert-to-action` | POST | `kairo.backlog.manage` | Backlog → Maßnahme |
| `/api/initiatives/{id}/milestones` | GET/POST | `kairo.milestone.read/manage` | Meilensteine |
| `/api/milestones/{id}` | GET/PATCH/DELETE | `kairo.milestone.read/manage` | Einzelner Meilenstein |
**Status Blocker:** `open`, `in_progress`, `resolved`, `accepted_risk`, `dismissed`
**Status Backlog:** `new`, `triaged`, `accepted`, `rejected`, `converted`
**Status Meilenstein:** `planned`, `active`, `at_risk`, `reached`, `moved`, `discarded`
Capabilities gesamt nach AP0.8: **25** (inkl. `kairo.attention.read` und Blocker/Backlog/Milestone read/manage).
Zielarchitektur-Einordnung: AP0.8 ist Schritt 5 der Evolutionslinie (Signal/NextAction) und Operating-Model-Phase A — siehe `docs/architecture/Kairo_System_Target_State_v0.1.md` und `Sprint0_AP0_8_Assignment_v0.2.md`.
Tenant-Invarianten: `docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
`docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
**Status Vorhaben:** `active`, `paused`, `completed`, `archived`
@ -218,9 +243,9 @@ Nach Login leitet Kairo auf den **Workspace** weiter.
| Route | Beschreibung |
|-------|--------------|
| `/workspace` | Karten: Kontext, Überblick, offene/blockierte Maßnahmen, aktive Vorhaben |
| `/workspace` | Karten: Kontext, Überblick, **Aufmerksamkeit**, offene/blockierte Maßnahmen, aktive Vorhaben |
| `/initiatives` | Vorhabenliste mit offenen Maßnahmen-Zähler |
| `/initiatives/:id` | Vorhaben-Detail mit Maßnahmen CRUD, Status, Zuweisung |
| `/initiatives/:id` | Vorhaben-Detail: Maßnahmen, **Meilensteine**, **Backlog**, **Blocker** |
| `/my-actions` | Alle offenen Maßnahmen des aktuellen Actors |
**App Shell (AP0.6b):** Desktop-Sidebar (≥1024px), Mobile-Header + Bottom-Navigation (<1024px), Tenant-/Actor-Kontext, Jinkendo-Family-Design-Tokens (`--jk-*`).
@ -246,8 +271,9 @@ cd frontend && npm install && npm run test && npm run build
5. Responsive: DevTools Viewports **1440px**, **1024px**, **390px** — keine horizontale Scrollbar, Bottom-Nav sichtbar unter 1024px
6. PWA: Manifest unter `/manifest.webmanifest` erreichbar; „App installieren“ im Browser prüfbar
7. AP0.7: ActorSelect lädt `/api/actors`; Maßnahme einem Agent zuweisen; Workspace-Überblick sichtbar
8. AP0.8: Attention-Widget auf Workspace; Vorhaben-Detail mit Blocker/Backlog/Meilenstein; Backlog → Maßnahme konvertieren
Abschlussberichte: `docs/sprints/Sprint0_AP0_6_Completion_Report_v0.1.md`, `docs/sprints/Sprint0_AP0_6b_Completion_Report_v0.2.md`, `docs/sprints/Sprint0_AP0_7_Completion_Report_v0.2.md`
Abschlussberichte: `docs/sprints/Sprint0_AP0_6_Completion_Report_v0.1.md`, `docs/sprints/Sprint0_AP0_6b_Completion_Report_v0.2.md`, `docs/sprints/Sprint0_AP0_7_Completion_Report_v0.2.md`, `docs/sprints/Sprint0_AP0_8_Completion_Report_v0.1.md`
### Registries (AP0.4)

View File

@ -0,0 +1,418 @@
"""Attention and NextAction read-models — regelbasiert, tenant-scoped (AP0.8a)."""
from __future__ import annotations
from typing import Any, Literal, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from tenant_context import TenantContext
AttentionKind = Literal[
"blocked_action",
"open_blocker",
"high_priority_action",
"unassigned_action",
"initiative_without_next_action",
"stale_initiative",
"milestone_at_risk",
]
NextActionKind = Literal[
"assign_action",
"resolve_blocker",
"create_action",
"convert_backlog",
"review_milestone",
]
Severity = Literal["info", "warning", "critical"]
_SEVERITY_ORDER = {"critical": 0, "warning": 1, "info": 2}
OPEN_BLOCKER_STATUSES = ("open", "in_progress")
OPEN_ACTION_STATUSES = ("open", "in_progress", "blocked")
ACTIVE_INITIATIVE_STATUSES = ("active", "paused")
def _serialize_attention(row: dict[str, Any]) -> dict[str, Any]:
item = dict(row)
for key in ("scope_id", "initiative_id", "action_id", "blocker_id", "milestone_id"):
if item.get(key):
item[key] = str(item[key])
return item
def _blocked_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
cur.execute(
"""
SELECT
'blocked_action' AS kind,
'critical' AS severity,
a.title AS title,
'Maßnahme ist blockiert' 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,
'action_blocked' AS reason_code,
'actions' AS data_source
FROM actions a
WHERE a.tenant_id = %s AND a.status = 'blocked'
ORDER BY a.updated_at DESC
LIMIT 50
""",
(ctx.tenant_id,),
)
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
def _open_blockers(cur, ctx: TenantContext) -> list[dict[str, Any]]:
cur.execute(
"""
SELECT
'open_blocker' AS kind,
'warning' AS severity,
b.title AS title,
'Offener Blocker im Vorhaben' AS summary,
'blocker' AS scope_type,
b.id AS scope_id,
b.initiative_id,
b.action_id,
b.id AS blocker_id,
NULL::uuid AS milestone_id,
'blocker_open' AS reason_code,
'blockers' AS data_source
FROM blockers b
WHERE b.tenant_id = %s AND b.status IN ('open', 'in_progress')
ORDER BY b.updated_at DESC
LIMIT 50
""",
(ctx.tenant_id,),
)
items = []
for row in cur.fetchall():
item = _serialize_attention(dict(row))
if item.get("action_id"):
item["action_id"] = str(item["action_id"])
items.append(item)
return items
def _high_priority_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
actor_filter = ""
params: list[Any] = [ctx.tenant_id]
if ctx.actor_id:
actor_filter = """
AND EXISTS (
SELECT 1 FROM action_assignments aa
WHERE aa.action_id = a.id
AND aa.tenant_id = a.tenant_id
AND aa.actor_id = %s
)
"""
params.append(ctx.actor_id)
else:
return []
cur.execute(
f"""
SELECT
'high_priority_action' AS kind,
'warning' AS severity,
a.title AS title,
'High-Priority Maßnahme offen' 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,
'action_high_priority_open' AS reason_code,
'actions' AS data_source
FROM actions a
WHERE a.tenant_id = %s
AND a.priority = 'high'
AND a.status IN ('open', 'in_progress')
{actor_filter}
ORDER BY a.updated_at DESC
LIMIT 30
""",
tuple(params),
)
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
def _unassigned_actions(cur, ctx: TenantContext) -> list[dict[str, Any]]:
cur.execute(
"""
SELECT
'unassigned_action' AS kind,
'warning' AS severity,
a.title AS title,
'Maßnahme ohne Zuweisung' 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,
'action_unassigned' AS reason_code,
'actions' AS data_source
FROM actions a
WHERE a.tenant_id = %s
AND a.status IN ('open', 'in_progress')
AND NOT EXISTS (
SELECT 1 FROM action_assignments aa
WHERE aa.action_id = a.id AND aa.tenant_id = a.tenant_id
)
ORDER BY a.updated_at DESC
LIMIT 30
""",
(ctx.tenant_id,),
)
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
def _initiatives_without_next_action(cur, ctx: TenantContext) -> list[dict[str, Any]]:
cur.execute(
"""
SELECT
'initiative_without_next_action' AS kind,
'info' AS severity,
i.title AS title,
'Keine offene nächste Maßnahme' AS summary,
'initiative' AS scope_type,
i.id AS scope_id,
i.id AS initiative_id,
NULL::uuid AS action_id,
NULL::uuid AS blocker_id,
NULL::uuid AS milestone_id,
'initiative_no_open_action' AS reason_code,
'initiatives' AS data_source
FROM initiatives i
WHERE i.tenant_id = %s
AND i.status IN ('active', 'paused')
AND NOT EXISTS (
SELECT 1 FROM actions a
WHERE a.initiative_id = i.id
AND a.tenant_id = i.tenant_id
AND a.status IN ('open', 'in_progress', 'blocked')
)
ORDER BY i.updated_at DESC
LIMIT 30
""",
(ctx.tenant_id,),
)
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
def _stale_initiatives(cur, ctx: TenantContext) -> list[dict[str, Any]]:
cur.execute(
"""
SELECT
'stale_initiative' AS kind,
'info' AS severity,
i.title AS title,
'Vorhaben seit über 14 Tagen unverändert' AS summary,
'initiative' AS scope_type,
i.id AS scope_id,
i.id AS initiative_id,
NULL::uuid AS action_id,
NULL::uuid AS blocker_id,
NULL::uuid AS milestone_id,
'initiative_stale' AS reason_code,
'initiatives' AS data_source
FROM initiatives i
WHERE i.tenant_id = %s
AND i.status = 'active'
AND i.updated_at < NOW() - INTERVAL '14 days'
ORDER BY i.updated_at ASC
LIMIT 20
""",
(ctx.tenant_id,),
)
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
def _milestones_at_risk(cur, ctx: TenantContext) -> list[dict[str, Any]]:
cur.execute(
"""
SELECT
'milestone_at_risk' AS kind,
'warning' AS severity,
m.title AS title,
'Meilenstein als gefährdet markiert' AS summary,
'milestone' AS scope_type,
m.id AS scope_id,
m.initiative_id,
NULL::uuid AS action_id,
NULL::uuid AS blocker_id,
m.id AS milestone_id,
'milestone_at_risk' AS reason_code,
'milestones' AS data_source
FROM milestones m
WHERE m.tenant_id = %s AND m.status = 'at_risk'
ORDER BY m.updated_at DESC
LIMIT 20
""",
(ctx.tenant_id,),
)
return [_serialize_attention(dict(r)) for r in cur.fetchall()]
def get_attention_items(ctx: TenantContext) -> list[dict[str, Any]]:
"""Regelbasierte Attention Items — tenant-scoped, erklärbar."""
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
items: list[dict[str, Any]] = []
items.extend(_blocked_actions(cur, ctx))
items.extend(_open_blockers(cur, ctx))
items.extend(_high_priority_actions(cur, ctx))
items.extend(_unassigned_actions(cur, ctx))
items.extend(_initiatives_without_next_action(cur, ctx))
items.extend(_stale_initiatives(cur, ctx))
items.extend(_milestones_at_risk(cur, ctx))
items.sort(key=lambda x: _SEVERITY_ORDER.get(x["severity"], 99))
return items
finally:
conn.close()
def get_next_action_candidates(
ctx: TenantContext, *, limit: int = 10
) -> list[dict[str, Any]]:
"""Regelbasierte NextActionCandidates — limitiert, tenant-scoped."""
if limit < 1:
limit = 1
if limit > 50:
limit = 50
conn = get_connection()
candidates: list[dict[str, Any]] = []
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT
'resolve_blocker' AS kind,
b.title AS title,
'Blocker klären oder Status aktualisieren' AS summary,
b.initiative_id,
b.action_id,
NULL::uuid AS backlog_item_id,
'blocker_open' AS reason_code,
'Blocker bearbeiten' AS recommended_action
FROM blockers b
WHERE b.tenant_id = %s AND b.status IN ('open', 'in_progress')
ORDER BY b.updated_at DESC
LIMIT %s
""",
(ctx.tenant_id, limit),
)
for row in cur.fetchall():
item = dict(row)
item["initiative_id"] = str(item["initiative_id"])
if item.get("action_id"):
item["action_id"] = str(item["action_id"])
candidates.append(item)
remaining = limit - len(candidates)
if remaining > 0:
cur.execute(
"""
SELECT
'assign_action' AS kind,
a.title AS title,
'Actor zuweisen' AS summary,
a.initiative_id,
a.id AS action_id,
NULL::uuid AS backlog_item_id,
'action_unassigned' AS reason_code,
'Maßnahme zuweisen' AS recommended_action
FROM actions a
WHERE a.tenant_id = %s
AND a.status IN ('open', 'in_progress')
AND NOT EXISTS (
SELECT 1 FROM action_assignments aa
WHERE aa.action_id = a.id AND aa.tenant_id = a.tenant_id
)
ORDER BY a.updated_at DESC
LIMIT %s
""",
(ctx.tenant_id, remaining),
)
for row in cur.fetchall():
item = dict(row)
item["initiative_id"] = str(item["initiative_id"])
item["action_id"] = str(item["action_id"])
candidates.append(item)
remaining = limit - len(candidates)
if remaining > 0:
cur.execute(
"""
SELECT
'convert_backlog' AS kind,
bi.title AS title,
'Freigegebenes Backlog-Item in Maßnahme umwandeln' AS summary,
bi.initiative_id,
NULL::uuid AS action_id,
bi.id AS backlog_item_id,
'backlog_accepted_not_converted' AS reason_code,
'In Maßnahme umwandeln' AS recommended_action
FROM backlog_items bi
WHERE bi.tenant_id = %s
AND bi.status = 'accepted'
AND bi.converted_action_id IS NULL
ORDER BY bi.updated_at DESC
LIMIT %s
""",
(ctx.tenant_id, remaining),
)
for row in cur.fetchall():
item = dict(row)
item["initiative_id"] = str(item["initiative_id"])
item["backlog_item_id"] = str(item["backlog_item_id"])
candidates.append(item)
remaining = limit - len(candidates)
if remaining > 0:
cur.execute(
"""
SELECT
'create_action' AS kind,
i.title AS title,
'Nächste Maßnahme für Vorhaben anlegen' AS summary,
i.id AS initiative_id,
NULL::uuid AS action_id,
NULL::uuid AS backlog_item_id,
'initiative_no_open_action' AS reason_code,
'Maßnahme anlegen' AS recommended_action
FROM initiatives i
WHERE i.tenant_id = %s
AND i.status IN ('active', 'paused')
AND NOT EXISTS (
SELECT 1 FROM actions a
WHERE a.initiative_id = i.id
AND a.tenant_id = i.tenant_id
AND a.status IN ('open', 'in_progress', 'blocked')
)
ORDER BY i.updated_at DESC
LIMIT %s
""",
(ctx.tenant_id, remaining),
)
for row in cur.fetchall():
item = dict(row)
item["initiative_id"] = str(item["initiative_id"])
candidates.append(item)
return candidates[:limit]
finally:
conn.close()

View File

@ -53,7 +53,20 @@ app.add_middleware(
allow_headers=["*"],
)
from routers import actions, actors, auth, config, features, initiatives, me, prompts, workspace # noqa: E402
from routers import ( # noqa: E402
actions,
actors,
auth,
backlog,
blockers,
config,
features,
initiatives,
me,
milestones,
prompts,
workspace,
)
app.include_router(auth.router)
app.include_router(me.router)
@ -62,6 +75,9 @@ app.include_router(prompts.router)
app.include_router(config.router)
app.include_router(initiatives.router)
app.include_router(actions.router)
app.include_router(blockers.router)
app.include_router(backlog.router)
app.include_router(milestones.router)
app.include_router(actors.router)
app.include_router(workspace.router)

View File

@ -0,0 +1,52 @@
-- AP0.8: Operating Model Extension I — blockers, backlog_items, milestones
CREATE TABLE blockers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
action_id UUID NULL REFERENCES actions(id) ON DELETE SET NULL,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'in_progress', 'resolved', 'accepted_risk', 'dismissed')),
reported_by_actor_id UUID NULL REFERENCES actors(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_blockers_tenant_initiative ON blockers(tenant_id, initiative_id);
CREATE INDEX idx_blockers_tenant_status ON blockers(tenant_id, status);
CREATE TABLE backlog_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'new'
CHECK (status IN ('new', 'triaged', 'accepted', 'rejected', 'converted')),
priority VARCHAR(16) NOT NULL DEFAULT 'normal'
CHECK (priority IN ('low', 'normal', 'high')),
converted_action_id UUID NULL REFERENCES actions(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_backlog_items_tenant_initiative ON backlog_items(tenant_id, initiative_id);
CREATE INDEX idx_backlog_items_tenant_status ON backlog_items(tenant_id, status);
CREATE TABLE milestones (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
goal_description TEXT NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'planned'
CHECK (status IN ('planned', 'active', 'at_risk', 'reached', 'moved', 'discarded')),
target_date DATE NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_milestones_tenant_initiative ON milestones(tenant_id, initiative_id);
CREATE INDEX idx_milestones_tenant_status ON milestones(tenant_id, status);

View File

@ -1,5 +1,19 @@
"""Import all module registrations — side effect registers capabilities."""
from . import initiative_ops, platform, registry_ops, tenant_ops, workspace_ops # noqa: F401
from . import ( # noqa: F401
initiative_ops,
operating_model_ops,
platform,
registry_ops,
tenant_ops,
workspace_ops,
)
__all__ = ["initiative_ops", "platform", "registry_ops", "tenant_ops", "workspace_ops"]
__all__ = [
"initiative_ops",
"operating_model_ops",
"platform",
"registry_ops",
"tenant_ops",
"workspace_ops",
]

View File

@ -0,0 +1,83 @@
"""Operating model capabilities (AP0.8)."""
from __future__ import annotations
from rights_registry import CapabilityRegistration, register_capability
_MEMBER_READ = (
("portal", "admin"),
("portal", "user"),
("tenant", "owner"),
("tenant", "admin"),
("tenant", "member"),
)
_MEMBER_MANAGE = (
("portal", "admin"),
("tenant", "owner"),
("tenant", "admin"),
("tenant", "member"),
)
register_capability(
CapabilityRegistration(
key="kairo.attention.read",
module="attention",
description="Attention- und NextAction-Sichten lesen",
default_grants=_MEMBER_READ,
)
)
register_capability(
CapabilityRegistration(
key="kairo.blocker.read",
module="blocker",
description="Blocker im aktiven Tenant lesen",
default_grants=_MEMBER_READ,
)
)
register_capability(
CapabilityRegistration(
key="kairo.blocker.manage",
module="blocker",
description="Blocker anlegen und bearbeiten",
default_grants=_MEMBER_MANAGE,
)
)
register_capability(
CapabilityRegistration(
key="kairo.backlog.read",
module="backlog",
description="Backlog-Items im aktiven Tenant lesen",
default_grants=_MEMBER_READ,
)
)
register_capability(
CapabilityRegistration(
key="kairo.backlog.manage",
module="backlog",
description="Backlog-Items anlegen, bearbeiten und konvertieren",
default_grants=_MEMBER_MANAGE,
)
)
register_capability(
CapabilityRegistration(
key="kairo.milestone.read",
module="milestone",
description="Meilensteine im aktiven Tenant lesen",
default_grants=_MEMBER_READ,
)
)
register_capability(
CapabilityRegistration(
key="kairo.milestone.manage",
module="milestone",
description="Meilensteine anlegen und bearbeiten",
default_grants=_MEMBER_MANAGE,
)
)

103
backend/routers/backlog.py Normal file
View File

@ -0,0 +1,103 @@
"""BacklogItem API — AP0.8c."""
from __future__ import annotations
from typing import Literal, Optional
from capabilities import require_capability
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from services import backlog as backlog_service
from tenant_context import TenantContext
router = APIRouter(prefix="/api/backlog", tags=["backlog"])
class BacklogCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
description: str = ""
status: Literal["new", "triaged", "accepted", "rejected"] = "new"
priority: Literal["low", "normal", "high"] = "normal"
class BacklogUpdateRequest(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
description: Optional[str] = None
status: Optional[Literal["new", "triaged", "accepted", "rejected"]] = None
priority: Optional[Literal["low", "normal", "high"]] = None
class BacklogConvertRequest(BaseModel):
assigned_actor_ids: list[str] = Field(default_factory=list)
@router.get("/{backlog_item_id}")
def get_backlog_item(
backlog_item_id: str,
ctx: TenantContext = Depends(require_capability("kairo.backlog.read")),
):
item = backlog_service.get_backlog_item(
tenant_id=ctx.tenant_id, backlog_item_id=backlog_item_id
)
if not item:
raise HTTPException(status_code=404, detail="Backlog-Item nicht gefunden")
return item
@router.patch("/{backlog_item_id}")
def update_backlog_item(
backlog_item_id: str,
body: BacklogUpdateRequest,
ctx: TenantContext = Depends(require_capability("kairo.backlog.manage")),
):
try:
item = backlog_service.update_backlog_item(
tenant_id=ctx.tenant_id,
backlog_item_id=backlog_item_id,
user_id=ctx.user_id,
title=body.title,
description=body.description,
status=body.status,
priority=body.priority,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="Backlog-Item nicht gefunden")
return item
@router.delete("/{backlog_item_id}", status_code=204)
def delete_backlog_item(
backlog_item_id: str,
ctx: TenantContext = Depends(require_capability("kairo.backlog.manage")),
):
if not backlog_service.delete_backlog_item(
tenant_id=ctx.tenant_id,
backlog_item_id=backlog_item_id,
user_id=ctx.user_id,
):
raise HTTPException(status_code=404, detail="Backlog-Item nicht gefunden")
@router.post("/{backlog_item_id}/convert-to-action", status_code=201)
def convert_backlog_to_action(
backlog_item_id: str,
body: BacklogConvertRequest,
ctx: TenantContext = Depends(require_capability("kairo.backlog.manage")),
):
assigned = body.assigned_actor_ids
if not assigned and ctx.actor_id:
assigned = [ctx.actor_id]
try:
return backlog_service.convert_backlog_to_action(
tenant_id=ctx.tenant_id,
backlog_item_id=backlog_item_id,
user_id=ctx.user_id,
assigned_actor_ids=assigned,
)
except ValueError as exc:
detail = str(exc)
if detail == "Backlog-Item nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc

View File

@ -0,0 +1,77 @@
"""Blocker API — AP0.8b."""
from __future__ import annotations
from typing import Literal, Optional
from capabilities import require_capability
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from services import blockers as blocker_service
from tenant_context import TenantContext
router = APIRouter(prefix="/api/blockers", tags=["blockers"])
class BlockerCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
description: str = ""
status: Literal["open", "in_progress", "resolved", "accepted_risk", "dismissed"] = "open"
action_id: Optional[str] = None
set_action_blocked: bool = False
class BlockerUpdateRequest(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
description: Optional[str] = None
status: Optional[
Literal["open", "in_progress", "resolved", "accepted_risk", "dismissed"]
] = None
action_id: Optional[str] = None
clear_action_id: bool = False
@router.get("/{blocker_id}")
def get_blocker(
blocker_id: str,
ctx: TenantContext = Depends(require_capability("kairo.blocker.read")),
):
item = blocker_service.get_blocker(tenant_id=ctx.tenant_id, blocker_id=blocker_id)
if not item:
raise HTTPException(status_code=404, detail="Blocker nicht gefunden")
return item
@router.patch("/{blocker_id}")
def update_blocker(
blocker_id: str,
body: BlockerUpdateRequest,
ctx: TenantContext = Depends(require_capability("kairo.blocker.manage")),
):
try:
item = blocker_service.update_blocker(
tenant_id=ctx.tenant_id,
blocker_id=blocker_id,
user_id=ctx.user_id,
title=body.title,
description=body.description,
status=body.status,
action_id=body.action_id,
clear_action_id=body.clear_action_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="Blocker nicht gefunden")
return item
@router.delete("/{blocker_id}", status_code=204)
def delete_blocker(
blocker_id: str,
ctx: TenantContext = Depends(require_capability("kairo.blocker.manage")),
):
if not blocker_service.delete_blocker(
tenant_id=ctx.tenant_id, blocker_id=blocker_id, user_id=ctx.user_id
):
raise HTTPException(status_code=404, detail="Blocker nicht gefunden")

View File

@ -8,7 +8,10 @@ from capabilities import require_capability
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from services import actions as action_service
from services import backlog as backlog_service
from services import blockers as blocker_service
from services import initiatives as initiative_service
from services import milestones as milestone_service
from tenant_context import TenantContext
router = APIRouter(prefix="/api/initiatives", tags=["initiatives"])
@ -38,6 +41,28 @@ class ActionCreateRequest(BaseModel):
assigned_actor_ids: list[str] = Field(default_factory=list)
class BlockerCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
description: str = ""
status: Literal["open", "in_progress", "resolved", "accepted_risk", "dismissed"] = "open"
action_id: Optional[str] = None
set_action_blocked: bool = False
class BacklogCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
description: str = ""
status: Literal["new", "triaged", "accepted", "rejected"] = "new"
priority: Literal["low", "normal", "high"] = "normal"
class MilestoneCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
goal_description: str = ""
status: Literal["planned", "active", "at_risk", "reached", "moved", "discarded"] = "planned"
target_date: Optional[str] = None
@router.get("")
def list_initiatives(
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
@ -153,3 +178,127 @@ def create_initiative_action(
if detail == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc
@router.get("/{initiative_id}/blockers")
def list_initiative_blockers(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.blocker.read")),
):
try:
return blocker_service.list_blockers_for_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
except ValueError as exc:
if str(exc) == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=str(exc)) from exc
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/{initiative_id}/blockers", status_code=201)
def create_initiative_blocker(
initiative_id: str,
body: BlockerCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.blocker.manage")),
):
try:
return blocker_service.create_blocker(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
title=body.title,
description=body.description,
status=body.status,
action_id=body.action_id,
reported_by_actor_id=ctx.actor_id,
user_id=ctx.user_id,
set_action_blocked=body.set_action_blocked,
)
except ValueError as exc:
detail = str(exc)
if detail == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc
@router.get("/{initiative_id}/backlog")
def list_initiative_backlog(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.backlog.read")),
):
try:
return backlog_service.list_backlog_for_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
except ValueError as exc:
if str(exc) == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=str(exc)) from exc
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/{initiative_id}/backlog", status_code=201)
def create_initiative_backlog_item(
initiative_id: str,
body: BacklogCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.backlog.manage")),
):
try:
return backlog_service.create_backlog_item(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
title=body.title,
description=body.description,
status=body.status,
priority=body.priority,
user_id=ctx.user_id,
)
except ValueError as exc:
detail = str(exc)
if detail == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc
@router.get("/{initiative_id}/milestones")
def list_initiative_milestones(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
):
try:
return milestone_service.list_milestones_for_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
except ValueError as exc:
if str(exc) == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=str(exc)) from exc
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/{initiative_id}/milestones", status_code=201)
def create_initiative_milestone(
initiative_id: str,
body: MilestoneCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
from datetime import date as date_type
target_date = None
if body.target_date:
try:
target_date = date_type.fromisoformat(body.target_date)
except ValueError as exc:
raise HTTPException(status_code=400, detail="Ungültiges target_date") from exc
try:
return milestone_service.create_milestone(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
title=body.title,
goal_description=body.goal_description,
status=body.status,
target_date=target_date,
user_id=ctx.user_id,
)
except ValueError as exc:
detail = str(exc)
if detail == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc

View File

@ -0,0 +1,77 @@
"""Milestone API — AP0.8d."""
from __future__ import annotations
from datetime import date
from typing import Literal, Optional
from capabilities import require_capability
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from services import milestones as milestone_service
from tenant_context import TenantContext
router = APIRouter(prefix="/api/milestones", tags=["milestones"])
class MilestoneCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
goal_description: str = ""
status: Literal["planned", "active", "at_risk", "reached", "moved", "discarded"] = "planned"
target_date: Optional[date] = None
class MilestoneUpdateRequest(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
goal_description: Optional[str] = None
status: Optional[
Literal["planned", "active", "at_risk", "reached", "moved", "discarded"]
] = None
target_date: Optional[date] = None
clear_target_date: bool = False
@router.get("/{milestone_id}")
def get_milestone(
milestone_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
):
item = milestone_service.get_milestone(tenant_id=ctx.tenant_id, milestone_id=milestone_id)
if not item:
raise HTTPException(status_code=404, detail="Meilenstein nicht gefunden")
return item
@router.patch("/{milestone_id}")
def update_milestone(
milestone_id: str,
body: MilestoneUpdateRequest,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
try:
item = milestone_service.update_milestone(
tenant_id=ctx.tenant_id,
milestone_id=milestone_id,
user_id=ctx.user_id,
title=body.title,
goal_description=body.goal_description,
status=body.status,
target_date=body.target_date,
clear_target_date=body.clear_target_date,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="Meilenstein nicht gefunden")
return item
@router.delete("/{milestone_id}", status_code=204)
def delete_milestone(
milestone_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
if not milestone_service.delete_milestone(
tenant_id=ctx.tenant_id, milestone_id=milestone_id, user_id=ctx.user_id
):
raise HTTPException(status_code=404, detail="Meilenstein nicht gefunden")

View File

@ -7,6 +7,7 @@ from typing import Optional
from capabilities import require_capability
from data_layer import actions as dl_actions
from data_layer import actors as dl_actors
from data_layer import attention as dl_attention
from data_layer import initiatives as dl_initiatives
from data_layer import workspace as dl_workspace
from fastapi import APIRouter, Depends, HTTPException, Query
@ -61,3 +62,18 @@ def workspace_actor_workload(
ctx: TenantContext = Depends(require_capability("kairo.workspace.read")),
):
return dl_actors.get_actor_workload(ctx)
@router.get("/attention")
def workspace_attention(
ctx: TenantContext = Depends(require_capability("kairo.attention.read")),
):
return dl_attention.get_attention_items(ctx)
@router.get("/next-actions")
def workspace_next_actions(
limit: Optional[int] = Query(default=10, ge=1, le=50),
ctx: TenantContext = Depends(require_capability("kairo.attention.read")),
):
return dl_attention.get_next_action_candidates(ctx, limit=limit or 10)

296
backend/services/backlog.py Normal file
View File

@ -0,0 +1,296 @@
"""BacklogItem service — tenant-scoped CRUD + convert (AP0.8c)."""
from __future__ import annotations
from typing import Any, Literal, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from services.actions import create_action
from services.audit import log_audit
from services.initiatives import PRIORITIES, get_initiative
BacklogStatus = Literal["new", "triaged", "accepted", "rejected", "converted"]
BACKLOG_STATUSES = frozenset({"new", "triaged", "accepted", "rejected", "converted"})
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
result = dict(row)
for key in ("id", "tenant_id", "initiative_id", "converted_action_id"):
if result.get(key):
result[key] = str(result[key])
if result.get("created_at"):
result["created_at"] = result["created_at"].isoformat()
if result.get("updated_at"):
result["updated_at"] = result["updated_at"].isoformat()
return result
def _validate_status(status: str) -> None:
if status not in BACKLOG_STATUSES:
raise ValueError(f"Ungültiger Backlog-Status: {status}")
def _validate_priority(priority: str) -> None:
if priority not in PRIORITIES:
raise ValueError(f"Ungültige Priorität: {priority}")
def create_backlog_item(
*,
tenant_id: str,
initiative_id: str,
title: str,
description: str = "",
status: BacklogStatus = "new",
priority: str = "normal",
user_id: Optional[str] = None,
) -> dict[str, Any]:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
_validate_status(status)
_validate_priority(priority)
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
INSERT INTO backlog_items (
tenant_id, initiative_id, title, description, status, priority
)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id, tenant_id, initiative_id, title, description, status,
priority, converted_action_id, created_at, updated_at
""",
(tenant_id, initiative_id, title, description, status, priority),
)
row = _serialize_row(dict(cur.fetchone()))
conn.commit()
finally:
conn.close()
log_audit(
"backlog.created",
user_id=user_id,
tenant_id=tenant_id,
details={"backlog_item_id": row["id"], "initiative_id": initiative_id, "title": title},
)
return row
def list_backlog_for_initiative(*, tenant_id: str, initiative_id: str) -> list[dict[str, Any]]:
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, tenant_id, initiative_id, title, description, 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, title
""",
(tenant_id, initiative_id),
)
return [_serialize_row(dict(r)) for r in cur.fetchall()]
finally:
conn.close()
def get_backlog_item(*, tenant_id: str, backlog_item_id: str) -> Optional[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, tenant_id, initiative_id, title, description, status,
priority, converted_action_id, created_at, updated_at
FROM backlog_items
WHERE id = %s AND tenant_id = %s
""",
(backlog_item_id, tenant_id),
)
row = cur.fetchone()
return _serialize_row(dict(row)) if row else None
finally:
conn.close()
def update_backlog_item(
*,
tenant_id: str,
backlog_item_id: str,
user_id: Optional[str] = None,
title: Optional[str] = None,
description: Optional[str] = None,
status: Optional[BacklogStatus] = None,
priority: Optional[str] = None,
) -> Optional[dict[str, Any]]:
existing = get_backlog_item(tenant_id=tenant_id, backlog_item_id=backlog_item_id)
if not existing:
return None
if existing["status"] == "converted":
raise ValueError("Konvertiertes Backlog-Item kann nicht bearbeitet werden")
old_status = existing["status"]
updates: list[str] = []
params: list[Any] = []
if title is not None:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
updates.append("title = %s")
params.append(title)
if description is not None:
updates.append("description = %s")
params.append(description)
if status is not None:
_validate_status(status)
updates.append("status = %s")
params.append(status)
if priority is not None:
_validate_priority(priority)
updates.append("priority = %s")
params.append(priority)
if not updates:
return existing
updates.append("updated_at = NOW()")
params.extend([backlog_item_id, tenant_id])
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
UPDATE backlog_items
SET {", ".join(updates)}
WHERE id = %s AND tenant_id = %s
RETURNING id, tenant_id, initiative_id, title, description, status,
priority, converted_action_id, created_at, updated_at
""",
params,
)
row = cur.fetchone()
if not row:
return None
result = _serialize_row(dict(row))
conn.commit()
finally:
conn.close()
log_audit(
"backlog.updated",
user_id=user_id,
tenant_id=tenant_id,
details={"backlog_item_id": backlog_item_id},
)
if status is not None and status != old_status:
log_audit(
"backlog.status_changed",
user_id=user_id,
tenant_id=tenant_id,
details={
"backlog_item_id": backlog_item_id,
"from_status": old_status,
"to_status": status,
},
)
return result
def delete_backlog_item(
*,
tenant_id: str,
backlog_item_id: str,
user_id: Optional[str] = None,
) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM backlog_items WHERE id = %s AND tenant_id = %s RETURNING id",
(backlog_item_id, tenant_id),
)
deleted = cur.fetchone() is not None
conn.commit()
finally:
conn.close()
if deleted:
log_audit(
"backlog.deleted",
user_id=user_id,
tenant_id=tenant_id,
details={"backlog_item_id": backlog_item_id},
)
return deleted
def convert_backlog_to_action(
*,
tenant_id: str,
backlog_item_id: str,
user_id: Optional[str] = None,
assigned_actor_ids: Optional[list[str]] = None,
) -> dict[str, Any]:
existing = get_backlog_item(tenant_id=tenant_id, backlog_item_id=backlog_item_id)
if not existing:
raise ValueError("Backlog-Item nicht gefunden")
if existing["status"] == "converted":
raise ValueError("Backlog-Item wurde bereits konvertiert")
if existing["status"] not in ("accepted", "triaged", "new"):
raise ValueError("Backlog-Item kann in diesem Status nicht konvertiert werden")
action = create_action(
tenant_id=tenant_id,
initiative_id=existing["initiative_id"],
title=existing["title"],
description=existing["description"] or "",
priority=existing["priority"],
assigned_actor_ids=assigned_actor_ids or [],
user_id=user_id,
)
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
UPDATE backlog_items
SET status = 'converted',
converted_action_id = %s,
updated_at = NOW()
WHERE id = %s AND tenant_id = %s
RETURNING id, tenant_id, initiative_id, title, description, status,
priority, converted_action_id, created_at, updated_at
""",
(action["id"], backlog_item_id, tenant_id),
)
row = _serialize_row(dict(cur.fetchone()))
conn.commit()
finally:
conn.close()
log_audit(
"backlog.converted_to_action",
user_id=user_id,
tenant_id=tenant_id,
details={
"backlog_item_id": backlog_item_id,
"action_id": action["id"],
"initiative_id": existing["initiative_id"],
},
)
return {"backlog_item": row, "action": action}

View File

@ -0,0 +1,295 @@
"""Blocker service — tenant-scoped CRUD (AP0.8b)."""
from __future__ import annotations
from typing import Any, Literal, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from services.audit import log_audit
from services.initiatives import get_initiative
BlockerStatus = Literal["open", "in_progress", "resolved", "accepted_risk", "dismissed"]
BLOCKER_STATUSES = frozenset(
{"open", "in_progress", "resolved", "accepted_risk", "dismissed"}
)
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
result = dict(row)
for key in ("id", "tenant_id", "initiative_id", "action_id", "reported_by_actor_id"):
if result.get(key):
result[key] = str(result[key])
if result.get("created_at"):
result["created_at"] = result["created_at"].isoformat()
if result.get("updated_at"):
result["updated_at"] = result["updated_at"].isoformat()
return result
def _validate_status(status: str) -> None:
if status not in BLOCKER_STATUSES:
raise ValueError(f"Ungültiger Blocker-Status: {status}")
def _actor_in_tenant(*, tenant_id: str, actor_id: str) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT 1 FROM actors WHERE id = %s AND tenant_id = %s AND is_active = TRUE",
(actor_id, tenant_id),
)
return cur.fetchone() is not None
finally:
conn.close()
def _action_in_initiative(*, tenant_id: str, initiative_id: str, action_id: str) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT 1 FROM actions
WHERE id = %s AND tenant_id = %s AND initiative_id = %s
""",
(action_id, tenant_id, initiative_id),
)
return cur.fetchone() is not None
finally:
conn.close()
def create_blocker(
*,
tenant_id: str,
initiative_id: str,
title: str,
description: str = "",
status: BlockerStatus = "open",
action_id: Optional[str] = None,
reported_by_actor_id: Optional[str] = None,
user_id: Optional[str] = None,
set_action_blocked: bool = False,
) -> dict[str, Any]:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
_validate_status(status)
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden")
if action_id and not _action_in_initiative(
tenant_id=tenant_id, initiative_id=initiative_id, action_id=action_id
):
raise ValueError("Maßnahme gehört nicht zum Vorhaben")
if reported_by_actor_id and not _actor_in_tenant(
tenant_id=tenant_id, actor_id=reported_by_actor_id
):
raise ValueError("Reporter-Actor gehört nicht zum Tenant")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
INSERT INTO blockers (
tenant_id, initiative_id, action_id, title, description,
status, reported_by_actor_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING id, tenant_id, initiative_id, action_id, title, description,
status, reported_by_actor_id, created_at, updated_at
""",
(
tenant_id,
initiative_id,
action_id,
title,
description,
status,
reported_by_actor_id,
),
)
row = _serialize_row(dict(cur.fetchone()))
if set_action_blocked and action_id:
cur.execute(
"""
UPDATE actions SET status = 'blocked', updated_at = NOW()
WHERE id = %s AND tenant_id = %s
""",
(action_id, tenant_id),
)
conn.commit()
finally:
conn.close()
log_audit(
"blocker.created",
user_id=user_id,
tenant_id=tenant_id,
details={"blocker_id": row["id"], "initiative_id": initiative_id, "title": title},
)
return row
def list_blockers_for_initiative(*, tenant_id: str, initiative_id: str) -> list[dict[str, Any]]:
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, tenant_id, initiative_id, action_id, title, description,
status, reported_by_actor_id, created_at, updated_at
FROM blockers
WHERE tenant_id = %s AND initiative_id = %s
ORDER BY updated_at DESC, title
""",
(tenant_id, initiative_id),
)
return [_serialize_row(dict(r)) for r in cur.fetchall()]
finally:
conn.close()
def get_blocker(*, tenant_id: str, blocker_id: str) -> Optional[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, tenant_id, initiative_id, action_id, title, description,
status, reported_by_actor_id, created_at, updated_at
FROM blockers
WHERE id = %s AND tenant_id = %s
""",
(blocker_id, tenant_id),
)
row = cur.fetchone()
return _serialize_row(dict(row)) if row else None
finally:
conn.close()
def update_blocker(
*,
tenant_id: str,
blocker_id: str,
user_id: Optional[str] = None,
title: Optional[str] = None,
description: Optional[str] = None,
status: Optional[BlockerStatus] = None,
action_id: Optional[str] = None,
clear_action_id: bool = False,
) -> Optional[dict[str, Any]]:
existing = get_blocker(tenant_id=tenant_id, blocker_id=blocker_id)
if not existing:
return None
old_status = existing["status"]
updates: list[str] = []
params: list[Any] = []
if title is not None:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
updates.append("title = %s")
params.append(title)
if description is not None:
updates.append("description = %s")
params.append(description)
if status is not None:
_validate_status(status)
updates.append("status = %s")
params.append(status)
if clear_action_id:
updates.append("action_id = NULL")
elif action_id is not None:
if not _action_in_initiative(
tenant_id=tenant_id,
initiative_id=existing["initiative_id"],
action_id=action_id,
):
raise ValueError("Maßnahme gehört nicht zum Vorhaben")
updates.append("action_id = %s")
params.append(action_id)
if not updates:
return existing
updates.append("updated_at = NOW()")
params.extend([blocker_id, tenant_id])
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
UPDATE blockers
SET {", ".join(updates)}
WHERE id = %s AND tenant_id = %s
RETURNING id, tenant_id, initiative_id, action_id, title, description,
status, reported_by_actor_id, created_at, updated_at
""",
params,
)
row = cur.fetchone()
if not row:
return None
result = _serialize_row(dict(row))
conn.commit()
finally:
conn.close()
log_audit(
"blocker.updated",
user_id=user_id,
tenant_id=tenant_id,
details={"blocker_id": blocker_id},
)
if status is not None and status != old_status:
log_audit(
"blocker.status_changed",
user_id=user_id,
tenant_id=tenant_id,
details={
"blocker_id": blocker_id,
"from_status": old_status,
"to_status": status,
},
)
return result
def delete_blocker(
*,
tenant_id: str,
blocker_id: str,
user_id: Optional[str] = None,
) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM blockers WHERE id = %s AND tenant_id = %s RETURNING id",
(blocker_id, tenant_id),
)
deleted = cur.fetchone() is not None
conn.commit()
finally:
conn.close()
if deleted:
log_audit(
"blocker.deleted",
user_id=user_id,
tenant_id=tenant_id,
details={"blocker_id": blocker_id},
)
return deleted

View File

@ -0,0 +1,242 @@
"""Milestone service — tenant-scoped CRUD (AP0.8d)."""
from __future__ import annotations
from datetime import date
from typing import Any, Literal, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from services.audit import log_audit
from services.initiatives import get_initiative
MilestoneStatus = Literal[
"planned", "active", "at_risk", "reached", "moved", "discarded"
]
MILESTONE_STATUSES = frozenset(
{"planned", "active", "at_risk", "reached", "moved", "discarded"}
)
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
result = dict(row)
for key in ("id", "tenant_id", "initiative_id"):
if result.get(key):
result[key] = str(result[key])
if result.get("target_date"):
result["target_date"] = (
result["target_date"].isoformat()
if hasattr(result["target_date"], "isoformat")
else str(result["target_date"])
)
if result.get("created_at"):
result["created_at"] = result["created_at"].isoformat()
if result.get("updated_at"):
result["updated_at"] = result["updated_at"].isoformat()
return result
def _validate_status(status: str) -> None:
if status not in MILESTONE_STATUSES:
raise ValueError(f"Ungültiger Meilenstein-Status: {status}")
def create_milestone(
*,
tenant_id: str,
initiative_id: str,
title: str,
goal_description: str = "",
status: MilestoneStatus = "planned",
target_date: Optional[date] = None,
user_id: Optional[str] = None,
) -> dict[str, Any]:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
_validate_status(status)
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
INSERT INTO milestones (
tenant_id, initiative_id, title, goal_description, status, target_date
)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id, tenant_id, initiative_id, title, goal_description, status,
target_date, created_at, updated_at
""",
(tenant_id, initiative_id, title, goal_description, status, target_date),
)
row = _serialize_row(dict(cur.fetchone()))
conn.commit()
finally:
conn.close()
log_audit(
"milestone.created",
user_id=user_id,
tenant_id=tenant_id,
details={"milestone_id": row["id"], "initiative_id": initiative_id, "title": title},
)
return row
def list_milestones_for_initiative(*, tenant_id: str, initiative_id: str) -> list[dict[str, Any]]:
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, tenant_id, initiative_id, title, goal_description, status,
target_date, created_at, updated_at
FROM milestones
WHERE tenant_id = %s AND initiative_id = %s
ORDER BY target_date NULLS LAST, updated_at DESC, title
""",
(tenant_id, initiative_id),
)
return [_serialize_row(dict(r)) for r in cur.fetchall()]
finally:
conn.close()
def get_milestone(*, tenant_id: str, milestone_id: str) -> Optional[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, tenant_id, initiative_id, title, goal_description, status,
target_date, created_at, updated_at
FROM milestones
WHERE id = %s AND tenant_id = %s
""",
(milestone_id, tenant_id),
)
row = cur.fetchone()
return _serialize_row(dict(row)) if row else None
finally:
conn.close()
def update_milestone(
*,
tenant_id: str,
milestone_id: str,
user_id: Optional[str] = None,
title: Optional[str] = None,
goal_description: Optional[str] = None,
status: Optional[MilestoneStatus] = None,
target_date: Optional[date] = None,
clear_target_date: bool = False,
) -> Optional[dict[str, Any]]:
existing = get_milestone(tenant_id=tenant_id, milestone_id=milestone_id)
if not existing:
return None
old_status = existing["status"]
updates: list[str] = []
params: list[Any] = []
if title is not None:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
updates.append("title = %s")
params.append(title)
if goal_description is not None:
updates.append("goal_description = %s")
params.append(goal_description)
if status is not None:
_validate_status(status)
updates.append("status = %s")
params.append(status)
if clear_target_date:
updates.append("target_date = NULL")
elif target_date is not None:
updates.append("target_date = %s")
params.append(target_date)
if not updates:
return existing
updates.append("updated_at = NOW()")
params.extend([milestone_id, tenant_id])
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
UPDATE milestones
SET {", ".join(updates)}
WHERE id = %s AND tenant_id = %s
RETURNING id, tenant_id, initiative_id, title, goal_description, status,
target_date, created_at, updated_at
""",
params,
)
row = cur.fetchone()
if not row:
return None
result = _serialize_row(dict(row))
conn.commit()
finally:
conn.close()
log_audit(
"milestone.updated",
user_id=user_id,
tenant_id=tenant_id,
details={"milestone_id": milestone_id},
)
if status is not None and status != old_status:
log_audit(
"milestone.status_changed",
user_id=user_id,
tenant_id=tenant_id,
details={
"milestone_id": milestone_id,
"from_status": old_status,
"to_status": status,
},
)
return result
def delete_milestone(
*,
tenant_id: str,
milestone_id: str,
user_id: Optional[str] = None,
) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM milestones WHERE id = %s AND tenant_id = %s RETURNING id",
(milestone_id, tenant_id),
)
deleted = cur.fetchone() is not None
conn.commit()
finally:
conn.close()
if deleted:
log_audit(
"milestone.deleted",
user_id=user_id,
tenant_id=tenant_id,
details={"milestone_id": milestone_id},
)
return deleted

View File

@ -1,4 +1,8 @@
"""Shared pytest fixtures for AP0.2 auth tests."""
"""Shared pytest fixtures — Integrationstests benötigen PostgreSQL.
Lokal typischerweise nicht verfügbar: Tests laufen nach Push auf develop/main
im Backend-Container auf dem Pi (Gitea workflow test.yml).
"""
from __future__ import annotations
@ -23,6 +27,7 @@ def _db_available() -> bool:
dbname=os.getenv("DB_NAME", "kairo_dev"),
user=os.getenv("DB_USER", "kairo_dev"),
password=os.getenv("DB_PASSWORD", "dev_password"),
connect_timeout=3,
)
conn.close()
return True
@ -30,7 +35,18 @@ def _db_available() -> bool:
return False
pytestmark = pytest.mark.skipif(not _db_available(), reason="PostgreSQL nicht erreichbar")
_REMOTE_HINT = (
"PostgreSQL lokal nicht erreichbar. "
"Backend-Integrationstests laufen nach Push auf develop/main "
"im deployten Backend-Container (Gitea: test.yml auf dem Pi)."
)
@pytest.fixture(scope="session", autouse=True)
def _require_postgresql():
"""Session-weiter Guard — verhindert Migrations-Setup ohne DB (keine ERROR-Spirale)."""
if not _db_available():
pytest.skip(_REMOTE_HINT)
@pytest.fixture(scope="session", autouse=True)

View File

@ -0,0 +1,279 @@
"""AP0.8 — Operating Model Extension I tests.
Ausführung: im Backend-Container auf dem Pi nach Deploy (develop/main),
siehe .gitea/workflows/test.yml und docs/DEPLOYMENT.md.
"""
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 _create_blocker(client, token, initiative_id, **kwargs):
body = {"title": "Blocker Test", **kwargs}
return client.post(
f"/api/initiatives/{initiative_id}/blockers",
json=body,
headers=_auth(token),
)
def _create_backlog(client, token, initiative_id, **kwargs):
body = {"title": "Backlog Test", **kwargs}
return client.post(
f"/api/initiatives/{initiative_id}/backlog",
json=body,
headers=_auth(token),
)
def _create_milestone(client, token, initiative_id, **kwargs):
body = {"title": "Milestone Test", **kwargs}
return client.post(
f"/api/initiatives/{initiative_id}/milestones",
json=body,
headers=_auth(token),
)
def test_attention_blocked_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, assigned_actor_ids=[user["actor_id"]]
).json()
client.patch(
f"/api/actions/{action['id']}",
json={"status": "blocked"},
headers=_auth(token),
)
res = client.get("/api/workspace/attention", headers=_auth(token))
assert res.status_code == 200
items = res.json()
assert any(i["kind"] == "blocked_action" for i in items)
blocked = next(i for i in items if i["kind"] == "blocked_action")
assert blocked["reason_code"] == "action_blocked"
assert blocked["data_source"] == "actions"
assert blocked["scope_type"] == "action"
def test_attention_unassigned_action(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
_create_action(client, token, initiative_id, title="Unassigned")
res = client.get("/api/workspace/attention", headers=_auth(token))
assert res.status_code == 200
assert any(i["kind"] == "unassigned_action" for i in res.json())
def test_attention_initiative_without_next_action(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
_create_initiative(client, token, title="Empty Initiative")
res = client.get("/api/workspace/attention", headers=_auth(token))
assert res.status_code == 200
assert any(i["kind"] == "initiative_without_next_action" for i in res.json())
def test_attention_open_blocker(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
_create_blocker(client, token, initiative_id, title="Dependency missing")
res = client.get("/api/workspace/attention", headers=_auth(token))
assert res.status_code == 200
assert any(i["kind"] == "open_blocker" for i in res.json())
def test_attention_milestone_at_risk(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
ms = _create_milestone(client, token, initiative_id).json()
client.patch(
f"/api/milestones/{ms['id']}",
json={"status": "at_risk"},
headers=_auth(token),
)
res = client.get("/api/workspace/attention", headers=_auth(token))
assert any(i["kind"] == "milestone_at_risk" for i in res.json())
def test_attention_severity_sorting(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": "blocked"},
headers=_auth(token),
)
_create_blocker(client, token, initiative_id)
items = client.get("/api/workspace/attention", headers=_auth(token)).json()
severities = [i["severity"] for i in items]
order = {"critical": 0, "warning": 1, "info": 2}
assert severities == sorted(severities, key=lambda s: order.get(s, 99))
def test_next_action_convert_backlog(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
item = _create_backlog(
client, token, initiative_id, status="accepted", title="Convert me"
).json()
res = client.get("/api/workspace/next-actions?limit=10", headers=_auth(token))
assert res.status_code == 200
assert any(
c["kind"] == "convert_backlog" and c["backlog_item_id"] == item["id"]
for c in res.json()
)
def test_next_action_limit(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
for i in range(5):
_create_backlog(
client, token, initiative_id, status="accepted", title=f"Item {i}"
)
res = client.get("/api/workspace/next-actions?limit=2", headers=_auth(token))
assert len(res.json()) <= 2
def test_blocker_crud(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()
created = _create_blocker(
client,
token,
initiative_id,
title="API Block",
action_id=action["id"],
)
assert created.status_code == 201
blocker = created.json()
assert blocker["initiative_id"] == initiative_id
listed = client.get(
f"/api/initiatives/{initiative_id}/blockers", headers=_auth(token)
)
assert listed.status_code == 200
assert any(b["id"] == blocker["id"] for b in listed.json())
updated = client.patch(
f"/api/blockers/{blocker['id']}",
json={"status": "resolved"},
headers=_auth(token),
)
assert updated.status_code == 200
assert updated.json()["status"] == "resolved"
deleted = client.delete(f"/api/blockers/{blocker['id']}", headers=_auth(token))
assert deleted.status_code == 204
def test_blocker_cross_tenant(client):
user_a = provision_user_in_tenant()
user_b = provision_user_in_tenant()
token_a = _login(client, user_a)
token_b = _login(client, user_b)
initiative_id = _create_initiative(client, token_a).json()["id"]
blocker = _create_blocker(client, token_a, initiative_id).json()
assert (
client.get(f"/api/blockers/{blocker['id']}", headers=_auth(token_b)).status_code
== 404
)
def test_backlog_convert_to_action(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
item = _create_backlog(
client, token, initiative_id, status="accepted", title="Feature X"
).json()
converted = client.post(
f"/api/backlog/{item['id']}/convert-to-action",
json={},
headers=_auth(token),
)
assert converted.status_code == 201
body = converted.json()
assert body["backlog_item"]["status"] == "converted"
assert body["action"]["title"] == "Feature X"
assert body["backlog_item"]["converted_action_id"] == body["action"]["id"]
def test_backlog_crud(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
created = _create_backlog(client, token, initiative_id, status="triaged")
assert created.status_code == 201
item = created.json()
patched = client.patch(
f"/api/backlog/{item['id']}",
json={"status": "accepted"},
headers=_auth(token),
)
assert patched.status_code == 200
deleted = client.delete(f"/api/backlog/{item['id']}", headers=_auth(token))
assert deleted.status_code == 204
def test_milestone_crud(client):
user = provision_user_in_tenant(tenant_role="member")
token = _login(client, user)
initiative_id = _create_initiative(client, token).json()["id"]
created = _create_milestone(
client, token, initiative_id, status="active", target_date="2026-12-31"
)
assert created.status_code == 201
ms = created.json()
assert ms["target_date"] == "2026-12-31"
deleted = client.delete(f"/api/milestones/{ms['id']}", headers=_auth(token))
assert deleted.status_code == 204
def test_member_has_ap08_capabilities(client):
member = provision_user_in_tenant(tenant_role="member")
token = _login(client, member)
ctx = client.get("/api/me/context", headers=_auth(token)).json()
caps = ctx["capabilities"]
for key in (
"kairo.attention.read",
"kairo.blocker.read",
"kairo.blocker.manage",
"kairo.backlog.read",
"kairo.backlog.manage",
"kairo.milestone.read",
"kairo.milestone.manage",
):
assert key in caps

View File

@ -37,6 +37,7 @@ def test_migration_runner_finds_migrations():
assert "004_capabilities_registry" in names
assert "005_prompt_feature_config_registry" in names
assert "006_initiatives_actions" in names
assert "007_operating_model_extension_i" in names
def test_migration_runner_is_idempotent():
@ -52,6 +53,7 @@ def test_migration_runner_is_idempotent():
assert "004_capabilities_registry" in executed
assert "005_prompt_feature_config_registry" in executed
assert "006_initiatives_actions" in executed
assert "007_operating_model_extension_i" in executed
def test_core_table_exists():

View File

@ -28,6 +28,13 @@ def test_registry_contains_initial_capabilities():
"kairo.action.read",
"kairo.action.manage",
"kairo.workspace.read",
"kairo.attention.read",
"kairo.blocker.read",
"kairo.blocker.manage",
"kairo.backlog.read",
"kairo.backlog.manage",
"kairo.milestone.read",
"kairo.milestone.manage",
}
@ -39,7 +46,7 @@ def test_sync_is_idempotent():
try:
with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM capabilities")
assert cur.fetchone()[0] == 18
assert cur.fetchone()[0] == 25
cur.execute("SELECT COUNT(*) FROM role_capability_grants")
assert cur.fetchone()[0] >= 5
finally:

View File

@ -1,3 +1,3 @@
APP_VERSION = "0.7.0-ap0.7"
DB_SCHEMA_VERSION = "006"
APP_VERSION = "0.8.0-ap0.8"
DB_SCHEMA_VERSION = "007"
APP_NAME = "jinkendo-kairo"

View File

@ -0,0 +1,808 @@
# Kairo System Target State
## v0.1 Zielzustand des angedachten Gesamtsystems
**Status:** konsolidiertes Zielbild / Nordstern-Dokument
**Stand:** 2026-07-05
**Zweck:** Beschreibung des angedachten späteren Zielzustands von Kairo als System, unabhängig von konkreten Implementierungsschnitten.
---
## 1. Executive Summary
Kairo ist langfristig keine einfache Aufgabenliste, keine reine Projektmanagement-App, kein Roadmap-Tool und keine freie Workflow-Automatisierungsplattform.
Kairo ist eine:
```text
adaptive, methodengeführte Entwicklungs- und Steuerungsplattform
```
Der Kern ist ein:
```text
Method-driven Adaptive Steering Core
```
Kairo soll unterschiedliche Entwicklungs- und Steuerungskontexte führen können:
```text
- Software- und Produktentwicklung
- Buch- und Content-Entwicklung
- Programmsteuerung
- Projekt- und Initiativensteuerung
- persönliche Entwicklung
- Reifegradentwicklung
- Lernpfade
- Routinen / Betrieb
- Review- und Auditkontexte
- Agentenläufe
- KI-/Tool-gestützte Steuerung
```
Die zentrale Leitfrage lautet:
```text
Welcher nächste Schritt bringt diesen steuerbaren Kontext jetzt am wirksamsten voran?
```
---
## 2. Grundverständnis
Kairo steuert nicht nur Aufgaben.
Kairo steuert Entwicklungen.
Eine Entwicklung kann sein:
```text
- ein Produkt
- ein Programm
- ein Projekt
- eine Initiative
- ein Buch
- eine Fähigkeit
- ein Reifegradziel
- eine Routine
- ein Review
- ein Audit
- ein Agentenauftrag
```
Kairo hält dabei fest:
```text
- Was soll entwickelt werden?
- Warum ist es relevant?
- Welche Methode gilt?
- Welche Struktur braucht diese Methode?
- Wo steht der Kontext aktuell?
- Was fehlt?
- Was blockiert?
- Was braucht Aufmerksamkeit?
- Was ist der nächste wirksame Schritt?
- Wer oder welcher Agent soll handeln?
- Worauf wartet Kairo?
- Welche Nachweise, Reviews oder Entscheidungen sind nötig?
- Wann wird angepasst, eskaliert oder abgeschlossen?
```
---
## 3. Produktidentität
Kairo ist:
```text
System of Record
Steering Authority
Actor-/Tenant-sicherer Kontextgeber
methodengeführter Steuerungskern
Roadmap- und Entwicklungsstrukturmodell
Signal- und NextAction-System
später Workflow-/Agenten-/Tool-orchestrierend
```
Kairo ist nicht:
```text
freie Workflow-Plattform
beliebiger Automatisierungsbaukasten
reines Projektmanagementsystem
reine To-do-Liste
reines Roadmap-Tool
reine KI-Agentenplattform
reines Prompt-System
```
---
## 4. Kernmodell
Das Zielmodell:
```text
Steerable Object
→ Steering Context
→ Steering Method
→ Standard Lifecycle / Method Lifecycle
→ Hook Slugs
→ Structure Builder
→ Signal Rules
→ NextAction Strategy
→ Assignment Strategy
→ Waiting / Reminder / Escalation Strategy
→ Result Intake
→ Evidence / Review / Decision
→ Adaptation / Closure
→ optionale Workflow-Fragmente
→ optionale Human / Agent / LLM / Tool Steps
```
---
## 5. Method-driven Adaptive Steering Core
Der Adaptive Steering Core ist der fachliche und technische Kern von Kairo.
Er führt steuerbare Kontexte durch methodengeführte Steuerungsabläufe.
Typischer Ablauf:
```text
1. Ziel / Entwicklungsabsicht erfassen
2. Name, Scope und Kontext definieren
3. passende Basismethode wählen oder vorschlagen
4. gemäß Methode eine Steuerungsstruktur erzeugen
- Roadmap
- Backlog
- WBS
- Reifegradpfad
- Kapitelstruktur
- Feature Landscape
- WorkCycles
- Review Gates
5. Next Best Action ableiten
6. Actor zuweisen
7. auf Ergebnis, Rückmeldung, Termin oder Ereignis warten
8. erinnern, eskalieren oder nachsteuern
9. Ergebnis zurückführen
10. prüfen, reviewen, adaptieren oder abschließen
```
Dieser Ablauf ist der methodengeführte Steuerungsworkflow.
Er ist nicht gleichbedeutend mit einer freien technischen Workflow Engine.
---
## 6. Standard Lifecycle
Kairo besitzt einen minimalen Standard Lifecycle als gemeinsame Basis.
```text
intake
method_selection
structure_setup
planning
action_selection
assignment
waiting
result_intake
validation
review
adaptation
closure
```
Methoden können diesen Lifecycle:
```text
- verwenden
- teilweise überspringen
- spezialisieren
- erweitern
- eigene Hooks ergänzen
```
Der Standard Lifecycle ist kein starrer Projektprozess, sondern ein gemeinsames Steuerungsraster.
---
## 7. Steering Methods
Eine Methode ist kein Label, sondern ein registriertes Steuerungspaket.
Eine Methode definiert:
```text
- supported_domains
- supported_scope_types
- default_lifecycle
- required_structures
- allowed_structures
- hook_slugs
- structure_builders
- attention_rule_providers
- next_action_strategy
- assignment_strategy
- waiting_strategy
- reminder_strategy
- escalation_strategy
- result_intake_strategy
- review_strategy
- evidence_policy
- closure_policy
- allowed_workflow_fragments
- allowed_node_types
- default_configuration
```
Methoden werden zunächst built-in und code-first angelegt, später versioniert und begrenzt konfigurierbar.
Langfristig können Method Profiles entstehen.
---
## 8. Method Registry
Kairo benötigt eine Method Registry.
Sie funktioniert ähnlich einem Widget-System:
```text
Eine Methode meldet sich mit Slug, Version, unterstützten Domains,
Lifecycle, Hooks, Structure Builders, Strategies und Policies an.
```
Ziel:
```text
Neue Methoden sollen ergänzt werden können,
ohne den Core jedes Mal umzubauen.
```
Beispiele für Methoden:
```text
simple_action_driven
product_milestone_driven
backlog_driven
wbs_driven
cycle_based
content_chapter_based
maturity_progression
routine_control
review_driven
audit_driven
agent_controlled
```
---
## 9. Steering Domains
Kairo unterstützt unterschiedliche Steuerungsdomänen.
Beispiele:
```text
program
project
product_development
content_development
personal_development
maturity_development
routine_operations
audit_review
agent_execution
custom
```
Jede Domain kann eigene typische Lifecycles, Roadmap-Strukturen, Methoden, Hooks und Signals haben.
---
## 10. Steerable Objects
Steuerbar sind nicht nur Initiativen.
Mögliche steuerbare Objekte:
```text
program
initiative
project
roadmap
roadmap_item
backlog_item
action
blocker
review
evidence
decision
recurring_element
work_cycle
agent_run
custom
```
Diese Objekte werden über Steering Contexts an Methoden und Domains gebunden.
---
## 11. Steering Context
Ein Steering Context beschreibt:
```text
Für welches steuerbare Objekt gilt welche Methode, Domain und Lifecycle-Logik?
```
Konzeptuell:
```text
SteeringContext
- tenant_id
- scope_type
- scope_id
- steering_domain
- lifecycle_model
- steering_method
- method_profile_id optional
- method_version
- configuration_json
- inherits_from_parent
- is_active
```
Untergeordnete Objekte können den Steering Context ihres Parents erben oder überschreiben.
---
## 12. Roadmap / Development Model
Roadmap ist ein Kernmodell, nicht nur eine UI-Ansicht.
Kairo braucht Roadmaps, um Entwicklungsrichtung und Entwicklungsstruktur abzubilden.
Roadmap-Struktur:
```text
Roadmap
→ Roadmap Lane
→ Roadmap Item
```
RoadmapItems können sein:
```text
milestone
maturity_stage
feature
capability
phase
release
chapter
research_topic
work_cycle
learning_step
review_gate
decision_gate
architecture_step
dependency
risk_reduction
custom
```
Entscheidung:
```text
Milestone = RoadmapItem(type = milestone)
```
---
## 13. Roadmap vs. Backlog vs. Action
Zentrale Unterscheidung:
```text
RoadmapItem = Entwicklungsziel / Orientierung / geplanter Entwicklungsschritt
BacklogItem = konkreter möglicher Handlungsbedarf
Action = freigegebene operative Maßnahme
```
Beispiel Softwareentwicklung:
```text
RoadmapItem:
Attention Engine regelbasiert
BacklogItems:
- Attention DTO definieren
- Blockierte Maßnahmen auswerten
- Widget bauen
Actions:
- Endpoint implementieren
- Tests schreiben
```
Beispiel Buchentwicklung:
```text
RoadmapItem:
Kapitel 3 Argumentation ausarbeiten
BacklogItems:
- Recherche zur These prüfen
- Beispiel suchen
- Rohfassung schreiben
Actions:
- Heute Abschnitt 3.1 entwerfen
```
Beispiel Reifegradentwicklung:
```text
RoadmapItem:
Reifegrad: stabile Seitkick-Beweglichkeit
BacklogItems:
- Hüftmobilität prüfen
- Dehnroutine auswählen
- Technikvideo aufnehmen
Actions:
- 10 Minuten Mobility durchführen
```
---
## 14. Structure Builder
Structure Builder erzeugen methodenspezifische Steuerungsstrukturen.
Beispiele:
```text
roadmap_builder
milestone_plan_builder
feature_landscape_builder
wbs_builder
backlog_builder
maturity_path_builder
chapter_structure_builder
cycle_structure_builder
review_gate_builder
routine_structure_builder
```
Sie sind zentrale Erweiterungspunkte für Methoden.
Ein Structure Builder legt z. B. an:
```text
- Roadmap
- Roadmap Lanes
- Roadmap Items
- initiale BacklogItems
- Review Gates
- WorkCycles
```
---
## 15. Hooks
Hooks sind stabile Einhängepunkte in Steuerungsabläufen.
Beispiele:
```text
on_goal_captured
on_scope_named
on_method_selection_required
on_method_selected
on_structure_required
on_roadmap_required
on_backlog_required
on_wbs_required
on_maturity_path_required
on_content_structure_required
on_plan_required
on_dependency_analysis_required
on_dod_definition_required
on_next_action_requested
on_assignment_required
on_wait_started
on_due_date_reached
on_result_overdue
on_reminder_required
on_escalation_required
on_result_received
on_evidence_required
on_review_due
on_reassessment_required
on_replan_required
on_closure_requested
```
An Hooks können später kleine Workflow-Fragmente, Rules, Prompts, Agent Steps oder Tool Steps gebunden werden.
Hooks sind nicht beliebige technische Events, sondern fachlich stabile Steuerungspunkte.
---
## 16. Strategies
Methoden bringen oder referenzieren Strategies.
Zentrale Strategy-Typen:
```text
NextActionStrategy
AttentionRuleProvider
AssignmentStrategy
WaitingStrategy
ReminderStrategy
EscalationStrategy
ResultIntakeStrategy
EvidencePolicy
ReviewStrategy
ClosurePolicy
```
Diese verhindern, dass methodenspezifische Logik monolithisch im Core wächst.
---
## 17. Signals
Signals sind sichtbare Ausgaben des Steuerungskerns.
Signal Types:
```text
attention
next_action
review_due
evidence_required
escalation
blocker_signal
progress_signal
risk_signal
decision_required
dependency_signal
dod_signal
workflow_trigger
```
### Attention
Beantwortet:
```text
Was braucht Aufmerksamkeit?
```
### NextActionCandidate
Beantwortet:
```text
Was ist der nächste wirksame Schritt?
```
Signals müssen erklärbar sein:
```text
- warum erzeugt?
- durch welche Methode?
- durch welchen Hook?
- auf welchen Scope bezogen?
- welche Datenquelle?
- welche empfohlene Handlung?
```
---
## 18. Assignment / Waiting / Reminder / Escalation
Der Steuerungskern endet nicht mit dem Erzeugen einer Action.
Kairo muss steuern:
```text
- Actor auswählen
- Actor zuweisen
- auf Ergebnis warten
- Fälligkeit überwachen
- erinnern
- eskalieren
- externe Ereignisse aufnehmen
- Agentenergebnisse zurückführen
```
Actors können sein:
```text
human
agent
working_group
external_system
```
---
## 19. Result Intake / Evidence / Review / Decision
Kairo führt Ergebnisse zurück und entscheidet, was daraus folgt.
Mögliche Rückführungen:
```text
- Action erledigt
- Ergebnis unvollständig
- Evidence erforderlich
- Review erforderlich
- Decision erforderlich
- Blocker entstanden
- RoadmapItem erreicht
- Reifegrad plausibel erhöht
- neue NextAction nötig
```
Evidence, Reviews und Decisions sind zentrale Qualitätssicherungs- und Steuerungsobjekte.
---
## 20. Workflow-Fragmente und Runtime
Kairo soll langfristig Workflow-Fähigkeit haben.
Aber:
```text
Workflows sind methodengeführte Steuerungsabläufe,
keine beliebige freie Automatisierungsplattform als Produktkern.
```
Zielmodell:
```text
Hook
→ Workflow Fragment
→ Node / Step
→ Result
→ Rückführung in Kairo
```
Workflow-Fragmente können später enthalten:
```text
human_task
approval
decision_gate
review
reflection
llm_prompt
agent_task
tool_call
state_transition
evidence_check
dod_check
dependency_analysis
notification
reminder
escalation
```
---
## 21. Agenten / LLMs / Tools
Kairo bleibt Steering Authority.
Agenten, LLMs und Tools sind Provider.
Sie können später an Hooks und Workflow-Fragmente angebunden werden.
Grundsatz:
```text
Kairo entscheidet Kontext, Methode, Auftrag und Rückführung.
Agenten / LLMs / Tools führen einzelne Steps aus.
```
Agenten sind Actors.
Das ist wichtig für:
```text
- Verantwortlichkeit
- Audit
- Assignments
- Sichtbarkeit
- Tenant-Kontext
```
---
## 22. Security / Governance / Audit
Kairo muss tenant- und actor-sicher bleiben.
Grundsätze:
```text
- tenant_id immer aus TenantContext
- keine tenant_id aus Client Input vertrauen
- Actor-Bezüge tenant-intern validieren
- Capabilities prüfen
- Router dünn
- Services schreiben
- Data Layer liest
- Audit-by-design
- Signals und NextActions erklärbar
- Agentenaktionen nachvollziehbar
```
---
## 23. UI-Zielbild
Die UI dient als Steuerungs- und Interaktionsschicht.
Langfristige Views:
```text
Workspace
Steering View
Roadmap View
Method View
Attention / NextAction View
Backlog View
Action View
Waiting / Reminder View
Evidence / Review / Decision View
Workflow Run View später
Method/Profile Admin später
Agent Run View später
```
Die UI darf nicht die Methode hart kodieren.
Sie muss methodenbewusst und strukturgetrieben sein.
---
## 24. Architektur-Evolutionslinie
Grobe Evolutionslinie:
```text
1. Existing Foundation
2. Adaptive Steering Core Foundation
3. Method Registry / Hook Registry / Standard Lifecycle
4. Roadmap / Structure Builder Foundation
5. Signal / NextAction Engine
6. Assignment / Waiting / Reminder / Escalation
7. Evidence / Review / Decision
8. Workflow Fragments and Bindings
9. Workflow Runtime
10. Agent / LLM / Tool Providers
11. Method Profiles
12. Method Designer / Advanced Configuration
13. Advanced AI Steering / Next Best Action Ranking
```
Dies ist keine Sprintplanung, sondern ein Zielbild der technischen Entwicklung.
---
## 25. Zielzustand in einem Satz
Kairo soll ein methodengeführter adaptiver Steuerungskern werden, der Entwicklungen unterschiedlichster Art über registrierbare Methoden, gemeinsame Kernstrukturen, Roadmaps, Hooks, Signals, NextActions, Assignments, Waiting-/Reminder-/Escalation-Mechanismen und später Agent-/Workflow-Fragmente wirksam und nachvollziehbar steuert.

View File

@ -1,7 +1,7 @@
# AP0.8 Operating Model Extension I
## Implementierungsauftrag zur Prüfung v0.1
**Status:** Entwurf zur Freigabe
**Status:** ersetzt durch `Sprint0_AP0_8_Assignment_v0.2.md`
**Stand:** 2026-07-05
**Vorgänger:** AP0.7 ✓ · AP0.R1 ✓
**Zielversion:** `0.8.0-ap0.8` · Schema `007`

View File

@ -0,0 +1,402 @@
# AP0.8 Operating Model Extension I
## Implementierungsauftrag v0.2 (freigegeben)
**Status:** freigegeben zur Umsetzung
**Stand:** 2026-07-05
**Vorgänger:** AP0.7 ✓ · AP0.R1 ✓ · KAIRO-ARCH-01 ✓
**Zielversion:** `0.8.0-ap0.8` · Schema `007`
**Ersetzt:** `Sprint0_AP0_8_Assignment_v0.1.md` (Entwurf)
---
## Einordnung
AP0.8 ist der **erste fachliche Ausbau nach dem Product Reset** und der **erste Schritt der Operating-Model-Evolutionslinie** gemäß System Target State §24:
```text
1. Existing Foundation ✓ AP0.1AP0.7
2. Adaptive Steering Core — nicht AP0.8 (→ AP1.x)
3. Method Registry / Hooks — nicht AP0.8
4. Roadmap / Structure Builder — nicht AP0.8 (Milestone als MVP-Brücke)
5. Signal / NextAction Engine ← AP0.8a startet hier (regelbasiert, Data Layer)
6. Assignment / Waiting / … — teilweise vorhanden; Waiting → AP0.9+
7. Evidence / Review / Decision — AP0.9
```
Ziel: Kairo sichtbar über `Initiative → Action` hinausheben und die Leitfrage operationalisieren:
> Welcher nächste Schritt bringt ein Vorhaben aktuell am wirkungsvollsten voran?
AP0.8 baut auf AP0.7 auf (Tenant-Invarianten, Actor Directory, Data Layer) und bleibt **regelbasiert ohne KI, ohne Method Registry, ohne SteeringContext**.
---
## Verbindliche Referenzen
Vor Umsetzung lesen:
1. `docs/product/Kairo_Product_Definition_and_MVP_Reset_v0.1.md`
2. `docs/product/Kairo_Canonical_Operating_Model_v0.1.md` — §5 Objektmodell, §7 Attention Logic, §8 Data Layer
3. `docs/product/Kairo_Corrected_MVP_Roadmap_v0.1.md` — AP0.8
4. `docs/architecture/Kairo_System_Target_State_v0.1.md` — §13 Roadmap/Backlog/Action, §17 Signals
5. `docs/architecture/Kairo_Target_Architecture_Method_Driven_Adaptive_Steering_Core_v0.1.md` — Phasen A/B, AD-TA-08
6. `docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
7. `docs/sprints/Sprint0_AP0_7_Completion_Report_v0.2.md`
---
## Abgleich System Target State
| Zielbild (System Target State) | AP0.8-Umsetzung |
|-------------------------------|-----------------|
| RoadmapItem = Entwicklungsziel; BacklogItem = möglicher Handlungsbedarf; Action = committete Maßnahme | Backlog und Action **getrennt**; Convert-Flow |
| Milestone = RoadmapItem(type=milestone) langfristig | **MVP-Brücke:** eigene `milestones`-Tabelle; Konsolidierung → RoadmapItem in Phase C (AD-TA-08) |
| Attention + NextActionCandidate als Signal-Typen | Read Models in `data_layer/attention.py` |
| Signals erklärbar (reason, scope, data_source) | DTO-Felder `reason_code`, `scope_type`, `scope_id`, `data_source` |
| Blocker als Steuerungsobjekt | eigene Tabelle, nicht nur Action-Status |
| Kein SteeringContext / Method Registry yet | explizit Nicht-Scope |
| Kein Workflow / KI | explizit Nicht-Scope |
---
## Leitentscheidungen (freigegeben)
| Entscheidung | Entscheidung | Begründung |
|--------------|--------------|------------|
| Umsetzungsreihenfolge | 4 Slices (8a→8d) in **einem** AP0.8 | Attention zuerst für Produktwirkung |
| Blocker | **Eigenes Objekt** | Canonical Model + System Target State §10 |
| Backlog vs. Action | **Getrennte Tabellen** | §13 Unterscheidung Program Director vs. To-do |
| Milestone | **Eigene Tabelle (MVP-Brücke)** | Schnell prüfbar; Roadmap-Konsolidierung später |
| Project | **Nicht in AP0.8** | Milestone direkt an Initiative |
| Action-Status `ready`/`review_required` | **Nicht in AP0.8** | AP0.9 |
| Due Dates auf Actions | **Nicht in AP0.8** | Regel 7 stub; Milestone `target_date` optional |
| Attention: open Blockers | **Ja, Regel 1b** | Ergänzung zu blocked Actions |
| Capabilities | 7 neue (1 read + 3×2) | Registry-first |
| `kairo.attention.read` | **Eigene Capability** | Explizit, Grants wie workspace.read |
| Member manage Blocker/Backlog/Milestone | **Ja** | wie Actions |
| Convert Backlog→Action | **Pflicht, minimal** | Commit-Flow |
| Blocker ↔ action.status=blocked | **Optional bei Create** | nicht zwingend synchron |
| UI | AP0.6b-Optik | kein Redesign |
---
## Empfohlene Umsetzungsreihenfolge
```text
AP0.8a Attention / NextActionCandidate (Read Model + Widget)
AP0.8b Blocker (Domäne + API + UI)
AP0.8c BacklogItem (Domäne + API + UI + convert)
AP0.8d Milestone minimal (Domäne + API + UI + Attention Regel 6)
```
Reihenfolge ist verbindlich für die Implementierung.
---
# Teil A — AP0.8a: Attention / NextActionCandidate
## Ziel
Regelbasierte **Signal-Schicht** im Data Layer — erste Instanz der Signal/NextAction-Engine (System Target State §17). Später Refactor in `backend/steering/signals/` mit Method Rule Providers.
## Data Layer
Neue Datei: `backend/data_layer/attention.py`
```text
get_attention_items(ctx) -> list[AttentionItemDTO]
get_next_action_candidates(ctx, *, limit=10) -> list[NextActionCandidateDTO]
```
**AttentionItemDTO** (mit Erklärbarkeit):
```json
{
"kind": "blocked_action | open_blocker | high_priority_action | unassigned_action | initiative_without_next_action | stale_initiative | milestone_at_risk",
"severity": "info | warning | critical",
"title": "...",
"summary": "...",
"scope_type": "action | blocker | initiative | milestone",
"scope_id": "...",
"initiative_id": "...",
"action_id": "...",
"blocker_id": "...",
"milestone_id": "...",
"reason_code": "...",
"data_source": "actions | blockers | initiatives | milestones | backlog_items"
}
```
**NextActionCandidateDTO:**
```json
{
"kind": "assign_action | resolve_blocker | create_action | convert_backlog | review_milestone",
"title": "...",
"summary": "...",
"initiative_id": "...",
"action_id": "...",
"backlog_item_id": "...",
"reason_code": "...",
"recommended_action": "..."
}
```
### Regeln
| # | Regel | Datenquelle | Severity |
|---|-------|-------------|----------|
| 1 | Blockierte Maßnahmen | `actions.status = blocked` | critical |
| 1b | Offene Blocker | `blockers.status IN (open, in_progress)` | warning |
| 2 | High-Priority offen | `priority = high`, open/in_progress; **personal** wenn Actor zugewiesen | warning |
| 3 | Maßnahmen ohne Assignment | keine `action_assignments` | warning |
| 4 | Vorhaben ohne offene Maßnahme | active/paused, keine open/in_progress/blocked Action | info |
| 5 | Lange unveränderte Vorhaben | `updated_at` > 14 Tage, status active | info |
| 6 | Meilenstein at_risk | ab AP0.8d: `milestones.status = at_risk` | warning |
| 79 | Due Dates, Reviews, Recurring | AP0.9+ — stub/leer | — |
**NextAction-Kandidaten (regelbasiert, limitiert):**
- unassigned Action → `assign_action`
- blocked Action / open Blocker → `resolve_blocker`
- Initiative ohne offene Maßnahme → `create_action`
- accepted BacklogItem ohne Action → `convert_backlog`
Sortierung Attention: critical → warning → info. Keine Client-Priorisierung.
## API
```text
GET /api/workspace/attention
GET /api/workspace/next-actions?limit=10
```
Capability: **`kairo.attention.read`**
## Frontend
- **`AttentionWidget`** (Registry-Key `kairo.attention`, order 8, unter Überblick)
- Links zu Vorhaben/Maßnahmen/Blockern
- Loading / Error / Empty States
## Tests
- Regeln 1, 1b, 25; Regel 6 nach 8d
- NextAction: convert_backlog, create_action
- Cross-Tenant isolation
- Router delegiert an Data Layer
---
# Teil B — AP0.8b: Blocker
## Migration `007_operating_model_extension_i.sql`
```sql
blockers (
id UUID PK,
tenant_id UUID NOT NULL REFERENCES tenants(id),
initiative_id UUID NOT NULL REFERENCES initiatives(id),
action_id UUID NULL REFERENCES actions(id),
title VARCHAR(255) NOT NULL,
description TEXT DEFAULT '',
status VARCHAR(32) NOT NULL, -- open, in_progress, resolved, accepted_risk, dismissed
reported_by_actor_id UUID NULL REFERENCES actors(id),
created_at, updated_at
)
```
Index: `(tenant_id, initiative_id)`, `(tenant_id, status)`
## Service + API
`backend/services/blockers.py` — CRUD, Audit: `blocker.created/updated/status_changed/deleted`
```text
GET/POST /api/initiatives/{id}/blockers
GET/PATCH/DELETE /api/blockers/{id}
```
Capabilities: **`kairo.blocker.read`**, **`kairo.blocker.manage`**
## Frontend
Vorhaben-Detail: Sektion **Blocker**
---
# Teil C — AP0.8c: BacklogItem
## Migration (gleiche `007`)
```sql
backlog_items (
id UUID PK,
tenant_id UUID NOT NULL,
initiative_id UUID NOT NULL REFERENCES initiatives(id),
title VARCHAR(255) NOT NULL,
description TEXT DEFAULT '',
status VARCHAR(32) NOT NULL, -- new, triaged, accepted, rejected, converted
priority VARCHAR(16) DEFAULT 'normal',
converted_action_id UUID NULL REFERENCES actions(id),
created_at, updated_at
)
```
## Service + API
```text
GET/POST /api/initiatives/{id}/backlog
GET/PATCH/DELETE /api/backlog/{id}
POST /api/backlog/{id}/convert-to-action
```
Capabilities: **`kairo.backlog.read`**, **`kairo.backlog.manage`**
Convert: erzeugt Action, setzt `converted_action_id`, status=converted, Audit.
## Frontend
Vorhaben-Detail: Sektion **Backlog** + Button „In Maßnahme umwandeln“
---
# Teil D — AP0.8d: Milestone minimal
## Migration (gleiche `007`)
```sql
milestones (
id UUID PK,
tenant_id UUID NOT NULL,
initiative_id UUID NOT NULL REFERENCES initiatives(id),
title VARCHAR(255) NOT NULL,
goal_description TEXT DEFAULT '',
status VARCHAR(32) NOT NULL, -- planned, active, at_risk, reached, moved, discarded
target_date DATE NULL,
created_at, updated_at
)
```
**Hinweis Zielarchitektur:** Langfristig `RoadmapItem(item_type=milestone)`. Diese Tabelle ist bewusste MVP-Brücke (AD-TA-08).
## Service + API
```text
GET/POST /api/initiatives/{id}/milestones
GET/PATCH/DELETE /api/milestones/{id}
```
Capabilities: **`kairo.milestone.read`**, **`kairo.milestone.manage`**
Attention-Regel 6 nach Implementierung aktivieren.
## Frontend
Vorhaben-Detail: Sektion **Meilensteine** (kompakt, kein Gantt)
---
# Capabilities AP0.8
| Capability | Modul | Grants |
|------------|-------|--------|
| `kairo.attention.read` | attention | Member+ (wie workspace.read) |
| `kairo.blocker.read` | blocker | Member+ |
| `kairo.blocker.manage` | blocker | Member+ |
| `kairo.backlog.read` | backlog | Member+ |
| `kairo.backlog.manage` | backlog | Member+ |
| `kairo.milestone.read` | milestone | Member+ |
| `kairo.milestone.manage` | milestone | Member+ |
Registrierung: `backend/rights_registrations/operating_model_ops.py`
Gesamt nach AP0.8: **25 Capabilities** (18 + 7)
---
# Architekturregeln (verbindlich)
1. **Tenant-first**`tenant_id` aus TenantContext
2. **Actor-first**`reported_by_actor_id` optional; Assignments unverändert
3. **Data Layer read / Services write** — Attention nur Data Layer
4. **Router dünn** — keine Aggregations-SQL in Routern
5. **Signals erklärbar** — reason_code, scope_type, data_source in DTOs
6. **Cross-Tenant → 404**
7. **Migration `007`** — kein ad-hoc DDL
8. **Kein Prompt/KI/MCP/Workflow/SteeringContext/Method Registry**
9. **Product Reset** — drei Ebenen RoadmapItem/BacklogItem/Action respektieren
---
# Nicht-Scope AP0.8
- Project / Program / SteeringContext / Method Registry / Hook Runtime
- Evidence, Decision, Review, RecurringElement (→ AP0.9)
- Action-Status `ready`, `review_required`
- Due Dates auf Actions / Überfälligkeits-Engine
- KI, MCP, Workflow Engine
- Roadmap/RoadmapLane/RoadmapItem-Modell (→ Phase C)
- Workspace-weite Backlog-Navigation
- AP0.10 Validation Testvorhaben
---
# Tests (Mindestumfang)
## Backend — `backend/tests/test_ap08_operating_model.py`
| Bereich | Tests |
|---------|-------|
| Attention | Regeln 1, 1b, 26, Sortierung, Cross-Tenant |
| NextAction | assign, convert_backlog, create_action, limit |
| Blocker | CRUD, Tenant-Isolation, optional action_id |
| Backlog | CRUD, convert-to-action |
| Milestone | CRUD, at_risk in Attention |
| Capabilities | Member grants, Gate 403, 25 sync |
| Migration | `007` in test_migrations |
## Frontend
- Vitest: Registry enthält `kairo.attention`
- Build grün
---
# Abnahmekriterien AP0.8
1. Migration `007`; Schema `007`
2. Attention/NextAction regelbasiert (Regeln 16)
3. Blocker, Backlog, Milestone CRUD tenant-sicher
4. `data_layer/attention.py`; Router delegieren
5. AttentionWidget im Workspace
6. Vorhaben-Detail: Blocker, Backlog, Meilensteine
7. Convert Backlog → Action
8. Cross-Tenant-Tests grün
9. 25 Capabilities registriert
10. AP0.6b UX erhalten
11. Kein Prompt/KI/MCP/Steering-Scope
12. Abschlussbericht + README
---
# Abschlussbericht (nach Umsetzung)
Siehe Vorlage in v0.1 — Abschnitte 115.
---
# Freigegebene Punkte (ehemals offen)
| # | Entscheidung |
|---|--------------|
| 1 | Ein AP0.8, 4 interne Slices |
| 2 | Eigene Capability `kairo.attention.read` |
| 3 | Blocker/Action-Status optional synchron |
| 4 | Convert Backlog→Action Pflicht |
| 5 | Member manage wie Actions |
| 6 | Initiative draft → AP0.8.1 |
| 7 | Backlog nur Initiative-Detail |
---
*Freigegeben — Umsetzung gemäß Slice-Reihenfolge 8a→8d.*

View File

@ -0,0 +1,149 @@
# AP0.8 Abschlussbericht Operating Model Extension I
**Status:** abgeschlossen (Implementierung) · QA mit DB ausstehend
**Stand:** 2026-07-05
**Version:** Backend/Frontend `0.8.0-ap0.8` · Schema `007`
**Auftrag:** `Sprint0_AP0_8_Assignment_v0.2.md` (abgeglichen mit System Target State v0.1)
---
## 1. Scope und Einordnung
AP0.8 ist der erste fachliche Ausbau nach dem Product Reset. Er operationalisiert die Leitfrage über regelbasierte Attention/NextAction und erweitert das Domänenmodell um Blocker, BacklogItem und Milestone.
Einordnung in der Evolutionslinie (System Target State §24):
- Foundation ✓ (AP0.7)
- **Signal / NextAction Engine — erste Instanz** ✓ (AP0.8a, Data Layer)
- Operating Model Phase A ✓ (Blocker, Backlog, Milestone)
- SteeringContext / Method Registry — bewusst **nicht** in AP0.8
---
## 2. Definition of Done — Prüfmatrix
| Kriterium | Ergebnis | Nachweis |
|-----------|----------|----------|
| Migration 007 | ✓ | `007_operating_model_extension_i.sql` |
| Attention Regeln 16 | ✓ | `data_layer/attention.py` |
| NextActionCandidates | ✓ | `get_next_action_candidates` |
| Blocker CRUD | ✓ | `services/blockers.py`, Router |
| Backlog CRUD + Convert | ✓ | `services/backlog.py` |
| Milestone CRUD | ✓ | `services/milestones.py` |
| AttentionWidget | ✓ | `widgets/AttentionWidget.jsx` |
| Initiative-Detail Sektionen | ✓ | Blockers/Backlog/Milestones |
| 25 Capabilities | ✓ | `operating_model_ops.py` |
| Frontend Vitest + Build | ✓ | 9/9, build grün |
| Backend pytest | ⏳ Remote | Gitea `test.yml` nach Push auf `develop` (Pi, Container) |
| Kein KI/MCP/Steering | ✓ | Scope eingehalten |
---
## 3. Migration 007
Tabellen: `blockers`, `backlog_items`, `milestones` — alle mit `tenant_id`, FK RESTRICT/CASCADE wie AP0.5.
---
## 4. Attention / NextActionCandidate
- Datei: `backend/data_layer/attention.py`
- API: `GET /api/workspace/attention`, `GET /api/workspace/next-actions`
- Regeln: blocked_action, open_blocker, high_priority (personal), unassigned, initiative_without_next_action, stale_initiative, milestone_at_risk
- DTOs mit `reason_code`, `scope_type`, `data_source` (Erklärbarkeit gemäß System Target State §17)
---
## 5. Blocker
- Service + nested/flat API
- Audit: `blocker.created/updated/status_changed/deleted`
- Optional: `set_action_blocked` bei Create
---
## 6. BacklogItem
- Getrennt von Actions (RoadmapItem/Backlog/Action-Unterscheidung)
- Convert-to-action mit Audit `backlog.converted_to_action`
---
## 7. Milestone minimal
- MVP-Brücke (langfristig RoadmapItem — AD-TA-08)
- Attention-Regel 6 für `at_risk`
---
## 8. Data Layer Erweiterung
Neu: `attention.py` — Read-only, tenant-scoped, keine Router-SQL.
---
## 9. API-Endpunkte
Siehe README AP0.8-Tabelle.
---
## 10. Capabilities
7 neue: `kairo.attention.read`, `kairo.blocker.*`, `kairo.backlog.*`, `kairo.milestone.*`
Gesamt: 25
---
## 11. Frontend-Integration
- `AttentionWidget` (order 8, capability `kairo.attention.read`)
- `BlockersSection`, `BacklogSection`, `MilestonesSection` im Vorhaben-Detail
- Status-Badges erweitert für blocker/backlog/milestone
---
## 12. Tests und Verifikation
| Test | Status |
|------|--------|
| `test_ap08_operating_model.py` | 14 Tests — Lauf auf Pi via `test.yml` |
| `test_rights_registry.py` | aktualisiert (25 Capabilities) |
| `test_migrations.py` | Migration 007 ergänzt |
| `conftest.py` | Session-Guard: skip lokal ohne DB, kein Migrations-Timeout |
| Vitest | 9/9 grün (lokal) |
| `vite build` | grün (lokal) |
**Verifikation:** Push auf `develop``deploy-dev.yml` + `test.yml` (pytest im Backend-Container gegen PostgreSQL im Compose-Stack).
---
## 13. Abweichungen von diesem Auftrag
| Punkt | Abweichung |
|-------|------------|
| Auftrag v0.1 | Ersetzt durch v0.2 nach Abgleich System Target State |
| Attention DTO | Zusätzlich `scope_type`, `data_source`, Regel 1b open_blocker |
| NextAction DTO | Explizit dokumentiert und implementiert |
| Milestone API target_date | Router akzeptiert ISO-Date-String |
Keine fachlichen Scope-Abweichungen.
---
## 14. Offene Punkte für AP0.9
- Evidence, Decision, Review, RecurringElement
- Action-Status `ready`, `review_required`
- Due Dates auf Actions + Attention Regeln 79
- Attention-Refactor in `backend/steering/signals/` (wenn Steering Core startet)
---
## 15. Empfehlung für AP0.10 / Validation
Nach AP0.9 ein reales Testvorhaben durchspielen (Backlog → Action → Blocker → Meilenstein → Attention). Milestone→RoadmapItem-Konsolidierung als ADP vor Phase C klären.
---
*Implementierung abgeschlossen — Backend-pytest und Smoke auf dem Pi nach Push auf `develop`.*

View File

@ -1,6 +1,6 @@
{
"name": "kairo-jinkendo-frontend",
"version": "0.7.0-ap0.7",
"version": "0.8.0-ap0.8",
"private": true,
"type": "module",
"scripts": {

View File

@ -0,0 +1,9 @@
import { apiFetch } from './client.js'
export function getAttentionItems() {
return apiFetch('/api/workspace/attention')
}
export function getNextActionCandidates(limit = 10) {
return apiFetch(`/api/workspace/next-actions?limit=${limit}`)
}

View File

@ -0,0 +1,30 @@
import { apiFetch } from './client.js'
export function listInitiativeBacklog(initiativeId) {
return apiFetch(`/api/initiatives/${initiativeId}/backlog`)
}
export function createInitiativeBacklogItem(initiativeId, body) {
return apiFetch(`/api/initiatives/${initiativeId}/backlog`, {
method: 'POST',
body: JSON.stringify(body),
})
}
export function updateBacklogItem(backlogItemId, body) {
return apiFetch(`/api/backlog/${backlogItemId}`, {
method: 'PATCH',
body: JSON.stringify(body),
})
}
export function deleteBacklogItem(backlogItemId) {
return apiFetch(`/api/backlog/${backlogItemId}`, { method: 'DELETE' })
}
export function convertBacklogToAction(backlogItemId, body = {}) {
return apiFetch(`/api/backlog/${backlogItemId}/convert-to-action`, {
method: 'POST',
body: JSON.stringify(body),
})
}

View File

@ -0,0 +1,23 @@
import { apiFetch } from './client.js'
export function listInitiativeBlockers(initiativeId) {
return apiFetch(`/api/initiatives/${initiativeId}/blockers`)
}
export function createInitiativeBlocker(initiativeId, body) {
return apiFetch(`/api/initiatives/${initiativeId}/blockers`, {
method: 'POST',
body: JSON.stringify(body),
})
}
export function updateBlocker(blockerId, body) {
return apiFetch(`/api/blockers/${blockerId}`, {
method: 'PATCH',
body: JSON.stringify(body),
})
}
export function deleteBlocker(blockerId) {
return apiFetch(`/api/blockers/${blockerId}`, { method: 'DELETE' })
}

View File

@ -0,0 +1,23 @@
import { apiFetch } from './client.js'
export function listInitiativeMilestones(initiativeId) {
return apiFetch(`/api/initiatives/${initiativeId}/milestones`)
}
export function createInitiativeMilestone(initiativeId, body) {
return apiFetch(`/api/initiatives/${initiativeId}/milestones`, {
method: 'POST',
body: JSON.stringify(body),
})
}
export function updateMilestone(milestoneId, body) {
return apiFetch(`/api/milestones/${milestoneId}`, {
method: 'PATCH',
body: JSON.stringify(body),
})
}
export function deleteMilestone(milestoneId) {
return apiFetch(`/api/milestones/${milestoneId}`, { method: 'DELETE' })
}

View File

@ -0,0 +1,115 @@
import { useState } from 'react'
import {
BACKLOG_STATUSES,
BACKLOG_STATUS_LABELS,
} from '../constants/status.js'
import { StatusBadge } from './StatusBadge.jsx'
import { PriorityBadge } from './PriorityBadge.jsx'
import { EmptyState } from './EmptyState.jsx'
export function BacklogSection({
items,
canManage,
onCreate,
onUpdateStatus,
onConvert,
onDelete,
busy,
}) {
const [title, setTitle] = useState('')
const [showForm, setShowForm] = useState(false)
async function handleSubmit(e) {
e.preventDefault()
if (!title.trim()) return
await onCreate({ title: title.trim() })
setTitle('')
setShowForm(false)
}
return (
<section className="card">
<div className="section-header">
<h2>Backlog</h2>
{canManage && (
<button
type="button"
className="btn btn-primary btn-block-mobile"
onClick={() => setShowForm((v) => !v)}
>
{showForm ? 'Abbrechen' : 'Backlog-Item'}
</button>
)}
</div>
{showForm && canManage && (
<form className="inline-form-block" onSubmit={handleSubmit}>
<label>
Titel
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={255}
required
/>
</label>
<button type="submit" className="btn btn-primary" disabled={busy}>
Anlegen
</button>
</form>
)}
{items.length === 0 && <EmptyState message="Backlog ist leer." />}
<ul className="item-list">
{items.map((item) => (
<li key={item.id} className="list-item card-list-item">
<div className="list-item-main">
<strong>{item.title}</strong>
{item.description && (
<p className="list-item-desc">{item.description}</p>
)}
</div>
<div className="list-item-meta action-controls">
<StatusBadge kind="backlog" status={item.status} />
<PriorityBadge priority={item.priority} />
{canManage && item.status !== 'converted' && (
<>
<select
className="inline-select"
value={item.status}
onChange={(e) => onUpdateStatus(item.id, e.target.value)}
aria-label="Backlog-Status"
>
{BACKLOG_STATUSES.filter((s) => s !== 'converted').map((s) => (
<option key={s} value={s}>
{BACKLOG_STATUS_LABELS[s]}
</option>
))}
</select>
{item.status === 'accepted' && (
<button
type="button"
className="btn btn-primary"
onClick={() => onConvert(item.id)}
disabled={busy}
>
In Maßnahme umwandeln
</button>
)}
<button
type="button"
className="btn btn-secondary"
onClick={() => onDelete(item.id)}
>
Löschen
</button>
</>
)}
</div>
</li>
))}
</ul>
</section>
)
}

View File

@ -0,0 +1,102 @@
import { useState } from 'react'
import {
BLOCKER_STATUSES,
BLOCKER_STATUS_LABELS,
} from '../constants/status.js'
import { StatusBadge } from './StatusBadge.jsx'
import { EmptyState } from './EmptyState.jsx'
export function BlockersSection({
blockers,
canManage,
onCreate,
onUpdateStatus,
onDelete,
busy,
}) {
const [title, setTitle] = useState('')
const [showForm, setShowForm] = useState(false)
async function handleSubmit(e) {
e.preventDefault()
if (!title.trim()) return
await onCreate({ title: title.trim() })
setTitle('')
setShowForm(false)
}
return (
<section className="card">
<div className="section-header">
<h2>Blocker</h2>
{canManage && (
<button
type="button"
className="btn btn-primary btn-block-mobile"
onClick={() => setShowForm((v) => !v)}
>
{showForm ? 'Abbrechen' : 'Blocker melden'}
</button>
)}
</div>
{showForm && canManage && (
<form className="inline-form-block" onSubmit={handleSubmit}>
<label>
Titel
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={255}
required
/>
</label>
<button type="submit" className="btn btn-primary" disabled={busy}>
Anlegen
</button>
</form>
)}
{blockers.length === 0 && <EmptyState message="Keine Blocker." />}
<ul className="item-list">
{blockers.map((blocker) => (
<li key={blocker.id} className="list-item card-list-item">
<div className="list-item-main">
<strong>{blocker.title}</strong>
{blocker.description && (
<p className="list-item-desc">{blocker.description}</p>
)}
</div>
<div className="list-item-meta action-controls">
<StatusBadge kind="blocker" status={blocker.status} />
{canManage && (
<>
<select
className="inline-select"
value={blocker.status}
onChange={(e) => onUpdateStatus(blocker.id, e.target.value)}
aria-label="Blocker-Status"
>
{BLOCKER_STATUSES.map((s) => (
<option key={s} value={s}>
{BLOCKER_STATUS_LABELS[s]}
</option>
))}
</select>
<button
type="button"
className="btn btn-secondary"
onClick={() => onDelete(blocker.id)}
>
Löschen
</button>
</>
)}
</div>
</li>
))}
</ul>
</section>
)
}

View File

@ -0,0 +1,105 @@
import { useState } from 'react'
import {
MILESTONE_STATUSES,
MILESTONE_STATUS_LABELS,
} from '../constants/status.js'
import { StatusBadge } from './StatusBadge.jsx'
import { EmptyState } from './EmptyState.jsx'
export function MilestonesSection({
milestones,
canManage,
onCreate,
onUpdateStatus,
onDelete,
busy,
}) {
const [title, setTitle] = useState('')
const [showForm, setShowForm] = useState(false)
async function handleSubmit(e) {
e.preventDefault()
if (!title.trim()) return
await onCreate({ title: title.trim() })
setTitle('')
setShowForm(false)
}
return (
<section className="card">
<div className="section-header">
<h2>Meilensteine</h2>
{canManage && (
<button
type="button"
className="btn btn-primary btn-block-mobile"
onClick={() => setShowForm((v) => !v)}
>
{showForm ? 'Abbrechen' : 'Meilenstein'}
</button>
)}
</div>
{showForm && canManage && (
<form className="inline-form-block" onSubmit={handleSubmit}>
<label>
Titel
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
maxLength={255}
required
/>
</label>
<button type="submit" className="btn btn-primary" disabled={busy}>
Anlegen
</button>
</form>
)}
{milestones.length === 0 && <EmptyState message="Keine Meilensteine." />}
<ul className="item-list">
{milestones.map((milestone) => (
<li key={milestone.id} className="list-item card-list-item">
<div className="list-item-main">
<strong>{milestone.title}</strong>
{milestone.goal_description && (
<p className="list-item-desc">{milestone.goal_description}</p>
)}
{milestone.target_date && (
<p className="muted list-item-sub">Ziel: {milestone.target_date}</p>
)}
</div>
<div className="list-item-meta action-controls">
<StatusBadge kind="milestone" status={milestone.status} />
{canManage && (
<>
<select
className="inline-select"
value={milestone.status}
onChange={(e) => onUpdateStatus(milestone.id, e.target.value)}
aria-label="Meilenstein-Status"
>
{MILESTONE_STATUSES.map((s) => (
<option key={s} value={s}>
{MILESTONE_STATUS_LABELS[s]}
</option>
))}
</select>
<button
type="button"
className="btn btn-secondary"
onClick={() => onDelete(milestone.id)}
>
Löschen
</button>
</>
)}
</div>
</li>
))}
</ul>
</section>
)
}

View File

@ -1,4 +1,4 @@
import { INITIATIVE_STATUS_LABELS, ACTION_STATUS_LABELS } from '../constants/status.js'
import { STATUS_LABELS_BY_KIND } from '../constants/status.js'
const VARIANTS = {
active: 'status-active',
@ -10,10 +10,22 @@ const VARIANTS = {
blocked: 'status-blocked',
done: 'status-done',
discarded: 'status-discarded',
new: 'status-open',
triaged: 'status-progress',
accepted: 'status-active',
rejected: 'status-discarded',
converted: 'status-done',
planned: 'status-open',
at_risk: 'status-blocked',
reached: 'status-done',
moved: 'status-paused',
resolved: 'status-done',
accepted_risk: 'status-paused',
dismissed: 'status-discarded',
}
export function StatusBadge({ kind = 'action', status }) {
const labels = kind === 'initiative' ? INITIATIVE_STATUS_LABELS : ACTION_STATUS_LABELS
const labels = STATUS_LABELS_BY_KIND[kind] || STATUS_LABELS_BY_KIND.action
const label = labels[status] || status
const variant = VARIANTS[status] || 'status-default'
return <span className={`badge status-badge ${variant}`}>{label}</span>

View File

@ -1,5 +1,8 @@
export const INITIATIVE_STATUSES = ['active', 'paused', 'completed', 'archived']
export const ACTION_STATUSES = ['open', 'in_progress', 'blocked', 'done', 'discarded']
export const BLOCKER_STATUSES = ['open', 'in_progress', 'resolved', 'accepted_risk', 'dismissed']
export const BACKLOG_STATUSES = ['new', 'triaged', 'accepted', 'rejected', 'converted']
export const MILESTONE_STATUSES = ['planned', 'active', 'at_risk', 'reached', 'moved', 'discarded']
export const PRIORITIES = ['low', 'normal', 'high']
export const OPEN_ACTION_STATUSES = ['open', 'in_progress', 'blocked']
@ -18,8 +21,41 @@ export const ACTION_STATUS_LABELS = {
discarded: 'Verworfen',
}
export const BLOCKER_STATUS_LABELS = {
open: 'Offen',
in_progress: 'In Klärung',
resolved: 'Gelöst',
accepted_risk: 'Risiko akzeptiert',
dismissed: 'Verworfen',
}
export const BACKLOG_STATUS_LABELS = {
new: 'Neu',
triaged: 'Eingeordnet',
accepted: 'Freigegeben',
rejected: 'Abgelehnt',
converted: 'Umgewandelt',
}
export const MILESTONE_STATUS_LABELS = {
planned: 'Geplant',
active: 'Aktiv',
at_risk: 'Gefährdet',
reached: 'Erreicht',
moved: 'Verschoben',
discarded: 'Verworfen',
}
export const PRIORITY_LABELS = {
low: 'Niedrig',
normal: 'Normal',
high: 'Hoch',
}
export const STATUS_LABELS_BY_KIND = {
initiative: INITIATIVE_STATUS_LABELS,
action: ACTION_STATUS_LABELS,
blocker: BLOCKER_STATUS_LABELS,
backlog: BACKLOG_STATUS_LABELS,
milestone: MILESTONE_STATUS_LABELS,
}

View File

@ -6,10 +6,32 @@ import {
createInitiativeAction,
} from '../api/initiatives.js'
import { updateAction, setActionAssignments } from '../api/actions.js'
import {
listInitiativeBlockers,
createInitiativeBlocker,
updateBlocker,
deleteBlocker,
} from '../api/blockers.js'
import {
listInitiativeBacklog,
createInitiativeBacklogItem,
updateBacklogItem,
deleteBacklogItem,
convertBacklogToAction,
} from '../api/backlog.js'
import {
listInitiativeMilestones,
createInitiativeMilestone,
updateMilestone,
deleteMilestone,
} from '../api/milestones.js'
import { ACTION_STATUS_LABELS } from '../constants/status.js'
import { StatusBadge } from '../components/StatusBadge.jsx'
import { PriorityBadge } from '../components/PriorityBadge.jsx'
import { ActionForm } from '../components/ActionForm.jsx'
import { BlockersSection } from '../components/BlockersSection.jsx'
import { BacklogSection } from '../components/BacklogSection.jsx'
import { MilestonesSection } from '../components/MilestonesSection.jsx'
import { EmptyState } from '../components/EmptyState.jsx'
import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx'
@ -20,7 +42,7 @@ import { useSession } from '../context/SessionContext.jsx'
export function InitiativeDetailPage() {
const { id } = useParams()
const { context } = useSession()
const { hasCapability } = useCapabilities()
const { capabilities } = useCapabilities()
const {
actors,
loading: actorsLoading,
@ -31,6 +53,9 @@ export function InitiativeDetailPage() {
const [initiative, setInitiative] = useState(null)
const [actions, setActions] = useState([])
const [blockers, setBlockers] = useState([])
const [backlogItems, setBacklogItems] = useState([])
const [milestones, setMilestones] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [showActionForm, setShowActionForm] = useState(false)
@ -48,12 +73,24 @@ export function InitiativeDetailPage() {
])
setInitiative(initData)
setActions(actionData)
const loads = []
if (capabilities.has('kairo.blocker.read')) {
loads.push(listInitiativeBlockers(id).then(setBlockers).catch(() => setBlockers([])))
}
if (capabilities.has('kairo.backlog.read')) {
loads.push(listInitiativeBacklog(id).then(setBacklogItems).catch(() => setBacklogItems([])))
}
if (capabilities.has('kairo.milestone.read')) {
loads.push(listInitiativeMilestones(id).then(setMilestones).catch(() => setMilestones([])))
}
await Promise.all(loads)
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [id])
}, [id, capabilities])
useEffect(() => {
load()
@ -91,7 +128,7 @@ export function InitiativeDetailPage() {
status: payload.status,
priority: payload.priority,
})
if (hasCapability('kairo.action.manage')) {
if (capabilities.has('kairo.action.manage')) {
await setActionAssignments(actionId, payload.assigned_actor_ids || [])
}
setEditingAction(null)
@ -104,7 +141,7 @@ export function InitiativeDetailPage() {
}
async function handleQuickStatus(action, status) {
if (!hasCapability('kairo.action.manage')) return
if (!capabilities.has('kairo.action.manage')) return
try {
await updateAction(action.id, { status })
await load()
@ -113,6 +150,108 @@ export function InitiativeDetailPage() {
}
}
async function handleCreateBlocker(body) {
setFormBusy(true)
try {
await createInitiativeBlocker(id, body)
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleBlockerStatus(blockerId, status) {
try {
await updateBlocker(blockerId, { status })
await load()
} catch (err) {
setError(err.message)
}
}
async function handleDeleteBlocker(blockerId) {
try {
await deleteBlocker(blockerId)
await load()
} catch (err) {
setError(err.message)
}
}
async function handleCreateBacklog(body) {
setFormBusy(true)
try {
await createInitiativeBacklogItem(id, body)
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleBacklogStatus(itemId, status) {
try {
await updateBacklogItem(itemId, { status })
await load()
} catch (err) {
setError(err.message)
}
}
async function handleConvertBacklog(itemId) {
setFormBusy(true)
try {
await convertBacklogToAction(itemId)
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleDeleteBacklog(itemId) {
try {
await deleteBacklogItem(itemId)
await load()
} catch (err) {
setError(err.message)
}
}
async function handleCreateMilestone(body) {
setFormBusy(true)
try {
await createInitiativeMilestone(id, body)
await load()
} catch (err) {
setError(err.message)
} finally {
setFormBusy(false)
}
}
async function handleMilestoneStatus(milestoneId, status) {
try {
await updateMilestone(milestoneId, { status })
await load()
} catch (err) {
setError(err.message)
}
}
async function handleDeleteMilestone(milestoneId) {
try {
await deleteMilestone(milestoneId)
await load()
} catch (err) {
setError(err.message)
}
}
if (loading) {
return (
<div className="app-page">
@ -168,7 +307,7 @@ export function InitiativeDetailPage() {
/>
Erledigte ausblenden
</label>
{hasCapability('kairo.action.manage') && (
{capabilities.has('kairo.action.manage') && (
<button
type="button"
className="btn btn-primary btn-block-mobile"
@ -232,7 +371,7 @@ export function InitiativeDetailPage() {
<div className="list-item-meta action-controls">
<StatusBadge status={action.status} />
<PriorityBadge priority={action.priority} />
{hasCapability('kairo.action.manage') && (
{capabilities.has('kairo.action.manage') && (
<>
<select
className="inline-select"
@ -265,6 +404,40 @@ export function InitiativeDetailPage() {
))}
</ul>
</section>
{capabilities.has('kairo.milestone.read') && (
<MilestonesSection
milestones={milestones}
canManage={capabilities.has('kairo.milestone.manage')}
onCreate={handleCreateMilestone}
onUpdateStatus={handleMilestoneStatus}
onDelete={handleDeleteMilestone}
busy={formBusy}
/>
)}
{capabilities.has('kairo.backlog.read') && (
<BacklogSection
items={backlogItems}
canManage={capabilities.has('kairo.backlog.manage')}
onCreate={handleCreateBacklog}
onUpdateStatus={handleBacklogStatus}
onConvert={handleConvertBacklog}
onDelete={handleDeleteBacklog}
busy={formBusy}
/>
)}
{capabilities.has('kairo.blocker.read') && (
<BlockersSection
blockers={blockers}
canManage={capabilities.has('kairo.blocker.manage')}
onCreate={handleCreateBlocker}
onUpdateStatus={handleBlockerStatus}
onDelete={handleDeleteBlocker}
busy={formBusy}
/>
)}
</div>
)
}

View File

@ -11,14 +11,17 @@ describe('widgetRegistry', () => {
expect(keys).toContain('kairo.blocked_actions')
expect(keys).toContain('kairo.tenant_context')
expect(keys).toContain('kairo.workspace_summary')
expect(keys).toContain('kairo.attention')
})
it('filters widgets by capability', () => {
const all = getWidgetsForArea('workspace', [
'kairo.action.read',
'kairo.initiative.read',
'kairo.workspace.read',
'kairo.attention.read',
])
expect(all.length).toBe(4)
expect(all.length).toBe(6)
const readOnly = getWidgetsForArea('workspace', ['kairo.initiative.read'])
expect(readOnly.some((w) => w.key === 'kairo.my_open_actions')).toBe(false)

View File

@ -3,6 +3,7 @@ import { InitiativesWidget } from '../widgets/InitiativesWidget.jsx'
import { BlockedActionsWidget } from '../widgets/BlockedActionsWidget.jsx'
import { TenantContextWidget } from '../widgets/TenantContextWidget.jsx'
import { WorkspaceSummaryWidget } from '../widgets/WorkspaceSummaryWidget.jsx'
import { AttentionWidget } from '../widgets/AttentionWidget.jsx'
/** @typedef {import('react').ComponentType<any>} WidgetComponent */
@ -36,6 +37,15 @@ export const WIDGETS = [
defaultOrder: 5,
component: WorkspaceSummaryWidget,
},
{
key: 'kairo.attention',
title: 'Aufmerksamkeit',
description: 'Regelbasierte Attention-Items',
area: 'workspace',
requiredCapability: 'kairo.attention.read',
defaultOrder: 8,
component: AttentionWidget,
},
{
key: 'kairo.my_open_actions',
title: 'Meine offenen Maßnahmen',

View File

@ -311,6 +311,33 @@
padding: 12px;
}
.attention-severity {
display: inline-block;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 0.25rem;
}
.attention-severity--critical {
color: var(--jk-danger, #c0392b);
}
.attention-severity--warning {
color: var(--jk-warning, #d68910);
}
.attention-severity--info {
color: var(--jk-text-muted);
}
.attention-list .list-item-main {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.list-item-main {
flex: 1;
min-width: 0;

View File

@ -0,0 +1,89 @@
import { useCallback, useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { getAttentionItems } from '../api/attention.js'
import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx'
import { WidgetCard } from '../components/WidgetCard.jsx'
import { EmptyState } from '../components/EmptyState.jsx'
const SEVERITY_LABELS = {
critical: 'Kritisch',
warning: 'Warnung',
info: 'Hinweis',
}
function attentionLink(item) {
if (item.initiative_id && item.action_id) {
return `/initiatives/${item.initiative_id}`
}
if (item.initiative_id) {
return `/initiatives/${item.initiative_id}`
}
return null
}
export function AttentionWidget() {
const [items, setItems] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await getAttentionItems()
setItems(Array.isArray(data) ? data.slice(0, 8) : [])
} catch (err) {
setError(err.message)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
}, [load])
return (
<WidgetCard
title="Aufmerksamkeit"
subtitle="Was braucht jetzt Steuerung?"
actions={
<button type="button" className="btn btn-ghost" onClick={load} disabled={loading}>
Aktualisieren
</button>
}
>
{loading && <LoadingState />}
{!loading && error && <ErrorState message={error} onRetry={load} />}
{!loading && !error && items.length === 0 && (
<EmptyState message="Keine Attention-Items — alles im grünen Bereich." />
)}
{!loading && !error && items.length > 0 && (
<ul className="item-list attention-list">
{items.map((item) => {
const href = attentionLink(item)
return (
<li key={`${item.kind}-${item.scope_id}`} className="list-item card-list-item">
<div className="list-item-main">
<span className={`attention-severity attention-severity--${item.severity}`}>
{SEVERITY_LABELS[item.severity] || item.severity}
</span>
<strong>{item.title}</strong>
{item.summary && <p className="list-item-desc muted">{item.summary}</p>}
</div>
<div className="list-item-meta">
{href ? (
<Link to={href} className="btn btn-secondary btn-sm">
Ansehen
</Link>
) : null}
</div>
</li>
)
})}
</ul>
)}
</WidgetCard>
)
}