From 7e4936a2e4ef264b0dad0442282fe1f0d5142561 Mon Sep 17 00:00:00 2001
From: Lars
Date: Mon, 6 Jul 2026 08:43:11 +0200
Subject: [PATCH] AP1.6: Plan/Ist-Verknuepfung und Journey-Timeline.
Backlog, Actions, Projects und Tasks koennen Gates zugeordnet werden; Gate-Beitragsliste und chronologische Journey machen Plan/Ist nachvollziehbar.
Co-authored-by: Cursor
---
backend/main.py | 3 +
backend/routers/actions.py | 4 +
backend/routers/backlog.py | 5 +
backend/routers/initiatives.py | 4 +
backend/routers/journey.py | 44 +++
backend/services/actions.py | 46 ++-
backend/services/backlog.py | 62 +++-
backend/services/plan_ist.py | 322 ++++++++++++++++++
backend/tests/test_ap16_plan_ist.py | 94 +++++
backend/version.py | 2 +-
.../Kairo_Corrected_MVP_Roadmap_v0.2.md | 12 +-
.../Kairo_Implementation_Truth_Table_v0.1.md | 6 +-
frontend/src/api/journey.js | 9 +
frontend/src/components/ActionForm.jsx | 7 +
frontend/src/components/ActionHubCard.jsx | 8 +
frontend/src/components/BacklogSection.jsx | 44 ++-
.../components/GateContributionsSection.jsx | 93 +++++
frontend/src/components/GateSelect.jsx | 28 ++
.../src/components/InitiativeActionsHub.jsx | 4 +
frontend/src/components/JourneyTimeline.jsx | 71 ++++
frontend/src/components/ProjectsSection.jsx | 26 ++
frontend/src/components/TasksSection.jsx | 61 +++-
.../context/InitiativeOperationsContext.jsx | 15 +
frontend/src/pages/ActionDetailPage.jsx | 15 +-
.../initiative/InitiativeExecutionPage.jsx | 4 +
.../pages/initiative/InitiativeInboxPage.jsx | 4 +
.../initiative/InitiativeJourneyPage.jsx | 48 ++-
.../initiative/RoadmapItemDetailPage.jsx | 28 ++
frontend/src/styles/components.css | 43 +++
29 files changed, 1072 insertions(+), 40 deletions(-)
create mode 100644 backend/routers/journey.py
create mode 100644 backend/services/plan_ist.py
create mode 100644 backend/tests/test_ap16_plan_ist.py
create mode 100644 frontend/src/api/journey.js
create mode 100644 frontend/src/components/GateContributionsSection.jsx
create mode 100644 frontend/src/components/GateSelect.jsx
create mode 100644 frontend/src/components/JourneyTimeline.jsx
diff --git a/backend/main.py b/backend/main.py
index 52f24e5..388e047 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -66,6 +66,7 @@ from routers import ( # noqa: E402
evidence,
features,
initiatives,
+ journey,
me,
milestones,
projects,
@@ -84,6 +85,8 @@ app.include_router(features.router)
app.include_router(prompts.router)
app.include_router(config.router)
app.include_router(initiatives.router)
+app.include_router(journey.initiative_router)
+app.include_router(journey.roadmap_router)
app.include_router(actions.router)
app.include_router(projects.initiative_router)
app.include_router(projects.items_router)
diff --git a/backend/routers/actions.py b/backend/routers/actions.py
index 71c1793..d908fe3 100644
--- a/backend/routers/actions.py
+++ b/backend/routers/actions.py
@@ -27,6 +27,8 @@ class ActionUpdateRequest(BaseModel):
clear_due_at: bool = False
project_id: Optional[str] = None
clear_project: bool = False
+ roadmap_item_id: Optional[str] = None
+ clear_roadmap_item: bool = False
class TaskCreateRequest(BaseModel):
@@ -129,6 +131,8 @@ def update_action(
clear_due_at=body.clear_due_at,
project_id=body.project_id,
clear_project=body.clear_project,
+ roadmap_item_id=body.roadmap_item_id,
+ clear_roadmap_item=body.clear_roadmap_item,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
diff --git a/backend/routers/backlog.py b/backend/routers/backlog.py
index da9da27..87ecb7f 100644
--- a/backend/routers/backlog.py
+++ b/backend/routers/backlog.py
@@ -18,6 +18,7 @@ class BacklogCreateRequest(BaseModel):
description: str = ""
status: Literal["new", "triaged", "accepted", "rejected"] = "new"
priority: Literal["low", "normal", "high"] = "normal"
+ roadmap_item_id: Optional[str] = None
class BacklogUpdateRequest(BaseModel):
@@ -25,6 +26,8 @@ class BacklogUpdateRequest(BaseModel):
description: Optional[str] = None
status: Optional[Literal["new", "triaged", "accepted", "rejected"]] = None
priority: Optional[Literal["low", "normal", "high"]] = None
+ roadmap_item_id: Optional[str] = None
+ clear_roadmap_item: bool = False
class BacklogConvertRequest(BaseModel):
@@ -59,6 +62,8 @@ def update_backlog_item(
description=body.description,
status=body.status,
priority=body.priority,
+ roadmap_item_id=body.roadmap_item_id,
+ clear_roadmap_item=body.clear_roadmap_item,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
diff --git a/backend/routers/initiatives.py b/backend/routers/initiatives.py
index 535cd96..2fc2db3 100644
--- a/backend/routers/initiatives.py
+++ b/backend/routers/initiatives.py
@@ -49,6 +49,7 @@ class ActionCreateRequest(BaseModel):
priority: Literal["low", "normal", "high"] = "normal"
due_at: Optional[str] = None
project_id: Optional[str] = None
+ roadmap_item_id: Optional[str] = None
assigned_actor_ids: list[str] = Field(default_factory=list)
@@ -65,6 +66,7 @@ class BacklogCreateRequest(BaseModel):
description: str = ""
status: Literal["new", "triaged", "accepted", "rejected"] = "new"
priority: Literal["low", "normal", "high"] = "normal"
+ roadmap_item_id: Optional[str] = None
class MilestoneCreateRequest(BaseModel):
@@ -251,6 +253,7 @@ def create_initiative_action(
priority=body.priority,
due_at=due_at,
project_id=body.project_id,
+ roadmap_item_id=body.roadmap_item_id,
assigned_actor_ids=body.assigned_actor_ids,
user_id=ctx.user_id,
)
@@ -330,6 +333,7 @@ def create_initiative_backlog_item(
description=body.description,
status=body.status,
priority=body.priority,
+ roadmap_item_id=body.roadmap_item_id,
user_id=ctx.user_id,
)
except ValueError as exc:
diff --git a/backend/routers/journey.py b/backend/routers/journey.py
new file mode 100644
index 0000000..41870da
--- /dev/null
+++ b/backend/routers/journey.py
@@ -0,0 +1,44 @@
+"""Plan/Ist & Journey API — AP1.6."""
+
+from __future__ import annotations
+
+from capabilities import require_capability
+from fastapi import APIRouter, Depends, HTTPException
+from services import plan_ist as plan_ist_service
+from tenant_context import TenantContext
+
+initiative_router = APIRouter(prefix="/api/initiatives", tags=["journey"])
+roadmap_router = APIRouter(prefix="/api/roadmap-items", tags=["journey"])
+
+
+@initiative_router.get("/{initiative_id}/journey")
+def list_initiative_journey(
+ initiative_id: str,
+ ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
+):
+ try:
+ events = plan_ist_service.list_journey_events(
+ tenant_id=ctx.tenant_id, initiative_id=initiative_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
+ return {"events": events}
+
+
+@roadmap_router.get("/{roadmap_item_id}/contributions")
+def list_gate_contributions(
+ roadmap_item_id: str,
+ ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
+):
+ try:
+ return plan_ist_service.list_gate_contributions(
+ tenant_id=ctx.tenant_id, roadmap_item_id=roadmap_item_id
+ )
+ except ValueError as exc:
+ detail = str(exc)
+ if detail == "Gate nicht gefunden":
+ raise HTTPException(status_code=404, detail=detail) from exc
+ raise HTTPException(status_code=400, detail=detail) from exc
diff --git a/backend/services/actions.py b/backend/services/actions.py
index a415f77..b1195a8 100644
--- a/backend/services/actions.py
+++ b/backend/services/actions.py
@@ -9,6 +9,7 @@ from psycopg2.extras import RealDictCursor
from db import get_connection
from services.audit import log_audit
from services.initiatives import PRIORITIES, get_initiative
+from services.plan_ist import validate_roadmap_item_in_initiative
from services.projects import project_is_leaf
ActionStatus = Literal[
@@ -23,14 +24,14 @@ OPEN_ACTION_STATUSES = frozenset(
)
_ACTION_COLUMNS = """
- id, tenant_id, initiative_id, project_id, title, description,
+ id, tenant_id, initiative_id, project_id, roadmap_item_id, title, description,
status, priority, due_at, created_at, updated_at
"""
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
result = dict(row)
- for key in ("id", "tenant_id", "initiative_id", "project_id", "owner_actor_id"):
+ for key in ("id", "tenant_id", "initiative_id", "project_id", "roadmap_item_id", "owner_actor_id"):
if result.get(key):
result[key] = str(result[key])
if result.get("created_at"):
@@ -135,6 +136,7 @@ def create_action(
priority: str = "normal",
due_at: Optional[Any] = None,
project_id: Optional[str] = None,
+ roadmap_item_id: Optional[str] = None,
assigned_actor_ids: Optional[list[str]] = None,
user_id: Optional[str] = None,
) -> dict[str, Any]:
@@ -157,16 +159,32 @@ def create_action(
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ validate_roadmap_item_in_initiative(
+ cur,
+ tenant_id=tenant_id,
+ initiative_id=initiative_id,
+ roadmap_item_id=roadmap_item_id,
+ )
cur.execute(
f"""
INSERT INTO actions (
- tenant_id, initiative_id, project_id, title, description,
+ tenant_id, initiative_id, project_id, roadmap_item_id, title, description,
status, priority, due_at
)
- VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
+ VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING {_ACTION_COLUMNS}
""",
- (tenant_id, initiative_id, project_id, title, description, status, priority, due_at),
+ (
+ tenant_id,
+ initiative_id,
+ project_id,
+ roadmap_item_id,
+ title,
+ description,
+ status,
+ priority,
+ due_at,
+ ),
)
row = _serialize_row(dict(cur.fetchone()))
for actor_id in assigned_actor_ids:
@@ -254,6 +272,8 @@ def update_action(
clear_due_at: bool = False,
project_id: Optional[str] = None,
clear_project: bool = False,
+ roadmap_item_id: Optional[str] = None,
+ clear_roadmap_item: bool = False,
) -> Optional[dict[str, Any]]:
existing = get_action(tenant_id=tenant_id, action_id=action_id)
if not existing:
@@ -295,6 +315,22 @@ def update_action(
)
updates.append("project_id = %s")
params.append(project_id)
+ if clear_roadmap_item:
+ updates.append("roadmap_item_id = NULL")
+ elif roadmap_item_id is not None:
+ conn = get_connection()
+ try:
+ with conn.cursor() as cur:
+ validate_roadmap_item_in_initiative(
+ cur,
+ tenant_id=tenant_id,
+ initiative_id=existing["initiative_id"],
+ roadmap_item_id=roadmap_item_id,
+ )
+ finally:
+ conn.close()
+ updates.append("roadmap_item_id = %s")
+ params.append(roadmap_item_id)
if not updates:
return existing
diff --git a/backend/services/backlog.py b/backend/services/backlog.py
index c4e944e..7561084 100644
--- a/backend/services/backlog.py
+++ b/backend/services/backlog.py
@@ -10,15 +10,21 @@ from db import get_connection
from services.actions import create_action
from services.audit import log_audit
from services.initiatives import PRIORITIES, get_initiative
+from services.plan_ist import validate_roadmap_item_in_initiative
BacklogStatus = Literal["new", "triaged", "accepted", "rejected", "converted"]
BACKLOG_STATUSES = frozenset({"new", "triaged", "accepted", "rejected", "converted"})
+_BACKLOG_COLUMNS = """
+ id, tenant_id, initiative_id, title, description, status,
+ priority, roadmap_item_id, converted_action_id, created_at, updated_at
+"""
+
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
result = dict(row)
- for key in ("id", "tenant_id", "initiative_id", "converted_action_id"):
+ for key in ("id", "tenant_id", "initiative_id", "converted_action_id", "roadmap_item_id"):
if result.get(key):
result[key] = str(result[key])
if result.get("created_at"):
@@ -46,6 +52,7 @@ def create_backlog_item(
description: str = "",
status: BacklogStatus = "new",
priority: str = "normal",
+ roadmap_item_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> dict[str, Any]:
title = title.strip()
@@ -59,16 +66,22 @@ def create_backlog_item(
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ validate_roadmap_item_in_initiative(
+ cur,
+ tenant_id=tenant_id,
+ initiative_id=initiative_id,
+ roadmap_item_id=roadmap_item_id,
+ )
cur.execute(
- """
+ f"""
INSERT INTO backlog_items (
- tenant_id, initiative_id, title, description, status, priority
+ tenant_id, initiative_id, title, description, status, priority,
+ roadmap_item_id
)
- VALUES (%s, %s, %s, %s, %s, %s)
- RETURNING id, tenant_id, initiative_id, title, description, status,
- priority, converted_action_id, created_at, updated_at
+ VALUES (%s, %s, %s, %s, %s, %s, %s)
+ RETURNING {_BACKLOG_COLUMNS}
""",
- (tenant_id, initiative_id, title, description, status, priority),
+ (tenant_id, initiative_id, title, description, status, priority, roadmap_item_id),
)
row = _serialize_row(dict(cur.fetchone()))
conn.commit()
@@ -92,9 +105,8 @@ def list_backlog_for_initiative(*, tenant_id: str, initiative_id: str) -> list[d
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
+ f"""
+ SELECT {_BACKLOG_COLUMNS}
FROM backlog_items
WHERE tenant_id = %s AND initiative_id = %s
ORDER BY updated_at DESC, title
@@ -111,9 +123,8 @@ def get_backlog_item(*, tenant_id: str, backlog_item_id: str) -> Optional[dict[s
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
+ f"""
+ SELECT {_BACKLOG_COLUMNS}
FROM backlog_items
WHERE id = %s AND tenant_id = %s
""",
@@ -134,6 +145,8 @@ def update_backlog_item(
description: Optional[str] = None,
status: Optional[BacklogStatus] = None,
priority: Optional[str] = None,
+ roadmap_item_id: Optional[str] = None,
+ clear_roadmap_item: bool = False,
) -> Optional[dict[str, Any]]:
existing = get_backlog_item(tenant_id=tenant_id, backlog_item_id=backlog_item_id)
if not existing:
@@ -162,6 +175,22 @@ def update_backlog_item(
_validate_priority(priority)
updates.append("priority = %s")
params.append(priority)
+ if clear_roadmap_item:
+ updates.append("roadmap_item_id = NULL")
+ elif roadmap_item_id is not None:
+ conn = get_connection()
+ try:
+ with conn.cursor() as cur:
+ validate_roadmap_item_in_initiative(
+ cur,
+ tenant_id=tenant_id,
+ initiative_id=existing["initiative_id"],
+ roadmap_item_id=roadmap_item_id,
+ )
+ finally:
+ conn.close()
+ updates.append("roadmap_item_id = %s")
+ params.append(roadmap_item_id)
if not updates:
return existing
@@ -177,8 +206,7 @@ def update_backlog_item(
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
+ RETURNING {_BACKLOG_COLUMNS}
""",
params,
)
@@ -259,6 +287,7 @@ def convert_backlog_to_action(
title=existing["title"],
description=existing["description"] or "",
priority=existing["priority"],
+ roadmap_item_id=existing.get("roadmap_item_id"),
assigned_actor_ids=assigned_actor_ids or [],
user_id=user_id,
)
@@ -273,8 +302,7 @@ def convert_backlog_to_action(
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
+ RETURNING {_BACKLOG_COLUMNS}
""",
(action["id"], backlog_item_id, tenant_id),
)
diff --git a/backend/services/plan_ist.py b/backend/services/plan_ist.py
new file mode 100644
index 0000000..3c50d74
--- /dev/null
+++ b/backend/services/plan_ist.py
@@ -0,0 +1,322 @@
+"""Plan/Ist-Verknüpfung — Gates, Journey, Beiträge (AP1.6)."""
+
+from __future__ import annotations
+
+from typing import Any, Literal, Optional
+
+from psycopg2.extras import RealDictCursor
+
+from db import get_connection
+from services.initiatives import get_initiative
+
+JourneyEventKind = Literal[
+ "decision",
+ "review",
+ "evidence",
+ "action",
+ "backlog",
+ "gate",
+]
+
+
+def validate_roadmap_item_in_initiative(
+ cur,
+ *,
+ tenant_id: str,
+ initiative_id: str,
+ roadmap_item_id: Optional[str],
+) -> None:
+ if not roadmap_item_id:
+ return
+ cur.execute(
+ """
+ SELECT 1 FROM roadmap_items ri
+ JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
+ WHERE ri.id = %s AND ri.tenant_id = %s AND r.initiative_id = %s
+ """,
+ (roadmap_item_id, tenant_id, initiative_id),
+ )
+ if not cur.fetchone():
+ raise ValueError("Gate gehört nicht zu diesem Vorhaben")
+
+
+def _iso(value: Any) -> Optional[str]:
+ if not value:
+ return None
+ return value.isoformat() if hasattr(value, "isoformat") else str(value)
+
+
+def list_gate_contributions(
+ *, tenant_id: str, roadmap_item_id: str
+) -> dict[str, Any]:
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ """
+ SELECT ri.id, ri.title, ri.status, r.initiative_id
+ FROM roadmap_items ri
+ JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
+ WHERE ri.id = %s AND ri.tenant_id = %s
+ """,
+ (roadmap_item_id, tenant_id),
+ )
+ gate = cur.fetchone()
+ if not gate:
+ raise ValueError("Gate nicht gefunden")
+ initiative_id = str(gate["initiative_id"])
+
+ cur.execute(
+ """
+ SELECT id, title, status, priority, converted_action_id
+ FROM backlog_items
+ WHERE tenant_id = %s AND initiative_id = %s AND roadmap_item_id = %s
+ ORDER BY updated_at DESC
+ """,
+ (tenant_id, initiative_id, roadmap_item_id),
+ )
+ backlog = [dict(r) for r in cur.fetchall()]
+
+ cur.execute(
+ """
+ SELECT id, title, status, priority, project_id
+ FROM actions
+ WHERE tenant_id = %s AND initiative_id = %s AND roadmap_item_id = %s
+ ORDER BY updated_at DESC
+ """,
+ (tenant_id, initiative_id, roadmap_item_id),
+ )
+ actions = [dict(r) for r in cur.fetchall()]
+
+ cur.execute(
+ """
+ SELECT id, title, status, parent_project_id
+ FROM projects
+ WHERE tenant_id = %s AND initiative_id = %s AND roadmap_item_id = %s
+ ORDER BY updated_at DESC
+ """,
+ (tenant_id, initiative_id, roadmap_item_id),
+ )
+ projects = [dict(r) for r in cur.fetchall()]
+
+ cur.execute(
+ """
+ SELECT t.id, t.title, t.status, t.action_id, a.title AS action_title
+ FROM tasks t
+ JOIN actions a ON a.id = t.action_id AND a.tenant_id = t.tenant_id
+ WHERE t.tenant_id = %s AND a.initiative_id = %s AND t.roadmap_item_id = %s
+ ORDER BY t.updated_at DESC
+ """,
+ (tenant_id, initiative_id, roadmap_item_id),
+ )
+ tasks = [dict(r) for r in cur.fetchall()]
+ finally:
+ conn.close()
+
+ def _ids(rows: list[dict]) -> list[dict[str, Any]]:
+ out = []
+ for row in rows:
+ item = dict(row)
+ for key in item:
+ if item[key] is not None and key.endswith("_id"):
+ item[key] = str(item[key])
+ out.append(item)
+ return out
+
+ return {
+ "roadmap_item_id": roadmap_item_id,
+ "initiative_id": initiative_id,
+ "gate_title": gate["title"],
+ "gate_status": gate["status"],
+ "backlog_items": _ids(backlog),
+ "actions": _ids(actions),
+ "projects": _ids(projects),
+ "tasks": _ids(tasks),
+ "counts": {
+ "backlog": len(backlog),
+ "actions": len(actions),
+ "projects": len(projects),
+ "tasks": len(tasks),
+ "total": len(backlog) + len(actions) + len(projects) + len(tasks),
+ },
+ }
+
+
+def list_journey_events(*, 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")
+
+ events: list[dict[str, Any]] = []
+ conn = get_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cur:
+ cur.execute(
+ """
+ SELECT id, title, status, outcome, created_at, updated_at
+ FROM decisions
+ WHERE tenant_id = %s AND initiative_id = %s
+ """,
+ (tenant_id, initiative_id),
+ )
+ for row in cur.fetchall():
+ events.append(
+ {
+ "kind": "decision",
+ "entity_id": str(row["id"]),
+ "title": row["title"],
+ "status": row["status"],
+ "summary": row["outcome"] or row["status"],
+ "occurred_at": _iso(row["updated_at"] or row["created_at"]),
+ "roadmap_item_id": None,
+ }
+ )
+
+ cur.execute(
+ """
+ SELECT id, title, status, summary, created_at, updated_at, milestone_id, roadmap_item_id
+ FROM reviews
+ WHERE tenant_id = %s AND initiative_id = %s
+ """,
+ (tenant_id, initiative_id),
+ )
+ for row in cur.fetchall():
+ rid = row.get("roadmap_item_id") or row.get("milestone_id")
+ events.append(
+ {
+ "kind": "review",
+ "entity_id": str(row["id"]),
+ "title": row["title"],
+ "status": row["status"],
+ "summary": row["summary"] or row["status"],
+ "occurred_at": _iso(row["updated_at"] or row["created_at"]),
+ "roadmap_item_id": str(rid) if rid else None,
+ }
+ )
+
+ cur.execute(
+ """
+ SELECT id, title, status, description, created_at, updated_at,
+ roadmap_item_id, action_id
+ FROM evidence
+ WHERE tenant_id = %s AND initiative_id = %s
+ """,
+ (tenant_id, initiative_id),
+ )
+ for row in cur.fetchall():
+ events.append(
+ {
+ "kind": "evidence",
+ "entity_id": str(row["id"]),
+ "title": row["title"],
+ "status": row["status"],
+ "summary": row["description"] or row["status"],
+ "occurred_at": _iso(row["updated_at"] or row["created_at"]),
+ "roadmap_item_id": (
+ str(row["roadmap_item_id"]) if row.get("roadmap_item_id") else None
+ ),
+ "action_id": str(row["action_id"]) if row.get("action_id") else None,
+ }
+ )
+
+ cur.execute(
+ """
+ SELECT id, title, status, created_at, updated_at, roadmap_item_id, project_id
+ FROM actions
+ WHERE tenant_id = %s AND initiative_id = %s
+ """,
+ (tenant_id, initiative_id),
+ )
+ for row in cur.fetchall():
+ events.append(
+ {
+ "kind": "action",
+ "entity_id": str(row["id"]),
+ "title": row["title"],
+ "status": row["status"],
+ "summary": f"Arbeitspaket — {row['status']}",
+ "occurred_at": _iso(row["created_at"]),
+ "roadmap_item_id": (
+ str(row["roadmap_item_id"]) if row.get("roadmap_item_id") else None
+ ),
+ "project_id": str(row["project_id"]) if row.get("project_id") else None,
+ }
+ )
+ if row["status"] in ("done", "discarded") and row["updated_at"] != row["created_at"]:
+ events.append(
+ {
+ "kind": "action",
+ "entity_id": str(row["id"]),
+ "title": row["title"],
+ "status": row["status"],
+ "summary": f"Arbeitspaket abgeschlossen — {row['status']}",
+ "occurred_at": _iso(row["updated_at"]),
+ "roadmap_item_id": (
+ str(row["roadmap_item_id"])
+ if row.get("roadmap_item_id")
+ else None
+ ),
+ "project_id": (
+ str(row["project_id"]) if row.get("project_id") else None
+ ),
+ }
+ )
+
+ cur.execute(
+ """
+ SELECT id, title, status, created_at, updated_at, roadmap_item_id, converted_action_id
+ FROM backlog_items
+ WHERE tenant_id = %s AND initiative_id = %s
+ """,
+ (tenant_id, initiative_id),
+ )
+ for row in cur.fetchall():
+ if row["status"] == "converted":
+ events.append(
+ {
+ "kind": "backlog",
+ "entity_id": str(row["id"]),
+ "title": row["title"],
+ "status": row["status"],
+ "summary": "Backlog → Arbeitspaket committet",
+ "occurred_at": _iso(row["updated_at"]),
+ "roadmap_item_id": (
+ str(row["roadmap_item_id"])
+ if row.get("roadmap_item_id")
+ else None
+ ),
+ "action_id": (
+ str(row["converted_action_id"])
+ if row.get("converted_action_id")
+ else None
+ ),
+ }
+ )
+
+ cur.execute(
+ """
+ SELECT ri.id, ri.title, ri.status, ri.updated_at, ri.created_at
+ FROM roadmap_items ri
+ JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
+ WHERE ri.tenant_id = %s AND r.initiative_id = %s
+ AND ri.status IN ('reached', 'moved', 'discarded', 'at_risk')
+ """,
+ (tenant_id, initiative_id),
+ )
+ for row in cur.fetchall():
+ events.append(
+ {
+ "kind": "gate",
+ "entity_id": str(row["id"]),
+ "title": row["title"],
+ "status": row["status"],
+ "summary": f"Zielzustand — {row['status']}",
+ "occurred_at": _iso(row["updated_at"] or row["created_at"]),
+ "roadmap_item_id": str(row["id"]),
+ }
+ )
+ finally:
+ conn.close()
+
+ events = [e for e in events if e.get("occurred_at")]
+ events.sort(key=lambda e: e["occurred_at"], reverse=True)
+ return events
diff --git a/backend/tests/test_ap16_plan_ist.py b/backend/tests/test_ap16_plan_ist.py
new file mode 100644
index 0000000..50a3b80
--- /dev/null
+++ b/backend/tests/test_ap16_plan_ist.py
@@ -0,0 +1,94 @@
+"""AP1.6 — Plan/Ist-Verknüpfung und Journey."""
+
+from __future__ import annotations
+
+from tests.factories import provision_user_in_tenant
+from tests.test_initiatives_actions import (
+ _auth,
+ _create_initiative,
+ _login,
+)
+
+
+def _create_gate(client, token, initiative_id, title="Gate A"):
+ item = client.post(
+ f"/api/initiatives/{initiative_id}/roadmap/items",
+ json={"title": title, "item_type": "milestone"},
+ headers=_auth(token),
+ )
+ assert item.status_code == 201
+ return item.json()
+
+
+def test_backlog_and_action_gate_link(client):
+ user = provision_user_in_tenant(tenant_role="member")
+ token = _login(client, user)
+ initiative_id = _create_initiative(client, token).json()["id"]
+ gate = _create_gate(client, token, initiative_id)
+
+ backlog = client.post(
+ f"/api/initiatives/{initiative_id}/backlog",
+ json={"title": "Idee", "roadmap_item_id": gate["id"]},
+ headers=_auth(token),
+ )
+ assert backlog.status_code == 201
+ assert backlog.json()["roadmap_item_id"] == gate["id"]
+
+ action = client.post(
+ f"/api/initiatives/{initiative_id}/actions",
+ json={"title": "Arbeit", "roadmap_item_id": gate["id"]},
+ headers=_auth(token),
+ )
+ assert action.status_code == 201
+ assert action.json()["roadmap_item_id"] == gate["id"]
+
+
+def test_convert_backlog_keeps_gate_link(client):
+ user = provision_user_in_tenant(tenant_role="member")
+ token = _login(client, user)
+ initiative_id = _create_initiative(client, token).json()["id"]
+ gate = _create_gate(client, token, initiative_id)
+
+ backlog = client.post(
+ f"/api/initiatives/{initiative_id}/backlog",
+ json={"title": "Commit me", "status": "accepted", "roadmap_item_id": gate["id"]},
+ headers=_auth(token),
+ ).json()
+
+ converted = client.post(
+ f"/api/backlog/{backlog['id']}/convert-to-action",
+ json={},
+ headers=_auth(token),
+ )
+ assert converted.status_code == 201
+ assert converted.json()["action"]["roadmap_item_id"] == gate["id"]
+
+
+def test_gate_contributions_and_journey(client):
+ user = provision_user_in_tenant(tenant_role="member")
+ token = _login(client, user)
+ initiative_id = _create_initiative(client, token).json()["id"]
+ gate = _create_gate(client, token, initiative_id)
+
+ client.post(
+ f"/api/initiatives/{initiative_id}/actions",
+ json={"title": "Linked work", "roadmap_item_id": gate["id"]},
+ headers=_auth(token),
+ )
+
+ contributions = client.get(
+ f"/api/roadmap-items/{gate['id']}/contributions",
+ headers=_auth(token),
+ )
+ assert contributions.status_code == 200
+ body = contributions.json()
+ assert body["counts"]["actions"] == 1
+ assert body["counts"]["total"] == 1
+
+ journey = client.get(
+ f"/api/initiatives/{initiative_id}/journey",
+ headers=_auth(token),
+ )
+ assert journey.status_code == 200
+ kinds = {e["kind"] for e in journey.json()["events"]}
+ assert "action" in kinds
diff --git a/backend/version.py b/backend/version.py
index be59c33..d064670 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,3 +1,3 @@
-APP_VERSION = "0.14.1-ap1.5c"
+APP_VERSION = "0.15.0-ap1.6"
DB_SCHEMA_VERSION = "014"
APP_NAME = "jinkendo-kairo"
diff --git a/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md b/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md
index c86be0c..c052a5c 100644
--- a/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md
+++ b/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md
@@ -68,7 +68,8 @@ Phase E Steering AP1.2, AP1.3 ◐
Phase F Gate-Backend AP1.4 + AP1.4b ✓ (Backend); UI minimal
Phase G Ist-Hierarchie AP1.5 ✓
Phase G2 Rekursive Container AP1.5c–5d ✓ 5c / → 5d nach AP1.6
-Phase H Plan/Ist-Verknüpfung AP1.6 ← **NÄCHSTES CODE**
+Phase H Plan/Ist-Verknüpfung AP1.6 ✓
+Phase H2 Rekursive Tasks AP1.5d ← **NÄCHSTES CODE**
Phase I Gate-Graph AP1.4d–4e nach AP1.6
Phase J Portfolio & Kontext AP1.8 geplant
Phase K Agent Interface AP1.7 geplant (unverändert)
@@ -91,7 +92,8 @@ Phase L Validation AP0.10d (laufend PO) parallel
| AP1.2c | 0.12.0-ap1.2c | IA-Skeleton |
| AP1.4 | 0.13.0-ap1.4 | RoadmapItem / Gates Minimal |
| AP1.4b | 0.13.1-ap1.4b | Gate-Checkliste Backend + Detail-Route |
-| AP1.5 | 0.14.0-ap1.5 | Project, Task flach, Ausführung-Tab |
+| AP1.5c | 0.14.1-ap1.5c | Rekursive Projects |
+| AP1.6 | 0.15.0-ap1.6 | Plan/Ist, Journey, Gate-Beiträge |
| DOC PO | 2026-07-06 | ADP Product Layering & IA-Reset |
| DOC Container | 2026-07-06 | ADP Recursive Containers & Execution Granularity |
@@ -144,7 +146,7 @@ Phase L Validation AP0.10d (laufend PO) parallel
---
-### AP1.6 — Plan/Ist-Verknüpfung & Journey (**NÄCHSTES CODE-PAKET**)
+### AP1.6 — Plan/Ist-Verknüpfung & Journey ✓
Backlog/Action/Project/Task → RoadmapItem; Decision-Spur; Journey-Timeline.
@@ -288,8 +290,8 @@ MVP-nah wenn:
0.13.1-ap1.4b ✓ Gate-Checkliste Backend
0.14.0-ap1.5 ✓ Hierarchie (Project, Task flach)
0.14.1-ap1.5c ✓ Rekursive Projects
-0.15.0-ap1.6 → Journey / Plan-Ist — NÄCHSTES
-0.15.1-ap1.5d → Rekursive Tasks / Roll-up
+0.15.0-ap1.6 ✓ Plan/Ist / Journey
+0.15.1-ap1.5d → Rekursive Tasks — NÄCHSTES
0.15.2-ap1.4d → Gate-Graph
0.16.0-ap1.7 → Agent Interface
0.17.0-ap1.8 → Portfolio + Kontext
diff --git a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md
index 4ed88d9..a086bc2 100644
--- a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md
+++ b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md
@@ -1,7 +1,7 @@
# Kairo — Implementation Truth Table v0.1
**Status:** living document — bei jedem AP aktualisieren
-**Stand:** 2026-07-06 (nach AP1.5c)
+**Stand:** 2026-07-06 (nach AP1.6)
**Zweck:** Ehrliche Trennung von **implementiert**, **teilweise**, **nur API/Schema**, **nur Dokumentiert**
Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
@@ -40,9 +40,9 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden.
|---------|-------|-----------|
| Initiative | ✓ | |
| Project | ◐ | AP1.5 + AP1.5c: Baum (`parent_project_id`), Blatt-Regel für Actions |
-| Action | ✓ | UI: Arbeitspaket (interim Maßnahme) |
+| Action | ✓ | Gate-Zuordnung AP1.6 |
| ActionAssignment | ✓ | |
-| BacklogItem | ✓ | |
+| BacklogItem | ✓ | Gate-Zuordnung AP1.6 |
| Blocker | ✓ | `action_id` optional |
| Milestone | ◐ | Tabelle MVP-Brücke; kein Gate |
| Evidence | ✓ | |
diff --git a/frontend/src/api/journey.js b/frontend/src/api/journey.js
new file mode 100644
index 0000000..ae9c15d
--- /dev/null
+++ b/frontend/src/api/journey.js
@@ -0,0 +1,9 @@
+import { apiFetch } from './client.js'
+
+export function listInitiativeJourney(initiativeId) {
+ return apiFetch(`/api/initiatives/${initiativeId}/journey`)
+}
+
+export function listGateContributions(roadmapItemId) {
+ return apiFetch(`/api/roadmap-items/${roadmapItemId}/contributions`)
+}
diff --git a/frontend/src/components/ActionForm.jsx b/frontend/src/components/ActionForm.jsx
index df9b86a..b65a49c 100644
--- a/frontend/src/components/ActionForm.jsx
+++ b/frontend/src/components/ActionForm.jsx
@@ -1,6 +1,7 @@
import { ACTION_STATUSES, PRIORITIES } from '../constants/status.js'
import { ACTION_STATUS_LABELS, PRIORITY_LABELS } from '../constants/status.js'
import { ActorSelect } from './ActorSelect.jsx'
+import { GateSelect } from './GateSelect.jsx'
import { flattenLeafProjectOptions, getLeafProjects } from '../utils/projectTree.js'
function isoToLocalInput(iso) {
@@ -13,6 +14,7 @@ function isoToLocalInput(iso) {
export function ActionForm({
initial = {},
projects = [],
+ roadmapItems = [],
actors = [],
actorsLoading = false,
actorsError = null,
@@ -34,6 +36,7 @@ export function ActionForm({
priority: form.priority.value,
due_at: form.due_at.value ? new Date(form.due_at.value).toISOString() : null,
project_id: form.project_id?.value || undefined,
+ roadmap_item_id: form.roadmap_item_id?.value || undefined,
assigned_actor_ids: selected,
})
}
@@ -93,6 +96,10 @@ export function ActionForm({
)}
+
{action.description}
)}
+ {action.roadmap_item_id && (
+
Gate: {gateTitleById(roadmapItems, action.roadmap_item_id) || '…'}
+ )}
{action.assigned_actor_ids?.length > 0 && (
{action.assigned_actor_ids.length} zugewiesen
)}
diff --git a/frontend/src/components/BacklogSection.jsx b/frontend/src/components/BacklogSection.jsx
index 3d1cd45..7740865 100644
--- a/frontend/src/components/BacklogSection.jsx
+++ b/frontend/src/components/BacklogSection.jsx
@@ -7,23 +7,32 @@ import { StatusBadge } from './StatusBadge.jsx'
import { PriorityBadge } from './PriorityBadge.jsx'
import { EmptyState } from './EmptyState.jsx'
+import { GateSelect, gateTitleById } from './GateSelect.jsx'
+
export function BacklogSection({
items,
+ roadmapItems = [],
canManage,
onCreate,
+ onUpdateGate,
onUpdateStatus,
onConvert,
onDelete,
busy,
}) {
const [title, setTitle] = useState('')
+ const [gateId, setGateId] = useState('')
const [showForm, setShowForm] = useState(false)
async function handleSubmit(e) {
e.preventDefault()
if (!title.trim()) return
- await onCreate({ title: title.trim() })
+ await onCreate({
+ title: title.trim(),
+ roadmap_item_id: gateId || undefined,
+ })
setTitle('')
+ setGateId('')
setShowForm(false)
}
@@ -53,6 +62,19 @@ export function BacklogSection({
required
/>
+ {roadmapItems.length > 0 && (
+
+ )}
@@ -69,10 +91,30 @@ export function BacklogSection({
{item.description && (
{item.description}
)}
+ {item.roadmap_item_id && (
+
+ Gate: {gateTitleById(roadmapItems, item.roadmap_item_id) || item.roadmap_item_id}
+
+ )}
+ {canManage && item.status !== 'converted' && roadmapItems.length > 0 && (
+
+ )}
{canManage && item.status !== 'converted' && (
<>
Release
+ {roadmapItems.length > 0 && (
+
+ )}
@@ -218,6 +243,7 @@ export function ProjectsSection({
{
if (!actionId) return
@@ -37,8 +39,12 @@ export function TasksSection({ actionId, canManage }) {
if (!title.trim()) return
setBusy(true)
try {
- await createActionTask(actionId, { title: title.trim() })
+ await createActionTask(actionId, {
+ title: title.trim(),
+ roadmap_item_id: gateId || undefined,
+ })
setTitle('')
+ setGateId('')
await load()
} catch (err) {
setError(err.message)
@@ -59,6 +65,21 @@ export function TasksSection({ actionId, canManage }) {
}
}
+ async function handleGate(taskId, roadmapItemId) {
+ setBusy(true)
+ try {
+ await updateTask(taskId, {
+ roadmap_item_id: roadmapItemId || undefined,
+ clear_roadmap_item: !roadmapItemId,
+ })
+ await load()
+ } catch (err) {
+ setError(err.message)
+ } finally {
+ setBusy(false)
+ }
+ }
+
async function handleDelete(taskId) {
setBusy(true)
try {
@@ -79,7 +100,7 @@ export function TasksSection({ actionId, canManage }) {
Aufgaben
- Kleinste ausführbare Schritte — später Zuordnung zu Gates (Zielerreichung).
+ Kleinste ausführbare Schritte — optional einem Zielzustand (Gate) zugeordnet.
@@ -98,6 +119,19 @@ export function TasksSection({ actionId, canManage }) {
placeholder="Konkreter nächster Schritt"
/>
+ {roadmapItems.length > 0 && (
+
+ )}
@@ -113,8 +147,29 @@ export function TasksSection({ actionId, canManage }) {
{task.title}
+ {task.roadmap_item_id && (
+
+ Gate: {gateTitleById(roadmapItems, task.roadmap_item_id)}
+
+ )}
+ {canManage && roadmapItems.length > 0 && (
+
+ )}
{canManage ? (
>
)
@@ -81,6 +85,7 @@ function ActionDetailStandalone() {
const actorsState = useActors()
const [action, setAction] = useState(null)
const [projects, setProjects] = useState([])
+ const [roadmapItems, setRoadmapItems] = useState([])
const [context, setContext] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
@@ -93,11 +98,13 @@ function ActionDetailStandalone() {
try {
const actionData = await getAction(actionId)
setAction(actionData)
- const [snap, projectData] = await Promise.all([
+ const [snap, projectData, roadmapData] = await Promise.all([
getInitiativeSteeringSnapshot(actionData.initiative_id),
listInitiativeProjects(actionData.initiative_id).catch(() => []),
+ listInitiativeRoadmapItems(actionData.initiative_id).catch(() => []),
])
setProjects(projectData)
+ setRoadmapItems(roadmapData)
setContext((snap.actions || []).find((a) => a.id === actionId) || null)
} catch (err) {
setError(err.message)
@@ -120,6 +127,10 @@ function ActionDetailStandalone() {
priority: payload.priority,
due_at: payload.due_at,
clear_due_at: !payload.due_at,
+ project_id: payload.project_id,
+ clear_project: payload.project_id === '' || payload.project_id === null,
+ roadmap_item_id: payload.roadmap_item_id,
+ clear_roadmap_item: payload.roadmap_item_id === '' || payload.roadmap_item_id === null,
})
if (capabilities.has('kairo.action.manage')) {
await setActionAssignments(action.id, payload.assigned_actor_ids || [])
@@ -204,6 +215,7 @@ function ActionDetailStandalone() {
capabilities={capabilities}
actorsState={actorsState}
projects={projects}
+ roadmapItems={roadmapItems}
/>
@@ -245,6 +257,7 @@ function ActionDetailNested() {
reload: ops.reloadActors,
}}
projects={ops.projects}
+ roadmapItems={ops.roadmapItems}
/>
)
diff --git a/frontend/src/pages/initiative/InitiativeExecutionPage.jsx b/frontend/src/pages/initiative/InitiativeExecutionPage.jsx
index d6ab19c..a5d771b 100644
--- a/frontend/src/pages/initiative/InitiativeExecutionPage.jsx
+++ b/frontend/src/pages/initiative/InitiativeExecutionPage.jsx
@@ -8,6 +8,7 @@ export function InitiativeExecutionPage() {
const {
visibleActions,
projects,
+ roadmapItems,
selectedProjectId,
setSelectedProjectId,
actionContextById,
@@ -22,6 +23,7 @@ export function InitiativeExecutionPage() {
unlinkedBlockers,
initiativeId,
error,
+ roadmapItems,
} = ops
return (
@@ -30,6 +32,7 @@ export function InitiativeExecutionPage() {
{error}}
{
+ if (!id) return
+ setJourneyLoading(true)
+ setJourneyError(null)
+ try {
+ const data = await listInitiativeJourney(id)
+ setJourneyEvents(data.events || [])
+ } catch (err) {
+ setJourneyError(err.message)
+ } finally {
+ setJourneyLoading(false)
+ }
+ }, [id])
+
+ useEffect(() => {
+ loadJourney()
+ }, [loadJourney, ops.decisions, ops.reviews, ops.evidenceItems, ops.actions])
if (!capabilities.has('kairo.initiative.read')) {
return null
@@ -15,9 +39,25 @@ export function InitiativeJourneyPage() {
return (
<>
{error && {error}
}
-
- Entscheidungen, Reviews und Nachweise — die Reise-Timeline folgt in AP1.6.
-
+
+
+ Journey
+
+ Chronologie aus Entscheidungen, Nachweisen, committeter Arbeit und Zielzuständen — Plan/Ist
+ nachvollziehbar.
+
+ {journeyError && {journeyError}
}
+ {journeyLoading ? (
+ Timeline wird geladen…
+ ) : (
+
+ )}
+
+
{
+ setContributionsLoading(true)
+ setContributionsError(null)
+ try {
+ setContributions(await listGateContributions(itemId))
+ } catch (err) {
+ setContributionsError(err.message)
+ } finally {
+ setContributionsLoading(false)
+ }
+ }, [itemId])
const load = useCallback(async () => {
setLoading(true)
@@ -108,6 +125,10 @@ export function RoadmapItemDetailPage() {
load()
}, [load])
+ useEffect(() => {
+ loadContributions()
+ }, [loadContributions])
+
async function runAction(fn) {
setBusy(true)
setError(null)
@@ -155,6 +176,13 @@ export function RoadmapItemDetailPage() {
Checkliste: {closedCount}/{totalCount} Kriterien abgeschlossen
+
+
{criteria.map((crit) => (
-
diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css
index 4f9c976..e98b43d 100644
--- a/frontend/src/styles/components.css
+++ b/frontend/src/styles/components.css
@@ -458,6 +458,49 @@
margin-left: 0.35rem;
}
+.journey-timeline {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.journey-event {
+ padding: 0.85rem 1rem;
+}
+
+.journey-event-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem 1rem;
+ font-size: 0.85rem;
+ margin-bottom: 0.35rem;
+}
+
+.journey-event-kind {
+ font-weight: 600;
+}
+
+.journey-event-links {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.75rem;
+ margin-top: 0.5rem;
+}
+
+.gate-contributions-grid {
+ display: grid;
+ gap: 1rem;
+}
+
+@media (min-width: 640px) {
+ .gate-contributions-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
.section-header h2 {
margin: 0;
font-size: 1rem;