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({
)}
+
{item.description}
)} + {item.roadmap_item_id && ( ++ Gate: {gateTitleById(roadmapItems, item.roadmap_item_id) || item.roadmap_item_id} +
+ )}- Kleinste ausführbare Schritte — später Zuordnung zu Gates (Zielerreichung). + Kleinste ausführbare Schritte — optional einem Zielzustand (Gate) zugeordnet.
+ Gate: {gateTitleById(roadmapItems, task.roadmap_item_id)} +
+ )}{error}
} -- Entscheidungen, Reviews und Nachweise — die Reise-Timeline folgt in AP1.6. -
+ ++ Chronologie aus Entscheidungen, Nachweisen, committeter Arbeit und Zielzuständen — Plan/Ist + nachvollziehbar. +
+ {journeyError &&{journeyError}
} + {journeyLoading ? ( +Timeline wird geladen…
+ ) : ( +