AP2.2b/c: A1-Stufenwechsel, Team-Fix, Agent-Anlage, Steuerungs-Hinweise.
AP2.0e rotiert Recurring bei maturity_stage reached. Maturity-Strategie priorisiert Routinen. Team zeigt Namen und Agent-Formular. Archetyp-Hints in Kontrolle und Stufe-A-Gruppierung bei Anlage. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
2b0d5dd3d9
commit
0b57e2ec75
|
|
@ -20,6 +20,11 @@ class ServiceTokenCreateRequest(BaseModel):
|
||||||
expires_in_days: Optional[int] = Field(default=365, ge=1, le=730)
|
expires_in_days: Optional[int] = Field(default=365, ge=1, le=730)
|
||||||
|
|
||||||
|
|
||||||
|
class ActorCreateRequest(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
actor_type: Literal["human", "agent", "working_group", "external_system"] = "agent"
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
def list_actors(
|
def list_actors(
|
||||||
actor_type: Optional[Literal["human", "agent", "working_group", "external_system"]] = None,
|
actor_type: Optional[Literal["human", "agent", "working_group", "external_system"]] = None,
|
||||||
|
|
@ -38,6 +43,26 @@ def list_actors(
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", status_code=201)
|
||||||
|
def create_actor(
|
||||||
|
body: ActorCreateRequest,
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.actor.manage")),
|
||||||
|
):
|
||||||
|
if body.actor_type == "human":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Human-Actors werden über User-Provisioning angelegt",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return actor_service.create_actor(
|
||||||
|
tenant_id=ctx.tenant_id,
|
||||||
|
actor_type=body.actor_type,
|
||||||
|
name=body.name.strip(),
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{actor_id}")
|
@router.get("/{actor_id}")
|
||||||
def get_actor(
|
def get_actor(
|
||||||
actor_id: str,
|
actor_id: str,
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from entity_archetypes import resolve_default_method_key
|
from entity_archetypes import resolve_default_method_key
|
||||||
|
|
@ -222,6 +223,7 @@ def _apply_a1(
|
||||||
title="Tägliche Übung",
|
title="Tägliche Übung",
|
||||||
description="Regelmäßige Übung für die aktuelle Reifegrad-Stufe",
|
description="Regelmäßige Übung für die aktuelle Reifegrad-Stufe",
|
||||||
interval_days=1,
|
interval_days=1,
|
||||||
|
next_due_at=datetime.now(timezone.utc),
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
87
backend/services/maturity_stage_transition.py
Normal file
87
backend/services/maturity_stage_transition.py
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
"""AP2.0e — Recurring transition when a maturity_stage is reached."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from services.recurring import (
|
||||||
|
create_recurring_element,
|
||||||
|
list_recurring_for_initiative,
|
||||||
|
update_recurring_element,
|
||||||
|
)
|
||||||
|
from services.roadmap import get_roadmap_item, list_roadmap_items_for_initiative, update_roadmap_item
|
||||||
|
|
||||||
|
|
||||||
|
def on_maturity_stage_reached(
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
initiative_id: str,
|
||||||
|
reached_item_id: str,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
) -> Optional[dict[str, Any]]:
|
||||||
|
"""Pause old routines, activate next stage, seed new routine for next stage."""
|
||||||
|
item = get_roadmap_item(tenant_id=tenant_id, item_id=reached_item_id)
|
||||||
|
if not item or item.get("item_type") != "maturity_stage":
|
||||||
|
return None
|
||||||
|
|
||||||
|
stages = [
|
||||||
|
s
|
||||||
|
for s in list_roadmap_items_for_initiative(
|
||||||
|
tenant_id=tenant_id, initiative_id=initiative_id
|
||||||
|
)
|
||||||
|
if s.get("item_type") == "maturity_stage"
|
||||||
|
]
|
||||||
|
stages.sort(key=lambda s: (s.get("sort_order", 0), s.get("title", "")))
|
||||||
|
|
||||||
|
reached_index = next(
|
||||||
|
(index for index, stage in enumerate(stages) if stage["id"] == reached_item_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if reached_index is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
next_stage = None
|
||||||
|
for stage in stages[reached_index + 1 :]:
|
||||||
|
if stage.get("status") in {"planned", "active", "at_risk"}:
|
||||||
|
next_stage = stage
|
||||||
|
break
|
||||||
|
|
||||||
|
paused: list[str] = []
|
||||||
|
for recurring in list_recurring_for_initiative(
|
||||||
|
tenant_id=tenant_id, initiative_id=initiative_id
|
||||||
|
):
|
||||||
|
if recurring.get("status") == "active":
|
||||||
|
update_recurring_element(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
recurring_id=recurring["id"],
|
||||||
|
status="paused",
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
paused.append(recurring["title"])
|
||||||
|
|
||||||
|
created_recurring = None
|
||||||
|
if next_stage:
|
||||||
|
update_roadmap_item(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
item_id=next_stage["id"],
|
||||||
|
status="active",
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
created_recurring = create_recurring_element(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
initiative_id=initiative_id,
|
||||||
|
title=f"Übung — {next_stage['title']}",
|
||||||
|
description=f"Routine für {next_stage['title']} (automatisch nach Stufenübergang)",
|
||||||
|
status="active",
|
||||||
|
interval_days=1,
|
||||||
|
next_due_at=datetime.now(timezone.utc),
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"reached_stage_id": reached_item_id,
|
||||||
|
"next_stage_id": next_stage["id"] if next_stage else None,
|
||||||
|
"paused_recurring": paused,
|
||||||
|
"new_recurring_id": created_recurring["id"] if created_recurring else None,
|
||||||
|
}
|
||||||
|
|
@ -708,6 +708,17 @@ def verify_reached(
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
details={"roadmap_item_id": item_id, "verify_reason": verify_reason},
|
details={"roadmap_item_id": item_id, "verify_reason": verify_reason},
|
||||||
)
|
)
|
||||||
|
if row.get("item_type") == "maturity_stage":
|
||||||
|
from services.maturity_stage_transition import on_maturity_stage_reached
|
||||||
|
|
||||||
|
transition = on_maturity_stage_reached(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
initiative_id=initiative_id,
|
||||||
|
reached_item_id=item_id,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
if transition:
|
||||||
|
row["maturity_transition"] = transition
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@ from steering.strategies.next_action.execution_ready import (
|
||||||
from steering.strategies.next_action.product_milestone_driven import (
|
from steering.strategies.next_action.product_milestone_driven import (
|
||||||
ProductMilestoneDrivenStrategy,
|
ProductMilestoneDrivenStrategy,
|
||||||
)
|
)
|
||||||
|
from steering.strategies.next_action.recurring_helpers import (
|
||||||
|
active_recurring_candidates,
|
||||||
|
recurring_due_candidates,
|
||||||
|
)
|
||||||
from steering.strategies.next_action.registry import (
|
from steering.strategies.next_action.registry import (
|
||||||
get_next_action_strategy,
|
get_next_action_strategy,
|
||||||
register_next_action_strategy,
|
register_next_action_strategy,
|
||||||
|
|
@ -39,26 +43,37 @@ class MaturityProgressionStrategy:
|
||||||
if not initiative_id:
|
if not initiative_id:
|
||||||
return _default.evaluate(ctx, limit=limit)
|
return _default.evaluate(ctx, limit=limit)
|
||||||
|
|
||||||
|
recurring = recurring_due_candidates(ctx, initiative_id, limit=limit)
|
||||||
|
if not recurring:
|
||||||
|
recurring = active_recurring_candidates(ctx, initiative_id, limit=1)
|
||||||
|
|
||||||
|
remaining = limit - len(recurring)
|
||||||
gate_scope = first_active_gate_id(ctx, initiative_id)
|
gate_scope = first_active_gate_id(ctx, initiative_id)
|
||||||
|
ready: list[dict[str, Any]] = []
|
||||||
|
if remaining > 0:
|
||||||
ready = list_execution_ready_candidates(
|
ready = list_execution_ready_candidates(
|
||||||
ctx,
|
ctx,
|
||||||
initiative_id,
|
initiative_id,
|
||||||
limit=limit,
|
limit=remaining,
|
||||||
scope_roadmap_item_id=gate_scope,
|
scope_roadmap_item_id=gate_scope,
|
||||||
prefer_critical_path=True,
|
prefer_critical_path=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
remaining = limit - len(ready)
|
merged = merge_candidates(recurring, ready, limit=limit)
|
||||||
|
if len(merged) >= limit:
|
||||||
|
return merged[:limit]
|
||||||
|
|
||||||
gate_items: list[dict[str, Any]] = []
|
gate_items: list[dict[str, Any]] = []
|
||||||
if remaining > 0:
|
rest_limit = limit - len(merged)
|
||||||
|
if rest_limit > 0:
|
||||||
gate_items = _milestone.evaluate(
|
gate_items = _milestone.evaluate(
|
||||||
ctx, initiative_id=initiative_id, limit=remaining
|
ctx, initiative_id=initiative_id, limit=rest_limit
|
||||||
)
|
)
|
||||||
gate_items = [
|
gate_items = [
|
||||||
item for item in gate_items if item.get("kind") == "review_milestone"
|
item for item in gate_items if item.get("kind") == "review_milestone"
|
||||||
]
|
]
|
||||||
|
|
||||||
merged = merge_candidates(ready, gate_items, limit=limit)
|
merged = merge_candidates(merged, gate_items, limit=limit)
|
||||||
if len(merged) >= limit:
|
if len(merged) >= limit:
|
||||||
return merged[:limit]
|
return merged[:limit]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from db import get_connection
|
from steering.strategies.next_action.recurring_helpers import recurring_due_candidates
|
||||||
from psycopg2.extras import RealDictCursor
|
|
||||||
from steering.signals import default_rules
|
from steering.signals import default_rules
|
||||||
from steering.strategies.next_action.default_strategy import DefaultNextActionStrategy
|
from steering.strategies.next_action.default_strategy import DefaultNextActionStrategy
|
||||||
from steering.strategies.next_action.execution_ready import (
|
from steering.strategies.next_action.execution_ready import (
|
||||||
|
|
@ -21,43 +20,6 @@ from tenant_context import TenantContext
|
||||||
_default = DefaultNextActionStrategy()
|
_default = DefaultNextActionStrategy()
|
||||||
|
|
||||||
|
|
||||||
def _recurring_due_candidates(
|
|
||||||
ctx: TenantContext, initiative_id: str, *, limit: int
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
conn = get_connection()
|
|
||||||
try:
|
|
||||||
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT
|
|
||||||
'recurring_due' AS kind,
|
|
||||||
re.title AS title,
|
|
||||||
'Wiederkehrendes Element fällig' AS summary,
|
|
||||||
re.initiative_id,
|
|
||||||
NULL::uuid AS action_id,
|
|
||||||
NULL::uuid AS backlog_item_id,
|
|
||||||
'recurring_due' AS reason_code,
|
|
||||||
'Rhythmus-Element bearbeiten' AS recommended_action
|
|
||||||
FROM recurring_elements re
|
|
||||||
WHERE re.tenant_id = %s AND re.initiative_id = %s
|
|
||||||
AND re.status = 'active'
|
|
||||||
AND re.next_due_at IS NOT NULL
|
|
||||||
AND re.next_due_at <= NOW()
|
|
||||||
ORDER BY re.next_due_at ASC
|
|
||||||
LIMIT %s
|
|
||||||
""",
|
|
||||||
(ctx.tenant_id, initiative_id, limit),
|
|
||||||
)
|
|
||||||
items: list[dict[str, Any]] = []
|
|
||||||
for row in cur.fetchall():
|
|
||||||
item = dict(row)
|
|
||||||
item["initiative_id"] = str(item["initiative_id"])
|
|
||||||
items.append(item)
|
|
||||||
return items
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
class RecurringControlStrategy:
|
class RecurringControlStrategy:
|
||||||
key = "recurring_control"
|
key = "recurring_control"
|
||||||
|
|
||||||
|
|
@ -73,7 +35,7 @@ class RecurringControlStrategy:
|
||||||
if not initiative_id:
|
if not initiative_id:
|
||||||
return _default.evaluate(ctx, limit=limit)
|
return _default.evaluate(ctx, limit=limit)
|
||||||
|
|
||||||
recurring = _recurring_due_candidates(ctx, initiative_id, limit=limit)
|
recurring = recurring_due_candidates(ctx, initiative_id, limit=limit)
|
||||||
remaining = limit - len(recurring)
|
remaining = limit - len(recurring)
|
||||||
ready: list[dict[str, Any]] = []
|
ready: list[dict[str, Any]] = []
|
||||||
if remaining > 0:
|
if remaining > 0:
|
||||||
|
|
|
||||||
82
backend/steering/strategies/next_action/recurring_helpers.py
Normal file
82
backend/steering/strategies/next_action/recurring_helpers.py
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
"""Shared recurring NextAction helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from db import get_connection
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
|
||||||
|
def recurring_due_candidates(
|
||||||
|
ctx: TenantContext, initiative_id: str, *, limit: int
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
'recurring_due' AS kind,
|
||||||
|
re.title AS title,
|
||||||
|
'Wiederkehrendes Element fällig' AS summary,
|
||||||
|
re.initiative_id,
|
||||||
|
NULL::uuid AS action_id,
|
||||||
|
NULL::uuid AS backlog_item_id,
|
||||||
|
'recurring_due' AS reason_code,
|
||||||
|
'Rhythmus-Element bearbeiten' AS recommended_action
|
||||||
|
FROM recurring_elements re
|
||||||
|
WHERE re.tenant_id = %s AND re.initiative_id = %s
|
||||||
|
AND re.status = 'active'
|
||||||
|
AND re.next_due_at IS NOT NULL
|
||||||
|
AND re.next_due_at <= NOW()
|
||||||
|
ORDER BY re.next_due_at ASC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(ctx.tenant_id, initiative_id, limit),
|
||||||
|
)
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for row in cur.fetchall():
|
||||||
|
item = dict(row)
|
||||||
|
item["initiative_id"] = str(item["initiative_id"])
|
||||||
|
items.append(item)
|
||||||
|
return items
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def active_recurring_candidates(
|
||||||
|
ctx: TenantContext, initiative_id: str, *, limit: int = 1
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Active recurring as 'heutige Übung' when nothing is due yet."""
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
'recurring_due' AS kind,
|
||||||
|
re.title AS title,
|
||||||
|
'Heutige Übung / Routine' AS summary,
|
||||||
|
re.initiative_id,
|
||||||
|
NULL::uuid AS action_id,
|
||||||
|
NULL::uuid AS backlog_item_id,
|
||||||
|
'recurring_active' AS reason_code,
|
||||||
|
'Routine ausführen' AS recommended_action
|
||||||
|
FROM recurring_elements re
|
||||||
|
WHERE re.tenant_id = %s AND re.initiative_id = %s
|
||||||
|
AND re.status = 'active'
|
||||||
|
ORDER BY re.next_due_at ASC NULLS LAST, re.title
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(ctx.tenant_id, initiative_id, limit),
|
||||||
|
)
|
||||||
|
items: list[dict[str, Any]] = []
|
||||||
|
for row in cur.fetchall():
|
||||||
|
item = dict(row)
|
||||||
|
item["initiative_id"] = str(item["initiative_id"])
|
||||||
|
items.append(item)
|
||||||
|
return items
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
62
backend/tests/test_ap20e_maturity_transition.py
Normal file
62
backend/tests/test_ap20e_maturity_transition.py
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
"""AP2.0e — maturity stage transition on verify reached."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from tests.factories import provision_user_in_tenant
|
||||||
|
from tests.test_initiatives_actions import _auth, _login
|
||||||
|
|
||||||
|
|
||||||
|
def test_maturity_stage_reached_rotates_recurring(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="admin")
|
||||||
|
token = _login(client, user)
|
||||||
|
|
||||||
|
created = client.post(
|
||||||
|
"/api/initiatives",
|
||||||
|
json={
|
||||||
|
"title": "Spagat Test",
|
||||||
|
"archetype_key": "initiative.maturity_journey",
|
||||||
|
},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert created.status_code == 201
|
||||||
|
initiative_id = created.json()["id"]
|
||||||
|
|
||||||
|
items = client.get(
|
||||||
|
f"/api/initiatives/{initiative_id}/roadmap/items",
|
||||||
|
headers=_auth(token),
|
||||||
|
).json()
|
||||||
|
stage1 = next(i for i in items if i["title"] == "Stufe 1 — Basis")
|
||||||
|
|
||||||
|
client.post(
|
||||||
|
f"/api/initiatives/{initiative_id}/evidence",
|
||||||
|
json={
|
||||||
|
"title": "Stufe 1 geschafft",
|
||||||
|
"roadmap_item_id": stage1["id"],
|
||||||
|
"status": "accepted",
|
||||||
|
},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
|
||||||
|
verify = client.post(
|
||||||
|
f"/api/roadmap-items/{stage1['id']}/verify-reached",
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert verify.status_code == 200
|
||||||
|
body = verify.json()
|
||||||
|
assert body["status"] == "reached"
|
||||||
|
assert body.get("maturity_transition", {}).get("new_recurring_id")
|
||||||
|
|
||||||
|
recurring = client.get(
|
||||||
|
f"/api/initiatives/{initiative_id}/recurring",
|
||||||
|
headers=_auth(token),
|
||||||
|
).json()
|
||||||
|
titles = {r["title"]: r["status"] for r in recurring}
|
||||||
|
assert titles.get("Tägliche Übung") == "paused"
|
||||||
|
assert any(t.startswith("Übung — Stufe 2") for t in titles)
|
||||||
|
|
||||||
|
items_after = client.get(
|
||||||
|
f"/api/initiatives/{initiative_id}/roadmap/items",
|
||||||
|
headers=_auth(token),
|
||||||
|
).json()
|
||||||
|
stage2 = next(i for i in items_after if i["title"] == "Stufe 2 — Aufbau")
|
||||||
|
assert stage2["status"] == "active"
|
||||||
18
backend/tests/test_ap22_actor_create.py
Normal file
18
backend/tests/test_ap22_actor_create.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
"""Actor create API."""
|
||||||
|
|
||||||
|
from tests.factories import provision_user_in_tenant
|
||||||
|
from tests.test_initiatives_actions import _auth, _login
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_agent_actor(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="admin")
|
||||||
|
token = _login(client, user)
|
||||||
|
|
||||||
|
created = client.post(
|
||||||
|
"/api/actors",
|
||||||
|
json={"name": "Cursor Agent", "actor_type": "agent"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert created.status_code == 201
|
||||||
|
assert created.json()["name"] == "Cursor Agent"
|
||||||
|
assert created.json()["actor_type"] == "agent"
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
APP_VERSION = "0.20.0-ap2.2a"
|
APP_VERSION = "0.20.1-ap2.2bc"
|
||||||
DB_SCHEMA_VERSION = "025"
|
DB_SCHEMA_VERSION = "025"
|
||||||
APP_NAME = "jinkendo-kairo"
|
APP_NAME = "jinkendo-kairo"
|
||||||
|
|
|
||||||
|
|
@ -71,10 +71,10 @@ Legende: **Anlage** · **Struktur** · **Ist** · **Kontrolle** · **UI-Default*
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Phase 0 DOC + Kurskorrektur (dieses Dokument) ← jetzt
|
Phase 0 DOC + Kurskorrektur (dieses Dokument) ← jetzt
|
||||||
Phase 1 AP2.2a Archetyp-geführte Anlage + Starter-Kits ← NÄCHSTES CODE
|
Phase 1 AP2.2a Archetyp-geführte Anlage + Starter-Kits ✓
|
||||||
Phase 2 AP1.9d Methoden-Default-Ansichten (Anti-Todo-Wand) parallel möglich
|
Phase 2 AP1.9d Methoden-Default-Ansichten (Anti-Todo-Wand) ◐ (Hints + Recurring)
|
||||||
Phase 3 AP2.2b A2 Linear End-to-End
|
Phase 3 AP2.2b A2 Linear End-to-End ◐
|
||||||
AP2.2c A1 Reifegrad + AP2.0e
|
AP2.2c A1 Reifegrad + AP2.0e ◐ Code
|
||||||
AP2.2d B2b Product + B3 Sprint
|
AP2.2d B2b Product + B3 Sprint
|
||||||
AP2.2e B2a Programm (optional vor AP2.1)
|
AP2.2e B2a Programm (optional vor AP2.1)
|
||||||
Phase 4 AP1.7b Operational API — Pflege-Parität
|
Phase 4 AP1.7b Operational API — Pflege-Parität
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "kairo-jinkendo-frontend",
|
"name": "kairo-jinkendo-frontend",
|
||||||
"version": "0.20.0-ap2.2a",
|
"version": "0.20.1-ap2.2bc",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,13 @@ export function getActor(id) {
|
||||||
return apiFetch(`/api/actors/${id}`)
|
return apiFetch(`/api/actors/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createActor(body) {
|
||||||
|
return apiFetch('/api/actors', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/** Fallback wenn Actor Directory nicht erreichbar (AP0.6-Kompatibilität). */
|
/** Fallback wenn Actor Directory nicht erreichbar (AP0.6-Kompatibilität). */
|
||||||
export function actorsFromContext(context) {
|
export function actorsFromContext(context) {
|
||||||
if (!context?.actor?.id) return []
|
if (!context?.actor?.id) return []
|
||||||
|
|
|
||||||
35
frontend/src/components/ArchetypeSteeringHints.jsx
Normal file
35
frontend/src/components/ArchetypeSteeringHints.jsx
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { scopedPath } from '../utils/routes.js'
|
||||||
|
|
||||||
|
const HINTS = {
|
||||||
|
'initiative.maturity_journey': {
|
||||||
|
text: 'Reifegrad: Heutige Übung (Rhythmus) und aktive Stufe steuern den nächsten Schritt.',
|
||||||
|
link: { to: '/control/journey', label: 'Rhythmen & Journey' },
|
||||||
|
},
|
||||||
|
'initiative.linear_project': {
|
||||||
|
text: 'Linear: Nächster Schritt am kritischen Pfad — Abhängigkeiten im Gate-Graph.',
|
||||||
|
link: { to: '/plan/gates', label: 'Gate-Graph öffnen' },
|
||||||
|
},
|
||||||
|
'initiative.product': {
|
||||||
|
text: 'Product: Eingang triagieren, committete Actions — optional Sprint-Zeitbox.',
|
||||||
|
link: { to: '/plan/inbox', label: 'Zum Eingang' },
|
||||||
|
},
|
||||||
|
'initiative.program': {
|
||||||
|
text: 'Programm: Gate-Horizont und Abschluss im Blick behalten.',
|
||||||
|
link: { to: '/plan/gates', label: 'Phasen / Gates' },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ArchetypeSteeringHints({ archetypeKey }) {
|
||||||
|
const hint = HINTS[archetypeKey]
|
||||||
|
if (!hint) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p className="steering-archetype-hints muted">
|
||||||
|
{hint.text}{' '}
|
||||||
|
<Link to={scopedPath(hint.link.to)} className="link-inline">
|
||||||
|
{hint.link.label}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { INITIATIVE_STATUSES, PRIORITIES } from '../constants/status.js'
|
import { INITIATIVE_STATUSES, PRIORITIES } from '../constants/status.js'
|
||||||
import { INITIATIVE_STATUS_LABELS, PRIORITY_LABELS } from '../constants/status.js'
|
import { INITIATIVE_STATUS_LABELS, PRIORITY_LABELS } from '../constants/status.js'
|
||||||
import { INITIATIVE_ARCHETYPE_LABELS } from '../constants/initiativeArchetypes.js'
|
import { INITIATIVE_ARCHETYPE_LABELS, STUFE_A_ARCHETYPE_KEYS } from '../constants/initiativeArchetypes.js'
|
||||||
import { listEntityArchetypes, getArchetypeStarterPreview } from '../api/entityArchetypes.js'
|
import { listEntityArchetypes, getArchetypeStarterPreview } from '../api/entityArchetypes.js'
|
||||||
import {
|
import {
|
||||||
getInitiativeFields,
|
getInitiativeFields,
|
||||||
|
|
@ -129,6 +129,8 @@ export function InitiativeForm({
|
||||||
|
|
||||||
const formReady = archetypes && !fieldsLoading
|
const formReady = archetypes && !fieldsLoading
|
||||||
const selectedArchetype = (archetypes || []).find((item) => item.key === archetypeKey)
|
const selectedArchetype = (archetypes || []).find((item) => item.key === archetypeKey)
|
||||||
|
const stufeA = (archetypes || []).filter((item) => STUFE_A_ARCHETYPE_KEYS.has(item.key))
|
||||||
|
const otherArchetypes = (archetypes || []).filter((item) => !STUFE_A_ARCHETYPE_KEYS.has(item.key))
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form className="form workspace-form initiative-form" onSubmit={handleSubmit}>
|
<form className="form workspace-form initiative-form" onSubmit={handleSubmit}>
|
||||||
|
|
@ -146,11 +148,24 @@ export function InitiativeForm({
|
||||||
onChange={(e) => setArchetypeKey(e.target.value)}
|
onChange={(e) => setArchetypeKey(e.target.value)}
|
||||||
disabled={!archetypes}
|
disabled={!archetypes}
|
||||||
>
|
>
|
||||||
{(archetypes || []).map((item) => (
|
{stufeA.length > 0 && (
|
||||||
|
<optgroup label="MVP Stufe A (empfohlen)">
|
||||||
|
{stufeA.map((item) => (
|
||||||
<option key={item.key} value={item.key}>
|
<option key={item.key} value={item.key}>
|
||||||
{item.label}
|
{item.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
{otherArchetypes.length > 0 && (
|
||||||
|
<optgroup label="Weitere Archetypen">
|
||||||
|
{otherArchetypes.map((item) => (
|
||||||
|
<option key={item.key} value={item.key}>
|
||||||
|
{item.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
</select>
|
</select>
|
||||||
{selectedArchetype?.description && (
|
{selectedArchetype?.description && (
|
||||||
<span className="muted form-hint">{selectedArchetype.description}</span>
|
<span className="muted form-hint">{selectedArchetype.description}</span>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import {
|
||||||
} from '../constants/operating.js'
|
} from '../constants/operating.js'
|
||||||
import { MILESTONE_STATUS_LABELS } from '../constants/status.js'
|
import { MILESTONE_STATUS_LABELS } from '../constants/status.js'
|
||||||
import { methodDescriptionForKey } from '../utils/archetypes.js'
|
import { methodDescriptionForKey } from '../utils/archetypes.js'
|
||||||
|
import { ArchetypeSteeringHints } from './ArchetypeSteeringHints.jsx'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { scopedPath } from '../utils/routes.js'
|
import { scopedPath } from '../utils/routes.js'
|
||||||
|
|
||||||
|
|
@ -106,6 +107,7 @@ export function SteeringSnapshotPanel({
|
||||||
{steering_guidance && (
|
{steering_guidance && (
|
||||||
<p className="steering-guidance">{steering_guidance}</p>
|
<p className="steering-guidance">{steering_guidance}</p>
|
||||||
)}
|
)}
|
||||||
|
<ArchetypeSteeringHints archetypeKey={archetype_key} />
|
||||||
</div>
|
</div>
|
||||||
<span className="badge status-badge status-active steering-lifecycle-badge">
|
<span className="badge status-badge status-active steering-lifecycle-badge">
|
||||||
{displayLabel}
|
{displayLabel}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
/** Fallback-Labels bis API geladen (AP1.10a / AP2.0). */
|
/** MVP Stufe A — geführte Referenz-Archetypen (AP2.2a). */
|
||||||
|
export const STUFE_A_ARCHETYPE_KEYS = new Set([
|
||||||
|
'initiative.maturity_journey',
|
||||||
|
'initiative.linear_project',
|
||||||
|
'initiative.product',
|
||||||
|
'initiative.program',
|
||||||
|
])
|
||||||
export const INITIATIVE_ARCHETYPE_LABELS = {
|
export const INITIATIVE_ARCHETYPE_LABELS = {
|
||||||
'initiative.generic': 'Allgemeines Vorhaben',
|
'initiative.generic': 'Allgemeines Vorhaben',
|
||||||
'initiative.maturity_journey': 'Reifegrad-Entwicklung',
|
'initiative.maturity_journey': 'Reifegrad-Entwicklung',
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,108 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { getActorWorkload } from '../../api/workspace.js'
|
import { getActorWorkload } from '../../api/workspace.js'
|
||||||
|
import { createActor, listActors } from '../../api/actors.js'
|
||||||
|
import { useSession } from '../../context/SessionContext.jsx'
|
||||||
import { ModeShell } from '../../components/ModeShell.jsx'
|
import { ModeShell } from '../../components/ModeShell.jsx'
|
||||||
import { LoadingState } from '../../components/LoadingState.jsx'
|
import { LoadingState } from '../../components/LoadingState.jsx'
|
||||||
import { ErrorState } from '../../components/ErrorState.jsx'
|
import { ErrorState } from '../../components/ErrorState.jsx'
|
||||||
import { scopedPath } from '../../utils/routes.js'
|
import { scopedPath } from '../../utils/routes.js'
|
||||||
|
|
||||||
|
const ACTOR_TYPE_LABELS = {
|
||||||
|
human: 'Person',
|
||||||
|
agent: 'Agent / Vibe-Coder',
|
||||||
|
working_group: 'Gruppe',
|
||||||
|
external_system: 'Externes System',
|
||||||
|
}
|
||||||
|
|
||||||
export function TeamPage() {
|
export function TeamPage() {
|
||||||
|
const { hasCapability } = useSession()
|
||||||
const [rows, setRows] = useState(null)
|
const [rows, setRows] = useState(null)
|
||||||
|
const [actors, setActors] = useState([])
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
const [agentName, setAgentName] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const [workload, directory] = await Promise.all([
|
||||||
|
getActorWorkload(),
|
||||||
|
listActors({ include_inactive: false }),
|
||||||
|
])
|
||||||
|
setRows(workload)
|
||||||
|
setActors(directory)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
load()
|
||||||
getActorWorkload()
|
|
||||||
.then((data) => {
|
|
||||||
if (!cancelled) setRows(data)
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (!cancelled) setError(err.message)
|
|
||||||
})
|
|
||||||
return () => {
|
|
||||||
cancelled = true
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
async function handleCreateAgent(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
const name = agentName.trim()
|
||||||
|
if (!name) return
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await createActor({ name, actor_type: 'agent' })
|
||||||
|
setAgentName('')
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canManage = hasCapability('kairo.actor.manage')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app-page">
|
<div className="app-page">
|
||||||
<ModeShell modeKey="team" />
|
<ModeShell modeKey="team" />
|
||||||
{rows === null && !error && <LoadingState message="Lade Team-Auslastung …" />}
|
{rows === null && !error && <LoadingState message="Lade Team …" />}
|
||||||
{error && <ErrorState message={error} />}
|
{error && <ErrorState message={error} onRetry={load} />}
|
||||||
{rows && (
|
{rows && (
|
||||||
|
<>
|
||||||
|
<section className="card">
|
||||||
|
<h2 className="card-title">Actor-Verzeichnis</h2>
|
||||||
|
{actors.length === 0 ? (
|
||||||
|
<p className="muted">Keine Actors im Tenant.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="team-workload-list">
|
||||||
|
{actors.map((actor) => (
|
||||||
|
<li key={actor.id} className="team-workload-list__item">
|
||||||
|
<div>
|
||||||
|
<strong>{actor.name}</strong>
|
||||||
|
<span className="muted">
|
||||||
|
{' '}
|
||||||
|
· {ACTOR_TYPE_LABELS[actor.actor_type] || actor.actor_type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{canManage && (
|
||||||
|
<form className="form inline-form team-agent-form" onSubmit={handleCreateAgent}>
|
||||||
|
<label>
|
||||||
|
Agent anlegen (Vibe-Coder)
|
||||||
|
<input
|
||||||
|
value={agentName}
|
||||||
|
onChange={(e) => setAgentName(e.target.value)}
|
||||||
|
placeholder="z. B. Cursor Agent"
|
||||||
|
maxLength={255}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className="btn btn-primary btn-sm" disabled={busy}>
|
||||||
|
{busy ? 'Anlegen …' : 'Agent anlegen'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<h2 className="card-title">Auslastung nach Actor</h2>
|
<h2 className="card-title">Auslastung nach Actor</h2>
|
||||||
{rows.length === 0 ? (
|
{rows.length === 0 ? (
|
||||||
|
|
@ -39,7 +112,7 @@ export function TeamPage() {
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
<li key={row.actor_id} className="team-workload-list__item">
|
<li key={row.actor_id} className="team-workload-list__item">
|
||||||
<div>
|
<div>
|
||||||
<strong>{row.actor_name || row.actor_id}</strong>
|
<strong>{row.display_name || row.actor_name || row.actor_id}</strong>
|
||||||
<span className="muted">
|
<span className="muted">
|
||||||
{' '}
|
{' '}
|
||||||
· {row.open_actions ?? 0} offen
|
· {row.open_actions ?? 0} offen
|
||||||
|
|
@ -55,10 +128,8 @@ export function TeamPage() {
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
<p className="page-footer-link muted">
|
|
||||||
AP1.9b erweitert Team um Filter nach Vorhaben und direkte Zuweisungslinks.
|
|
||||||
</p>
|
|
||||||
</section>
|
</section>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user