diff --git a/backend/migrations/014_project_hierarchy.sql b/backend/migrations/014_project_hierarchy.sql new file mode 100644 index 0000000..25530ff --- /dev/null +++ b/backend/migrations/014_project_hierarchy.sql @@ -0,0 +1,12 @@ +-- AP1.5c: Rekursive Projects (parent_project_id, container_kind) + +ALTER TABLE projects + ADD COLUMN parent_project_id UUID NULL REFERENCES projects(id) ON DELETE SET NULL, + ADD COLUMN container_kind VARCHAR(32) NULL + CHECK ( + container_kind IS NULL + OR container_kind IN ('project', 'stream', 'phase', 'release') + ); + +CREATE INDEX idx_projects_parent ON projects(tenant_id, parent_project_id) + WHERE parent_project_id IS NOT NULL; diff --git a/backend/routers/projects.py b/backend/routers/projects.py index fc78bb2..dd14d06 100644 --- a/backend/routers/projects.py +++ b/backend/routers/projects.py @@ -19,6 +19,8 @@ class ProjectCreateRequest(BaseModel): title: str = Field(min_length=1, max_length=255) description: str = "" status: Literal["active", "paused", "completed", "archived"] = "active" + parent_project_id: Optional[str] = None + container_kind: Optional[Literal["project", "stream", "phase", "release"]] = None roadmap_item_id: Optional[str] = None sort_order: int = 0 target_date: Optional[date] = None @@ -28,6 +30,10 @@ class ProjectUpdateRequest(BaseModel): title: Optional[str] = Field(default=None, min_length=1, max_length=255) description: Optional[str] = None status: Optional[Literal["active", "paused", "completed", "archived"]] = None + parent_project_id: Optional[str] = None + clear_parent_project: bool = False + container_kind: Optional[Literal["project", "stream", "phase", "release"]] = None + clear_container_kind: bool = False roadmap_item_id: Optional[str] = None clear_roadmap_item: bool = False sort_order: Optional[int] = None @@ -62,6 +68,8 @@ def create_initiative_project( title=body.title, description=body.description, status=body.status, + parent_project_id=body.parent_project_id, + container_kind=body.container_kind, roadmap_item_id=body.roadmap_item_id, sort_order=body.sort_order, target_date=body.target_date, @@ -95,6 +103,10 @@ def update_project( title=body.title, description=body.description, status=body.status, + parent_project_id=body.parent_project_id, + clear_parent_project=body.clear_parent_project, + container_kind=body.container_kind, + clear_container_kind=body.clear_container_kind, roadmap_item_id=body.roadmap_item_id, clear_roadmap_item=body.clear_roadmap_item, sort_order=body.sort_order, @@ -113,7 +125,11 @@ def delete_project( project_id: str, ctx: TenantContext = Depends(require_capability("kairo.project.manage")), ): - if not project_service.delete_project( - tenant_id=ctx.tenant_id, project_id=project_id, user_id=ctx.user_id - ): + try: + deleted = project_service.delete_project( + tenant_id=ctx.tenant_id, project_id=project_id, user_id=ctx.user_id + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if not deleted: raise HTTPException(status_code=404, detail="Projekt nicht gefunden") diff --git a/backend/services/actions.py b/backend/services/actions.py index 5c29d75..a415f77 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.projects import project_is_leaf ActionStatus = Literal[ "open", "ready", "in_progress", "blocked", "review_required", "done", "discarded" @@ -116,6 +117,10 @@ def _validate_project_in_initiative( ) if not cur.fetchone(): raise ValueError("Projekt gehört nicht zum Vorhaben") + if not project_is_leaf(tenant_id=tenant_id, project_id=project_id): + raise ValueError( + "Arbeitspakete nur an Blatt-Projekten (ohne Unterprojekte)" + ) finally: conn.close() diff --git a/backend/services/projects.py b/backend/services/projects.py index 09d5cf5..8059b59 100644 --- a/backend/services/projects.py +++ b/backend/services/projects.py @@ -1,4 +1,4 @@ -"""Project service — operative Struktur unter Initiative (AP1.5).""" +"""Project service — operative Struktur unter Initiative (AP1.5, AP1.5c).""" from __future__ import annotations @@ -14,15 +14,21 @@ from services.initiatives import get_initiative ProjectStatus = Literal["active", "paused", "completed", "archived"] PROJECT_STATUSES = frozenset({"active", "paused", "completed", "archived"}) +ContainerKind = Literal["project", "stream", "phase", "release"] +CONTAINER_KINDS = frozenset({"project", "stream", "phase", "release"}) + +MAX_PROJECT_DEPTH = 5 + _PROJECT_COLUMNS = """ - id, tenant_id, initiative_id, title, description, status, + id, tenant_id, initiative_id, parent_project_id, container_kind, + title, description, status, roadmap_item_id, sort_order, target_date, 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", "roadmap_item_id"): + for key in ("id", "tenant_id", "initiative_id", "parent_project_id", "roadmap_item_id"): if result.get(key): result[key] = str(result[key]) if result.get("target_date"): @@ -42,6 +48,11 @@ def _validate_status(status: str) -> None: raise ValueError(f"Ungültiger Project-Status: {status}") +def _validate_container_kind(container_kind: Optional[str]) -> None: + if container_kind is not None and container_kind not in CONTAINER_KINDS: + raise ValueError(f"Ungültiger container_kind: {container_kind}") + + def _validate_roadmap_item( cur, *, tenant_id: str, initiative_id: str, roadmap_item_id: Optional[str] ) -> None: @@ -59,6 +70,151 @@ def _validate_roadmap_item( raise ValueError("Gate/Plan-Element gehört nicht zu diesem Vorhaben") +def _fetch_project_row( + cur, *, tenant_id: str, project_id: str, initiative_id: Optional[str] = None +) -> Optional[dict[str, Any]]: + query = f""" + SELECT {_PROJECT_COLUMNS} + FROM projects + WHERE id = %s AND tenant_id = %s + """ + params: list[Any] = [project_id, tenant_id] + if initiative_id is not None: + query += " AND initiative_id = %s" + params.append(initiative_id) + cur.execute(query, params) + row = cur.fetchone() + return dict(row) if row else None + + +def _project_depth_from_map( + project_id: str, by_id: dict[str, dict[str, Any]], memo: dict[str, int] +) -> int: + if project_id in memo: + return memo[project_id] + project = by_id.get(project_id) + if not project or not project.get("parent_project_id"): + memo[project_id] = 1 + return 1 + parent_id = str(project["parent_project_id"]) + if parent_id == project_id: + raise ValueError("Zyklus in Project-Hierarchie") + depth = _project_depth_from_map(parent_id, by_id, memo) + 1 + memo[project_id] = depth + return depth + + +def _collect_descendant_ids( + project_id: str, by_parent: dict[str, list[str]] +) -> set[str]: + result: set[str] = set() + stack = list(by_parent.get(project_id, [])) + while stack: + child_id = stack.pop() + if child_id in result: + continue + result.add(child_id) + stack.extend(by_parent.get(child_id, [])) + return result + + +def _subtree_height(project_id: str, by_parent: dict[str, list[str]]) -> int: + children = by_parent.get(project_id, []) + if not children: + return 1 + return 1 + max(_subtree_height(child_id, by_parent) for child_id in children) + + +def _load_initiative_project_maps( + cur, *, tenant_id: str, initiative_id: str +) -> tuple[dict[str, dict[str, Any]], dict[str, list[str]]]: + cur.execute( + f""" + SELECT {_PROJECT_COLUMNS} + FROM projects + WHERE tenant_id = %s AND initiative_id = %s + """, + (tenant_id, initiative_id), + ) + rows = [dict(r) for r in cur.fetchall()] + by_id = {str(r["id"]): r for r in rows} + by_parent: dict[str, list[str]] = {} + for row in rows: + parent_id = row.get("parent_project_id") + if parent_id: + by_parent.setdefault(str(parent_id), []).append(str(row["id"])) + return by_id, by_parent + + +def _validate_parent_project( + cur, + *, + tenant_id: str, + initiative_id: str, + project_id: Optional[str], + parent_project_id: Optional[str], +) -> None: + if not parent_project_id: + return + if project_id and parent_project_id == project_id: + raise ValueError("Projekt kann nicht sein eigener Parent sein") + + parent = _fetch_project_row( + cur, tenant_id=tenant_id, project_id=parent_project_id, initiative_id=initiative_id + ) + if not parent: + raise ValueError("Parent-Projekt gehört nicht zu diesem Vorhaben") + + by_id, by_parent = _load_initiative_project_maps( + cur, tenant_id=tenant_id, initiative_id=initiative_id + ) + if project_id: + descendants = _collect_descendant_ids(project_id, by_parent) + if parent_project_id in descendants: + raise ValueError("Zyklus: Parent darf kein Nachfahr sein") + + memo: dict[str, int] = {} + parent_depth = _project_depth_from_map(parent_project_id, by_id, memo) + subtree_height = ( + _subtree_height(project_id, by_parent) if project_id else 1 + ) + if parent_depth + subtree_height > MAX_PROJECT_DEPTH: + raise ValueError( + f"Maximale Project-Tiefe ({MAX_PROJECT_DEPTH}) würde überschritten" + ) + + +def _attach_depths(projects: list[dict[str, Any]]) -> list[dict[str, Any]]: + if not projects: + return projects + by_id = {p["id"]: p for p in projects} + memo: dict[str, int] = {} + for project in projects: + project["depth"] = _project_depth_from_map(project["id"], by_id, memo) + project["has_children"] = any( + other.get("parent_project_id") == project["id"] for other in projects + ) + project["is_leaf"] = not project["has_children"] + return projects + + +def project_is_leaf(*, tenant_id: str, project_id: str) -> bool: + conn = get_connection() + try: + with conn.cursor() as cur: + cur.execute( + """ + SELECT 1 FROM projects + WHERE parent_project_id = %s AND tenant_id = %s + LIMIT 1 + """, + (project_id, tenant_id), + ) + return cur.fetchone() is None + finally: + conn.close() + + def create_project( *, tenant_id: str, @@ -66,6 +222,8 @@ def create_project( title: str, description: str = "", status: ProjectStatus = "active", + parent_project_id: Optional[str] = None, + container_kind: Optional[ContainerKind] = None, roadmap_item_id: Optional[str] = None, sort_order: int = 0, target_date: Optional[date] = None, @@ -75,6 +233,7 @@ def create_project( if not title: raise ValueError("Titel ist erforderlich") _validate_status(status) + _validate_container_kind(container_kind) if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id): raise ValueError("Initiative nicht gefunden") @@ -87,18 +246,28 @@ def create_project( initiative_id=initiative_id, roadmap_item_id=roadmap_item_id, ) + _validate_parent_project( + cur, + tenant_id=tenant_id, + initiative_id=initiative_id, + project_id=None, + parent_project_id=parent_project_id, + ) cur.execute( f""" INSERT INTO projects ( - tenant_id, initiative_id, title, description, status, + tenant_id, initiative_id, parent_project_id, container_kind, + title, description, status, roadmap_item_id, sort_order, target_date ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING {_PROJECT_COLUMNS} """, ( tenant_id, initiative_id, + parent_project_id, + container_kind, title, description, status, @@ -116,7 +285,12 @@ def create_project( "project.created", user_id=user_id, tenant_id=tenant_id, - details={"project_id": row["id"], "initiative_id": initiative_id, "title": title}, + details={ + "project_id": row["id"], + "initiative_id": initiative_id, + "title": title, + "parent_project_id": parent_project_id, + }, ) return row @@ -139,7 +313,8 @@ def list_projects_for_initiative( """, (tenant_id, initiative_id), ) - return [_serialize_row(dict(r)) for r in cur.fetchall()] + rows = [_serialize_row(dict(r)) for r in cur.fetchall()] + return _attach_depths(rows) finally: conn.close() @@ -150,16 +325,25 @@ def get_project(*, tenant_id: str, project_id: str) -> Optional[dict[str, Any]]: with conn.cursor(cursor_factory=RealDictCursor) as cur: cur.execute( f""" - SELECT {_PROJECT_COLUMNS} + SELECT initiative_id FROM projects WHERE id = %s AND tenant_id = %s """, (project_id, tenant_id), ) row = cur.fetchone() - return _serialize_row(dict(row)) if row else None + if not row: + return None + initiative_id = str(row["initiative_id"]) finally: conn.close() + for project in list_projects_for_initiative( + tenant_id=tenant_id, initiative_id=initiative_id + ): + if project["id"] == project_id: + return project + return None + def update_project( *, @@ -169,6 +353,10 @@ def update_project( title: Optional[str] = None, description: Optional[str] = None, status: Optional[ProjectStatus] = None, + parent_project_id: Optional[str] = None, + clear_parent_project: bool = False, + container_kind: Optional[ContainerKind] = None, + clear_container_kind: bool = False, roadmap_item_id: Optional[str] = None, clear_roadmap_item: bool = False, sort_order: Optional[int] = None, @@ -195,6 +383,29 @@ def update_project( _validate_status(status) updates.append("status = %s") params.append(status) + if clear_parent_project: + updates.append("parent_project_id = NULL") + elif parent_project_id is not None: + conn = get_connection() + try: + with conn.cursor() as cur: + _validate_parent_project( + cur, + tenant_id=tenant_id, + initiative_id=existing["initiative_id"], + project_id=project_id, + parent_project_id=parent_project_id, + ) + finally: + conn.close() + updates.append("parent_project_id = %s") + params.append(parent_project_id) + if clear_container_kind: + updates.append("container_kind = NULL") + elif container_kind is not None: + _validate_container_kind(container_kind) + updates.append("container_kind = %s") + params.append(container_kind) if clear_roadmap_item: updates.append("roadmap_item_id = NULL") elif roadmap_item_id is not None: @@ -251,7 +462,7 @@ def update_project( tenant_id=tenant_id, details={"project_id": project_id}, ) - return result + return get_project(tenant_id=tenant_id, project_id=project_id) def delete_project( @@ -260,6 +471,18 @@ def delete_project( conn = get_connection() try: with conn.cursor() as cur: + cur.execute( + """ + SELECT 1 FROM projects + WHERE parent_project_id = %s AND tenant_id = %s + LIMIT 1 + """, + (project_id, tenant_id), + ) + if cur.fetchone(): + raise ValueError( + "Projekt hat Unterprojekte — zuerst löschen oder verschieben" + ) cur.execute( "UPDATE actions SET project_id = NULL WHERE project_id = %s AND tenant_id = %s", (project_id, tenant_id), diff --git a/backend/tests/test_ap15c_project_hierarchy.py b/backend/tests/test_ap15c_project_hierarchy.py new file mode 100644 index 0000000..93c873f --- /dev/null +++ b/backend/tests/test_ap15c_project_hierarchy.py @@ -0,0 +1,111 @@ +"""AP1.5c — Rekursive Projects (parent_project_id).""" + +from __future__ import annotations + +from tests.factories import provision_user_in_tenant +from tests.test_initiatives_actions import ( + _auth, + _create_initiative, + _login, +) + + +def test_nested_projects_and_depth(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + + root = client.post( + f"/api/initiatives/{initiative_id}/projects", + json={"title": "Programm", "container_kind": "project"}, + headers=_auth(token), + ) + assert root.status_code == 201 + root_id = root.json()["id"] + + stream = client.post( + f"/api/initiatives/{initiative_id}/projects", + json={ + "title": "Backend Stream", + "parent_project_id": root_id, + "container_kind": "stream", + }, + headers=_auth(token), + ) + assert stream.status_code == 201 + stream_id = stream.json()["id"] + + leaf = client.post( + f"/api/initiatives/{initiative_id}/projects", + json={"title": "API", "parent_project_id": stream_id}, + headers=_auth(token), + ) + assert leaf.status_code == 201 + leaf_id = leaf.json()["id"] + + listed = client.get( + f"/api/initiatives/{initiative_id}/projects", + headers=_auth(token), + ) + assert listed.status_code == 200 + items = {p["id"]: p for p in listed.json()} + assert items[stream_id]["parent_project_id"] == root_id + assert items[leaf_id]["depth"] == 3 + assert items[leaf_id]["is_leaf"] is True + assert items[root_id]["has_children"] is True + + +def test_action_only_on_leaf_project(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + + parent = client.post( + f"/api/initiatives/{initiative_id}/projects", + json={"title": "Container"}, + headers=_auth(token), + ).json() + child = client.post( + f"/api/initiatives/{initiative_id}/projects", + json={"title": "Blatt", "parent_project_id": parent["id"]}, + headers=_auth(token), + ).json() + + blocked = client.post( + f"/api/initiatives/{initiative_id}/actions", + json={"title": "Am Container", "project_id": parent["id"]}, + headers=_auth(token), + ) + assert blocked.status_code == 400 + assert "Blatt" in blocked.json()["detail"] + + ok = client.post( + f"/api/initiatives/{initiative_id}/actions", + json={"title": "Am Blatt", "project_id": child["id"]}, + headers=_auth(token), + ) + assert ok.status_code == 201 + + +def test_cannot_delete_project_with_children(client): + user = provision_user_in_tenant(tenant_role="member") + token = _login(client, user) + initiative_id = _create_initiative(client, token).json()["id"] + + parent = client.post( + f"/api/initiatives/{initiative_id}/projects", + json={"title": "Parent"}, + headers=_auth(token), + ).json() + child = client.post( + f"/api/initiatives/{initiative_id}/projects", + json={"title": "Child", "parent_project_id": parent["id"]}, + headers=_auth(token), + ).json() + + resp = client.delete(f"/api/projects/{parent['id']}", headers=_auth(token)) + assert resp.status_code == 400 + + client.delete(f"/api/projects/{child['id']}", headers=_auth(token)) + ok = client.delete(f"/api/projects/{parent['id']}", headers=_auth(token)) + assert ok.status_code == 204 diff --git a/backend/version.py b/backend/version.py index 946151a..be59c33 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,3 +1,3 @@ -APP_VERSION = "0.14.0-ap1.5" -DB_SCHEMA_VERSION = "013" +APP_VERSION = "0.14.1-ap1.5c" +DB_SCHEMA_VERSION = "014" APP_NAME = "jinkendo-kairo" diff --git a/docs/architecture/ADP_PO_Product_Layering_and_IA_Reset_v0.1.md b/docs/architecture/ADP_PO_Product_Layering_and_IA_Reset_v0.1.md index 81124bb..3b0cffe 100644 --- a/docs/architecture/ADP_PO_Product_Layering_and_IA_Reset_v0.1.md +++ b/docs/architecture/ADP_PO_Product_Layering_and_IA_Reset_v0.1.md @@ -82,10 +82,12 @@ Minimaler Fix erlaubt: Tab-Beschreibung / Breadcrumb „Gates“ statt implizit ```text ✓ AP1.4b Gate-Checkliste Backend + Minimal-Detail-Route -→ AP1.5 Project, Task, Ausführung als Ist-Kern (NÄCHSTES CODE-PAKET) +→ AP1.5 Project, Task, Ausführung als Ist-Kern ✓ +→ AP1.5c Rekursive Projects (parent_project_id) → AP1.5b IA/Begriffe: Gates vs. Ausführung, optionale Gate-Sicht -→ AP1.6 Plan/Ist-Verknüpfung, Journey (Action/Backlog → Gate) -⏸ AP1.4c Gate-UI (Modal, Checkliste-UX) — nach AP1.5 +→ AP1.6 Plan/Ist-Verknüpfung, Journey (Action/Backlog/Project → Gate) +→ AP1.5d Rekursive Tasks (parent_task_id), Roll-up +⏸ AP1.4c Gate-UI (Modal, Checkliste-UX) — nach AP1.6 → AP1.4d Graph Engine (Gate-Kanten only) → AP1.4e Methodenprofile (generic_operating ohne Gate-Zwang) ``` @@ -101,9 +103,9 @@ Initiative (Vorhaben / Programm) │ ▲ │ │ zahlt ein (Evidence, Zuordnung) │ │ -├── [optional] Project ← AP1.5 +├── [optional] Project* ← AP1.5c parent_project_id │ └── Action (Arbeitspaket) ← Ist — „Plan zwischen Gates“ -│ └── Task ← AP1.5 minimal +│ └── Task* ← AP1.5d parent_task_id │ ├── Backlog → Commit → Action └── steering_context (Lifecycle, method_key) ← getrennt von Gate-Graph @@ -155,4 +157,4 @@ Initiative (Vorhaben / Programm) --- -*Referenz: `Kairo_Corrected_MVP_Roadmap_v0.2.md` (PO-Korrektur 2026-07-06), `Sprint1_AP1_5_Hierarchy_Assignment_v0.1.md`* +*Referenz: `Kairo_Corrected_MVP_Roadmap_v0.2.md` (PO-Korrektur 2026-07-06), `ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md`, `Sprint1_AP1_5_Hierarchy_Assignment_v0.1.md`* diff --git a/docs/architecture/ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md b/docs/architecture/ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md new file mode 100644 index 0000000..8fd7dd8 --- /dev/null +++ b/docs/architecture/ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md @@ -0,0 +1,198 @@ +# ADP — Rekursive Container & Ausführungs-Granularität v0.1 + +**Status:** PO-Freigabe (2026-07-06) +**Stand:** 2026-07-06 +**Auslöser:** PO-Review — Mega-Programme vs. normale Vorhaben; Granularität für Mensch vs. Agent +**Bezug:** `ADP_PO_Product_Layering_and_IA_Reset_v0.1.md`, Canonical OM v0.2, Target State §10–11 + +--- + +## Problem + +Zwei verwandte Fragen ohne feste Extra-Typen (`Stream`, `Subtask`, …): + +1. **Organisation:** Sehr große Programme brauchen optional **Project in Project**; normale Vorhaben nicht. +2. **Ausführung:** Arbeitspakete und Aufgaben brauchen **methoden- und akteursabhängige Tiefe** — Menschen ~2–3 Untergliederungen, Agenten feiner. + +Fest codierte Ebenen (`Stream`, `WorkPackage`, `Subtask` als eigene Typen) wären für kleine Vorhaben überdimensioniert und duplizieren semantisch **Project** bzw. **Task**. + +--- + +## Betroffene Regeln + +| Dokument | Regel | +|----------|--------| +| Canonical OM v0.2 §4 | Project optional; Action = Arbeitspaket; Task methodenabhängig | +| Vision v0.2 §4 | Action ≠ atomare Aufgabe; Task-Hierarchie / WBS | +| ADP Product Layering | Ist = Project → Action → Task; Gates orthogonal | +| Target State §10–11 | Steuerbare Objekte inkl. `project`; `SteeringContext` mit `scope_type` | +| Tenant Invariants | Kein paralleles Steering; Actor-first Assignments | + +--- + +## Optionen (Struktur) + +| Option | Kurz | Pro | Contra | +|--------|------|-----|--------| +| A | Feste Typen `Stream`, `Subtask` | Klare UI-Labels | Schema-Ballast; Doppelung zu Project/Task | +| B | **Rekursion gleicher Typen** (`parent_*_id`) | Ein Modell für klein + groß | Baum-UI; Roll-up-Regeln nötig | +| C | Unbegrenzte Rekursion + Steuerung pro Ebene | Maximale Flexibilität | Steuerungs-Explosion; MVP-Verzögerung | + +--- + +## Entscheidung (PO-Freigabe 2026-07-06) + +### 1. Organisations-Ebene: rekursives **Project** + +- **`parent_project_id`** nullable auf `projects` (Adjacency List). +- Gleiche Initiative; **Zyklen verboten**; **max. Tiefe 5** (hard block). +- Optional **`container_kind`** (Registry/Label): `project`, `stream`, `phase`, `release` — **nur Anzeige/Methoden-Hint**, keine Sonderlogik pro Kind. +- **Kein** eigener Typ `Stream`. + +**Semantik nach Tiefe (UI, nicht Schema):** + +| Tiefe | Typisches Label | Beispiel | +|-------|-----------------|----------| +| 0 | Projekt | „Kairo MVP“ | +| 1 | Stream / Phase | „Backend“, „Kumite“ | +| 2+ | Teilprojekt | „API-Schicht“ | + +**Guardrails:** + +- Actions bevorzugt an **Blatt-Projects** oder direkt an Initiative (Warnung bei Container mit Kindern). +- Gates (`roadmap_item_id`) an jeder Project-Tiefe oder Initiative — Plan/Ist in AP1.6. + +**Implementierung:** **AP1.5c** — Migration `014` o.ä. + +--- + +### 2. Ausführungs-Ebene: rekursives **Task** (Haupthebel Granularität) + +- **`parent_task_id`** nullable auf `tasks` — gleiche Tabelle, Zeiger auf weitere Instanz. +- **Max. Tiefe Schema:** 10 (Agent/API). +- **Max. Tiefe UI (Menschen):** 3 Ebenen (Task → Subtask → Sub-Subtask) — **Policy**, kein Schema-Limit. +- Roll-up: Action `done` wenn Task-Baum erfüllt (Regeln in AP1.5d); Task `done` wenn alle Kinder `done` oder explizit ohne Kinder. + +**Guardrails:** + +- **Steuerung bleibt auf Action** (Assignment, Blocker, Evidence, Next Action für Menschen). +- Kein `steering_context` pro Task-Ebene. +- Agenten: Blatt-Tasks als Ausführungsschritte; tiefe Bäume in UI **aggregiert** anzeigen. + +**Implementierung:** **AP1.5d** — **nach AP1.6** (Plan/Ist-Verknüpfung + Gate-Beitrag zuerst). + +--- + +### 3. Action-Verschachtelung: **eingeschränkt**, nicht Default + +- **`parent_action_id`** nur für **`wbs_driven`** (später), max. Tiefe **2**. +- Nur **Root-Action** aus Backlog-Commit; Kind-Actions = strukturelle Teilpakete desselben Commits. +- Default-Vorhaben: **eine Action + Task-Baum** — kein Action-Baum. + +**Implementierung:** dokumentiert; Code **nicht** in AP1.5c/d — erst mit Methodenprofil / Structure Builder. + +--- + +### 4. Steuerung: Vererbung, nicht Explosion + +**Stufe 1 (MVP bis AP1.6+):** + +```text +Initiative = einziger Steering-Root (steering_context, heute implementiert) +Project* = Struktur; erbt Initiative-Steuerung +Action = operative Steuerungs-Anker für Menschen (Assignment, Blocker, Evidence) +Task* = Ausführungsschritte; Roll-up, kein eigener Context +``` + +**Stufe 2 (später, Mega-Programme):** + +- Polymorpher `SteeringContext` (`scope_type`, `scope_id`) wie Target State. +- Nur Projects mit `steering_root = true` oder explizitem Override — **nicht** jede Ebene. + +--- + +## Schichtenmodell (Ziel) + +```text +Initiative ── steering_context (Pflicht) +│ +├── [optional] Project* parent_project_id +│ └── Project* … +│ └── Action Commit-Grenze (Root aus Backlog) +│ └── Task* parent_task_id +│ └── Task* … +│ +├── [optional] Gates (RoadmapItem) orthogonal +└── Backlog → Commit → Action (Root) +``` + +**Zwei orthogonale Dimensionen:** + +| Dimension | Rekursion | Mensch-Tiefe | Agent-Tiefe | +|-----------|-----------|--------------|-------------| +| Project (Organisation) | ja | 0–2 typisch | — | +| Action (Arbeitspaket) | selten (WBS) | 1 | 1–2 | +| Task (Ausführung) | ja | 2–3 | bis 10 | + +--- + +## Repriorisierung (verbindlich) + +```text +✓ AP1.5 Project, Task flach, Ausführung (0.14.0-ap1.5) +→ AP1.5c parent_project_id, Baum-UI, container_kind optional +→ AP1.6 Plan/Ist-Verknüpfung, Journey +→ AP1.5d parent_task_id, Roll-up, UI-Tiefen-Policy +⏸ AP1.4c Gate-UI — weiter eingefroren +→ AP1.4d Gate-Graph +→ AP1.7 Operational Actor Interface (unverändert) +``` + +AP1.5b (IA-Begriffe) kann parallel zu AP1.5c. + +--- + +## Konsequenzen + +| Bereich | Konsequenz | +|---------|------------| +| Schema | Zwei Migrationen: Project-Baum, Task-Baum — getrennte APs | +| UI | Baum/Outline statt nur flache Listen; Default collapsed ab Tiefe 2 | +| API | Cycle-Detection; depth validation | +| Tests | Klein (flach), Mega (Project-Tiefe 3), Agent (Task-Tiefe >3) | +| Steering | Keine Änderung in AP1.5c/d außer Roll-up-Fortschritt | +| Agent AP1.7 | Nutzt Task-Blätter; tiefe Zerlegung über AP1.5d | + +--- + +## Risiko + +| Risiko | Mitigation | +|--------|------------| +| UI-Overload durch Tiefe | UI soft cap 3 Tasks; Mega-Projects collapsed | +| Redundante Pfade (Project + Action + Task alle tief) | Guardrails + PO-Dokumentation | +| Commit-Modell bei nested Actions | Nested Actions erst WBS-Profil; Default flach | +| Steuerungs-Explosion | Stufe 1: nur Initiative-Context | + +--- + +## Rückbaubarkeit + +- `parent_*_id` nullable — flache Nutzung bleibt Default. +- `container_kind` optional — entfernbar ohne Schema-Bruch. +- Polymorpher SteeringContext ist **Add-on**, kein Breaking Change für AP1.5c/d. + +--- + +## Abnahme PO + +- [x] Rekursives Project statt Stream-Typ +- [x] Rekursives Task für Granularität (Mensch 2–3, Agent tiefer) +- [x] Steering-Root = Initiative (Stufe 1) +- [x] AP1.5c → AP1.6 → AP1.5d Reihenfolge +- [x] Implementierung AP1.5c +- [ ] Implementierung AP1.5d + +--- + +*Referenz: `Kairo_Corrected_MVP_Roadmap_v0.2.md` (Update 2026-07-06), `ADP_PO_Product_Layering_and_IA_Reset_v0.1.md`* diff --git a/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md b/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md index 7ee322f..c86be0c 100644 --- a/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md +++ b/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md @@ -6,7 +6,8 @@ **Ersetzt:** `Kairo_Corrected_MVP_Roadmap_v0.1.md` **Vision:** `Kairo_Vision_and_Product_Direction_v0.2.md` **Ist-Stand:** `Kairo_Implementation_Truth_Table_v0.1.md` -**PO-ADP:** `ADP_PO_Product_Layering_and_IA_Reset_v0.1.md` +**PO-ADP:** `ADP_PO_Product_Layering_and_IA_Reset_v0.1.md` +**Container-ADP:** `ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md` --- @@ -28,8 +29,8 @@ | Entscheidung | | |--------------|--| | AP1.4b Backend | ✓ behalten (Kriterien, Verify, Reopen) | -| Gate-UI-Politur (Modal, Graph-Workflow) | ⏸ **eingefroren** bis AP1.5 | -| Nächstes Code-Paket | **AP1.5** Hierarchie + Ausführung als Ist-Kern | +| Gate-UI-Politur (Modal, Graph-Workflow) | ⏸ **eingefroren** bis AP1.6 | +| Nächstes Code-Paket | **AP1.5c** rekursive Projects | | Tab „Plan“ | fachlich **Gates / Zielzustände** — Umbenennung AP1.5b | | Graph Engine AP1.4d | Gate-Kanten only — **kein** Lifecycle-Workflow | @@ -37,6 +38,24 @@ Siehe `ADP_PO_Product_Layering_and_IA_Reset_v0.1.md`. --- +## 1.2 PO-Entscheidung 2026-07-06 — Rekursive Container & Task-Tiefe + +**Auslöser:** Mega-Programme vs. normale Vorhaben; Granularität Mensch vs. Agent. + +| Entscheidung | | +|--------------|--| +| Stream als eigener Typ | **Nein** — `Project` mit `parent_project_id` + optionalem `container_kind` | +| Project in Project | **Ja** — AP1.5c | +| Task → Subtask → … | **Ja** — `parent_task_id`; UI max. ~3 Ebenen; Schema tiefer für Agenten — AP1.5d | +| Nested Actions | **Optional** später (`wbs_driven` only); nicht Default | +| Steuerung pro Ebene | **Nein (Stufe 1)** — Initiative-Root; Action = Anker für Menschen | + +Siehe `ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md`. + +**Reihenfolge:** AP1.5c → AP1.6 → AP1.5d + +--- + ## 2. Phasenübersicht ```text @@ -47,11 +66,12 @@ Phase C Dokumentation Welle 1–2 ✓ Phase D IA-Skeleton AP1.2 + AP1.2c ✓ Phase E Steering AP1.2, AP1.3 ◐ Phase F Gate-Backend AP1.4 + AP1.4b ✓ (Backend); UI minimal -Phase G **Ist-Hierarchie AP1.5** ← **NÄCHSTES CODE** -Phase H Plan/Ist-Verknüpfung AP1.6 geplant -Phase I Gate-Graph AP1.4d–4e nach AP1.5 +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 I Gate-Graph AP1.4d–4e nach AP1.6 Phase J Portfolio & Kontext AP1.8 geplant -Phase K Agent Interface AP1.7 geplant +Phase K Agent Interface AP1.7 geplant (unverändert) Phase L Validation AP0.10d (laufend PO) parallel ``` @@ -71,13 +91,15 @@ 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 | | DOC PO | 2026-07-06 | ADP Product Layering & IA-Reset | +| DOC Container | 2026-07-06 | ADP Recursive Containers & Execution Granularity | --- ## 4. Nächste Pakete (verbindliche Reihenfolge — PO 2026-07-06) -### AP1.5 — Hierarchie & Ist-Ebene (**NÄCHSTES CODE-PAKET**) +### AP1.5 — Hierarchie & Ist-Ebene ✓ **Ziel:** Operativer Plan zwischen Gates — Project, Task, Ausführung-Tab; Gates optional nutzbar. @@ -94,6 +116,22 @@ Phase L Validation AP0.10d (laufend PO) parallel --- +### AP1.5c — Rekursive Projects ✓ + +**Ziel:** Project-Baum für Mega-Programme; normale Vorhaben unverändert flach. + +| Scope | | +|-------|--| +| `parent_project_id`, Zyklen-Check, max. Tiefe 5 | +| Optional `container_kind` (Label) | +| Baum-UI in Ausführung; Actions nur an Blatt-Projects | +| **Kein** eigener Steering-Context pro Project | + +**ADP:** `ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md` +**Version:** `0.14.1-ap1.5c` + +--- + ### AP1.5b — IA-Begriffe Gates vs. Ausführung | Scope | | @@ -106,17 +144,35 @@ Phase L Validation AP0.10d (laufend PO) parallel --- -### AP1.6 — Plan/Ist-Verknüpfung & Journey +### AP1.6 — Plan/Ist-Verknüpfung & Journey (**NÄCHSTES CODE-PAKET**) -Backlog/Action → RoadmapItem; Decision-Spur; Journey-Timeline. +Backlog/Action/Project/Task → RoadmapItem; Decision-Spur; Journey-Timeline. -**Nach AP1.5** — Gates bekommen Bedeutung durch Ist-Bezug. +**Nach AP1.5c** — Gates bekommen Bedeutung durch Ist-Bezug (auch auf Project-Ebenen). **Version:** `0.15.0-ap1.6` --- -### AP1.4c — Gate-UI-Politur ⏸ (eingefroren bis AP1.5) +### AP1.5d — Rekursive Tasks & Roll-up + +**Ziel:** Task-Baum (`parent_task_id`); Mensch UI max. ~3 Ebenen; Agent tiefer. + +| Scope | | +|-------|--| +| Migration `parent_task_id`, Cycle/Depth-Validation | +| Roll-up Status Action ↔ Task-Baum | +| Gate-Beitrag auf Blatt-Tasks (Fortsetzung AP1.6) | +| UI: Outline, collapsed default ab Tiefe 2 | +| **Kein** nested Action (Default) | + +**ADP:** `ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md` +**Nach AP1.6** +**Version:** `0.15.1-ap1.5d` + +--- + +### AP1.4c — Gate-UI-Politur ⏸ (eingefroren bis AP1.6) Modal Gate anlegen/bearbeiten; Checkliste-UX; Fortschritt in Liste. @@ -134,7 +190,7 @@ Modal Gate anlegen/bearbeiten; Checkliste-UX; Fortschritt in Liste. | Deprecate `sequencing_mode` am Item | | Read Models: blocked, ready, fulfillment_ratio | -**Version:** `0.14.1-ap1.4d` (nach AP1.5) +**Version:** `0.15.2-ap1.4d` (nach AP1.6) --- @@ -204,7 +260,7 @@ Criteria, Waive/Defer, Reopen, Detail-Route. Version `0.13.1-ap1.4b` | Thema | Bis wann | |-------|----------| -| Gate-UI-Politur (Modal, Graph-Workflow) | **Nach AP1.5** (PO 2026-07-06) | +| Gate-UI-Politur (Modal, Graph-Workflow) | **Nach AP1.6** (PO 2026-07-06) | | Prompt/KI/MCP produktiv | Ist-Hierarchie + Plan/Ist | | Method Designer UI | AP1.3+ | | Gantt / Kalender | nach Plan/Ist | @@ -230,12 +286,14 @@ MVP-nah wenn: 0.12.0-ap1.2c ✓ IA-Skeleton 0.13.0-ap1.4 ✓ RoadmapItem / Gates Backend 0.13.1-ap1.4b ✓ Gate-Checkliste Backend -0.14.0-ap1.5 → Hierarchie (Project, Task) — NÄCHSTES -0.15.0-ap1.6 → Journey / Plan-Ist -0.14.1-ap1.4d → Gate-Graph (nach AP1.5) +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.2-ap1.4d → Gate-Graph 0.16.0-ap1.7 → Agent Interface 0.17.0-ap1.8 → Portfolio + Kontext ``` --- -*Siehe auch: `ADP_PO_Product_Layering_and_IA_Reset_v0.1.md`, `Sprint1_AP1_5_Hierarchy_Assignment_v0.1.md`, `Kairo_MVP_Usability_Recovery_Plan_v0.2.md`* +*Siehe auch: `ADP_Recursive_Containers_and_Execution_Granularity_v0.1.md`, `ADP_PO_Product_Layering_and_IA_Reset_v0.1.md`, `Sprint1_AP1_5_Hierarchy_Assignment_v0.1.md`, `Kairo_MVP_Usability_Recovery_Plan_v0.2.md`* diff --git a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md index 3b118ef..4ed88d9 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.5) +**Stand:** 2026-07-06 (nach AP1.5c) **Zweck:** Ehrliche Trennung von **implementiert**, **teilweise**, **nur API/Schema**, **nur Dokumentiert** Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden. @@ -30,7 +30,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden. | Capabilities | ✓ | Keine Objekt-Sichtbarkeit | | Auth / Session | ✓ | | | Audit (Auth/Admin) | ◐ | Nicht für alle OM-Events | -| Migrationen nummeriert | ✓ | Schema 013 | +| Migrationen nummeriert | ✓ | Schema 014 | --- @@ -39,7 +39,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden. | Element | Stand | Anmerkung | |---------|-------|-----------| | Initiative | ✓ | | -| Project | ◐ | AP1.5 GUI + API | +| Project | ◐ | AP1.5 + AP1.5c: Baum (`parent_project_id`), Blatt-Regel für Actions | | Action | ✓ | UI: Arbeitspaket (interim Maßnahme) | | ActionAssignment | ✓ | | | BacklogItem | ✓ | | diff --git a/frontend/src/components/ActionForm.jsx b/frontend/src/components/ActionForm.jsx index 2752fc3..df9b86a 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 { flattenLeafProjectOptions, getLeafProjects } from '../utils/projectTree.js' function isoToLocalInput(iso) { if (!iso) return '' @@ -38,6 +39,8 @@ export function ActionForm({ } const defaultActors = initial.assigned_actor_ids || [] + const leafProjects = getLeafProjects(projects) + const projectOptions = flattenLeafProjectOptions(projects) return (
@@ -77,14 +80,14 @@ export function ActionForm({ defaultValue={isoToLocalInput(initial.due_at)} /> - {projects.length > 0 && ( + {leafProjects.length > 0 && (