AP1.1: Method Registry — product_milestone_driven + Methode wählbar
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 1m23s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 12s
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 1m23s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 18s
Test Suite / playwright-smoke (push) Successful in 12s
Zweite Built-in-Methode mit meilensteinorientierter NextAction-Strategie, API und UI-Auswahl pro Vorhaben; AP0.10d Validation-Vorlagen. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
95feba6204
commit
fc956d4ceb
|
|
@ -241,6 +241,15 @@ Operating Model Integration (AP0.10, Schema weiter `008`):
|
||||||
|
|
||||||
Version nach AP1.0: **`0.11.0-ap1.0`**
|
Version nach AP1.0: **`0.11.0-ap1.0`**
|
||||||
|
|
||||||
|
**AP1.1 — Method Registry:**
|
||||||
|
|
||||||
|
| Endpoint | Methode | Capability | Beschreibung |
|
||||||
|
|----------|---------|------------|--------------|
|
||||||
|
| `/api/steering/methods` | GET | `kairo.initiative.read` | Built-in Steuerungsmethoden |
|
||||||
|
| `/api/steering/initiatives/{id}/context` | PATCH | `kairo.initiative.manage` | Methode pro Vorhaben setzen |
|
||||||
|
|
||||||
|
Version nach AP1.1: **`0.11.1-ap1.1`**
|
||||||
|
|
||||||
**Operating-Transitions:** Blocker gelöst → Maßnahme entblocken; Review abgeschlossen → Maßnahme `review_required` → `done`
|
**Operating-Transitions:** Blocker gelöst → Maßnahme entblocken; Review abgeschlossen → Maßnahme `review_required` → `done`
|
||||||
|
|
||||||
**Attention Regel 10:** `action_review_required` — Maßnahme ohne geplantes Review
|
**Attention Regel 10:** `action_review_required` — Maßnahme ohne geplantes Review
|
||||||
|
|
|
||||||
|
|
@ -320,6 +320,7 @@ def get_initiative_steering_snapshot(
|
||||||
"lifecycle_state": steering["lifecycle_state"],
|
"lifecycle_state": steering["lifecycle_state"],
|
||||||
"lifecycle_label": steering["lifecycle_label"],
|
"lifecycle_label": steering["lifecycle_label"],
|
||||||
"method_key": steering["method_key"],
|
"method_key": steering["method_key"],
|
||||||
|
"method_label": steering.get("method_label"),
|
||||||
"method_version": steering["method_version"],
|
"method_version": steering["method_version"],
|
||||||
"operating_phase": phase,
|
"operating_phase": phase,
|
||||||
"operating_phase_deprecated": True,
|
"operating_phase_deprecated": True,
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ from routers import ( # noqa: E402
|
||||||
prompts,
|
prompts,
|
||||||
recurring,
|
recurring,
|
||||||
reviews,
|
reviews,
|
||||||
|
steering,
|
||||||
workspace,
|
workspace,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -88,6 +89,7 @@ app.include_router(evidence.router)
|
||||||
app.include_router(decisions.router)
|
app.include_router(decisions.router)
|
||||||
app.include_router(reviews.router)
|
app.include_router(reviews.router)
|
||||||
app.include_router(recurring.router)
|
app.include_router(recurring.router)
|
||||||
|
app.include_router(steering.router)
|
||||||
app.include_router(actors.router)
|
app.include_router(actors.router)
|
||||||
app.include_router(workspace.router)
|
app.include_router(workspace.router)
|
||||||
|
|
||||||
|
|
|
||||||
62
backend/routers/steering.py
Normal file
62
backend/routers/steering.py
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
"""Steering API — methods and context (AP1.1)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal, Optional
|
||||||
|
|
||||||
|
from capabilities import require_capability
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from services import initiatives as initiative_service
|
||||||
|
from services import steering_context as sc_service
|
||||||
|
from steering.context import get_steering_context_dto
|
||||||
|
from steering.methods.registry import list_methods
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/steering", tags=["steering"])
|
||||||
|
|
||||||
|
|
||||||
|
class SteeringContextUpdateRequest(BaseModel):
|
||||||
|
method_key: Optional[
|
||||||
|
Literal["generic_operating", "product_milestone_driven"]
|
||||||
|
] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/methods")
|
||||||
|
def list_steering_methods(
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
|
||||||
|
):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"key": m.key,
|
||||||
|
"version": m.version,
|
||||||
|
"label": m.label,
|
||||||
|
"description": m.description,
|
||||||
|
"next_action_strategy_key": m.next_action_strategy_key,
|
||||||
|
}
|
||||||
|
for m in list_methods()
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/initiatives/{initiative_id}/context")
|
||||||
|
def patch_initiative_steering_context(
|
||||||
|
initiative_id: str,
|
||||||
|
body: SteeringContextUpdateRequest,
|
||||||
|
ctx: TenantContext = Depends(require_capability("kairo.initiative.manage")),
|
||||||
|
):
|
||||||
|
if not initiative_service.get_initiative(
|
||||||
|
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden")
|
||||||
|
if not body.method_key:
|
||||||
|
raise HTTPException(status_code=400, detail="method_key erforderlich")
|
||||||
|
try:
|
||||||
|
sc_service.update_method_key(
|
||||||
|
tenant_id=ctx.tenant_id,
|
||||||
|
initiative_id=initiative_id,
|
||||||
|
method_key=body.method_key,
|
||||||
|
user_id=ctx.user_id,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||||
|
return get_steering_context_dto(ctx, initiative_id=initiative_id)
|
||||||
|
|
@ -194,6 +194,57 @@ def update_lifecycle_state(
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def update_method_key(
|
||||||
|
*,
|
||||||
|
tenant_id: str,
|
||||||
|
initiative_id: str,
|
||||||
|
method_key: str,
|
||||||
|
user_id: Optional[str] = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
from steering.methods.registry import get_method
|
||||||
|
|
||||||
|
method = get_method(method_key)
|
||||||
|
if not method:
|
||||||
|
raise ValueError(f"Unbekannte Methode: {method_key}")
|
||||||
|
|
||||||
|
existing = get_steering_context(tenant_id=tenant_id, initiative_id=initiative_id)
|
||||||
|
if not existing:
|
||||||
|
raise ValueError("SteeringContext nicht gefunden")
|
||||||
|
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE steering_contexts
|
||||||
|
SET method_key = %s, method_version = %s, updated_at = NOW()
|
||||||
|
WHERE tenant_id = %s AND initiative_id = %s
|
||||||
|
RETURNING id, tenant_id, initiative_id, method_key, method_version,
|
||||||
|
lifecycle_state, lifecycle_metadata, created_at, updated_at
|
||||||
|
""",
|
||||||
|
(method.key, method.version, tenant_id, initiative_id),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
raise ValueError("SteeringContext nicht gefunden")
|
||||||
|
result = _serialize_row(dict(row))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
log_audit(
|
||||||
|
"steering_context.method_changed",
|
||||||
|
user_id=user_id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
details={
|
||||||
|
"initiative_id": initiative_id,
|
||||||
|
"from_method": existing["method_key"],
|
||||||
|
"to_method": method_key,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def create_context_for_new_initiative(
|
def create_context_for_new_initiative(
|
||||||
*,
|
*,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,16 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from steering.hooks.registry import register_builtin_hooks
|
from steering.hooks.registry import register_builtin_hooks
|
||||||
from steering.methods.registrations import generic_operating
|
from steering.methods.registrations import generic_operating, product_milestone_driven
|
||||||
|
from steering.strategies.next_action import register_builtin_strategies
|
||||||
|
|
||||||
|
|
||||||
def bootstrap_steering() -> None:
|
def bootstrap_steering() -> None:
|
||||||
"""Register built-in hooks and methods (idempotent)."""
|
"""Register built-in hooks, methods and strategies (idempotent)."""
|
||||||
register_builtin_hooks()
|
register_builtin_hooks()
|
||||||
|
register_builtin_strategies()
|
||||||
generic_operating.register()
|
generic_operating.register()
|
||||||
|
product_milestone_driven.register()
|
||||||
|
|
||||||
|
|
||||||
bootstrap_steering()
|
bootstrap_steering()
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ from typing import Any, Optional
|
||||||
|
|
||||||
from services import steering_context as sc_service
|
from services import steering_context as sc_service
|
||||||
from steering.lifecycle.states import lifecycle_label
|
from steering.lifecycle.states import lifecycle_label
|
||||||
|
from steering.methods.registry import get_method
|
||||||
from tenant_context import TenantContext
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -20,9 +21,11 @@ def get_steering_context_dto(
|
||||||
tenant_id=ctx.tenant_id,
|
tenant_id=ctx.tenant_id,
|
||||||
initiative_id=initiative_id,
|
initiative_id=initiative_id,
|
||||||
)
|
)
|
||||||
|
method = get_method(row["method_key"])
|
||||||
return {
|
return {
|
||||||
**row,
|
**row,
|
||||||
"lifecycle_label": lifecycle_label(row["lifecycle_state"]),
|
"lifecycle_label": lifecycle_label(row["lifecycle_state"]),
|
||||||
|
"method_label": method.label if method else row["method_key"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Built-in method: generic_operating — AP1.0."""
|
"""Built-in method: generic_operating."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -13,7 +13,9 @@ def register() -> None:
|
||||||
MethodDefinition(
|
MethodDefinition(
|
||||||
key="generic_operating",
|
key="generic_operating",
|
||||||
version="0.1.0",
|
version="0.1.0",
|
||||||
|
label="Allgemeine Steuerung",
|
||||||
description="Standard-Lifecycle ohne methodenspezifische Spezialisierung",
|
description="Standard-Lifecycle ohne methodenspezifische Spezialisierung",
|
||||||
default_lifecycle_steps=STANDARD_LIFECYCLE_STEPS,
|
default_lifecycle_steps=STANDARD_LIFECYCLE_STEPS,
|
||||||
|
next_action_strategy_key="default",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
"""Built-in method: product_milestone_driven — AP1.1."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from steering.lifecycle.states import STANDARD_LIFECYCLE_STEPS
|
||||||
|
from steering.methods.registry import MethodDefinition, get_method, register_method
|
||||||
|
|
||||||
|
_PRODUCT_STEPS = (
|
||||||
|
"intake",
|
||||||
|
"method_selection",
|
||||||
|
"structure_setup",
|
||||||
|
"planning",
|
||||||
|
"action_selection",
|
||||||
|
"assignment",
|
||||||
|
"waiting",
|
||||||
|
"result_intake",
|
||||||
|
"validation",
|
||||||
|
"review",
|
||||||
|
"adaptation",
|
||||||
|
"closure",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register() -> None:
|
||||||
|
if get_method("product_milestone_driven"):
|
||||||
|
return
|
||||||
|
register_method(
|
||||||
|
MethodDefinition(
|
||||||
|
key="product_milestone_driven",
|
||||||
|
version="0.1.0",
|
||||||
|
label="Produkt / Meilenstein",
|
||||||
|
description="Meilenstein-orientierte Steuerung für Produkt- und Projektentwicklung",
|
||||||
|
default_lifecycle_steps=_PRODUCT_STEPS,
|
||||||
|
next_action_strategy_key="product_milestone_driven",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
"""Method Registry — built-in steering methods (AP1.0)."""
|
"""Method Registry — built-in steering methods."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -11,8 +11,10 @@ _METHODS: dict[str, "MethodDefinition"] = {}
|
||||||
class MethodDefinition:
|
class MethodDefinition:
|
||||||
key: str
|
key: str
|
||||||
version: str
|
version: str
|
||||||
|
label: str
|
||||||
description: str
|
description: str
|
||||||
default_lifecycle_steps: tuple[str, ...]
|
default_lifecycle_steps: tuple[str, ...]
|
||||||
|
next_action_strategy_key: str = "default"
|
||||||
|
|
||||||
|
|
||||||
def register_method(defn: MethodDefinition) -> None:
|
def register_method(defn: MethodDefinition) -> None:
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,30 @@
|
||||||
"""Signal Engine — delegates to default rule provider (AP1.0)."""
|
"""Signal Engine — method-aware NextAction (AP1.1)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
|
|
||||||
|
from services import steering_context as sc_service
|
||||||
|
from steering.methods.registry import get_method
|
||||||
from steering.signals import default_rules
|
from steering.signals import default_rules
|
||||||
|
from steering.strategies.next_action.default_strategy import default_strategy
|
||||||
|
from steering.strategies.next_action.registry import get_next_action_strategy
|
||||||
from tenant_context import TenantContext
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
SignalKind = Literal["attention", "next_action"]
|
SignalKind = Literal["attention", "next_action"]
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_method_key(ctx: TenantContext, initiative_id: str | None) -> str:
|
||||||
|
if not initiative_id:
|
||||||
|
return "generic_operating"
|
||||||
|
row = sc_service.get_steering_context(
|
||||||
|
tenant_id=ctx.tenant_id, initiative_id=initiative_id
|
||||||
|
)
|
||||||
|
if row:
|
||||||
|
return row["method_key"]
|
||||||
|
return "generic_operating"
|
||||||
|
|
||||||
|
|
||||||
def evaluate(
|
def evaluate(
|
||||||
ctx: TenantContext,
|
ctx: TenantContext,
|
||||||
kind: SignalKind = "attention",
|
kind: SignalKind = "attention",
|
||||||
|
|
@ -19,8 +34,11 @@ def evaluate(
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
if kind == "attention":
|
if kind == "attention":
|
||||||
return default_rules.get_attention_items(ctx)
|
return default_rules.get_attention_items(ctx)
|
||||||
if initiative_id:
|
|
||||||
return default_rules.get_next_action_candidates_for_initiative(
|
method_key = _resolve_method_key(ctx, initiative_id)
|
||||||
ctx, initiative_id=initiative_id, limit=limit
|
method = get_method(method_key)
|
||||||
|
strategy_key = (
|
||||||
|
method.next_action_strategy_key if method else default_strategy.key
|
||||||
)
|
)
|
||||||
return default_rules.get_next_action_candidates(ctx, limit=limit)
|
strategy = get_next_action_strategy(strategy_key) or default_strategy
|
||||||
|
return strategy.evaluate(ctx, initiative_id=initiative_id, limit=limit)
|
||||||
|
|
|
||||||
0
backend/steering/strategies/__init__.py
Normal file
0
backend/steering/strategies/__init__.py
Normal file
18
backend/steering/strategies/next_action/__init__.py
Normal file
18
backend/steering/strategies/next_action/__init__.py
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
"""Bootstrap NextAction strategies."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from steering.strategies.next_action.default_strategy import default_strategy
|
||||||
|
from steering.strategies.next_action.product_milestone_driven import (
|
||||||
|
product_milestone_strategy,
|
||||||
|
)
|
||||||
|
from steering.strategies.next_action.registry import (
|
||||||
|
get_next_action_strategy,
|
||||||
|
register_next_action_strategy,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register_builtin_strategies() -> None:
|
||||||
|
for strategy in (default_strategy, product_milestone_strategy):
|
||||||
|
if not get_next_action_strategy(strategy.key):
|
||||||
|
register_next_action_strategy(strategy)
|
||||||
28
backend/steering/strategies/next_action/default_strategy.py
Normal file
28
backend/steering/strategies/next_action/default_strategy.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
"""Default NextAction strategy — delegates to global rules (AP1.0)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from steering.signals import default_rules
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
|
||||||
|
class DefaultNextActionStrategy:
|
||||||
|
key = "default"
|
||||||
|
|
||||||
|
def evaluate(
|
||||||
|
self,
|
||||||
|
ctx: TenantContext,
|
||||||
|
*,
|
||||||
|
initiative_id: str | None = None,
|
||||||
|
limit: int = 10,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
if initiative_id:
|
||||||
|
return default_rules.get_next_action_candidates_for_initiative(
|
||||||
|
ctx, initiative_id=initiative_id, limit=limit
|
||||||
|
)
|
||||||
|
return default_rules.get_next_action_candidates(ctx, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
default_strategy = DefaultNextActionStrategy()
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
"""Product/milestone-driven NextAction strategy — AP1.1."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from db import get_connection
|
||||||
|
from psycopg2.extras import RealDictCursor
|
||||||
|
from steering.signals import default_rules
|
||||||
|
from steering.strategies.next_action.default_strategy import DefaultNextActionStrategy
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
_default = DefaultNextActionStrategy()
|
||||||
|
|
||||||
|
|
||||||
|
class ProductMilestoneDrivenStrategy:
|
||||||
|
key = "product_milestone_driven"
|
||||||
|
|
||||||
|
def evaluate(
|
||||||
|
self,
|
||||||
|
ctx: TenantContext,
|
||||||
|
*,
|
||||||
|
initiative_id: str | None = None,
|
||||||
|
limit: int = 10,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
if limit < 1:
|
||||||
|
limit = 1
|
||||||
|
if not initiative_id:
|
||||||
|
return _default.evaluate(ctx, limit=limit)
|
||||||
|
|
||||||
|
candidates: list[dict[str, Any]] = []
|
||||||
|
conn = get_connection()
|
||||||
|
try:
|
||||||
|
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
'review_milestone' AS kind,
|
||||||
|
m.title AS title,
|
||||||
|
'Meilenstein prüfen oder Status aktualisieren' AS summary,
|
||||||
|
m.initiative_id,
|
||||||
|
NULL::uuid AS action_id,
|
||||||
|
NULL::uuid AS backlog_item_id,
|
||||||
|
'milestone_at_risk_or_active' AS reason_code,
|
||||||
|
'Meilenstein prüfen' AS recommended_action
|
||||||
|
FROM milestones m
|
||||||
|
WHERE m.tenant_id = %s AND m.initiative_id = %s
|
||||||
|
AND m.status IN ('at_risk', 'active')
|
||||||
|
ORDER BY
|
||||||
|
CASE m.status WHEN 'at_risk' THEN 0 ELSE 1 END,
|
||||||
|
m.target_date ASC NULLS LAST,
|
||||||
|
m.updated_at DESC
|
||||||
|
LIMIT %s
|
||||||
|
""",
|
||||||
|
(ctx.tenant_id, initiative_id, limit),
|
||||||
|
)
|
||||||
|
for row in cur.fetchall():
|
||||||
|
item = dict(row)
|
||||||
|
item["initiative_id"] = str(item["initiative_id"])
|
||||||
|
candidates.append(item)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
remaining = limit - len(candidates)
|
||||||
|
if remaining > 0:
|
||||||
|
rest = default_rules.get_next_action_candidates_for_initiative(
|
||||||
|
ctx, initiative_id=initiative_id, limit=remaining
|
||||||
|
)
|
||||||
|
candidates.extend(rest)
|
||||||
|
|
||||||
|
return candidates[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
product_milestone_strategy = ProductMilestoneDrivenStrategy()
|
||||||
35
backend/steering/strategies/next_action/registry.py
Normal file
35
backend/steering/strategies/next_action/registry.py
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
"""NextAction strategy registry — AP1.1."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
from tenant_context import TenantContext
|
||||||
|
|
||||||
|
_STRATEGIES: dict[str, "NextActionStrategy"] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class NextActionStrategy(Protocol):
|
||||||
|
key: str
|
||||||
|
|
||||||
|
def evaluate(
|
||||||
|
self,
|
||||||
|
ctx: TenantContext,
|
||||||
|
*,
|
||||||
|
initiative_id: str | None = None,
|
||||||
|
limit: int = 10,
|
||||||
|
) -> list[dict[str, Any]]: ...
|
||||||
|
|
||||||
|
|
||||||
|
def register_next_action_strategy(strategy: NextActionStrategy) -> None:
|
||||||
|
if strategy.key in _STRATEGIES:
|
||||||
|
raise ValueError(f"NextAction strategy already registered: {strategy.key}")
|
||||||
|
_STRATEGIES[strategy.key] = strategy
|
||||||
|
|
||||||
|
|
||||||
|
def get_next_action_strategy(key: str) -> NextActionStrategy | None:
|
||||||
|
return _STRATEGIES.get(key)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_strategies_for_tests() -> None:
|
||||||
|
_STRATEGIES.clear()
|
||||||
62
backend/tests/test_ap1_1_methods.py
Normal file
62
backend/tests/test_ap1_1_methods.py
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
"""AP1.1 — Method Registry tests."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from tests.factories import provision_user_in_tenant
|
||||||
|
from tests.test_initiatives_actions import (
|
||||||
|
_auth,
|
||||||
|
_create_initiative,
|
||||||
|
_login,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_steering_methods(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="member")
|
||||||
|
token = _login(client, user)
|
||||||
|
|
||||||
|
res = client.get("/api/steering/methods", headers=_auth(token))
|
||||||
|
assert res.status_code == 200
|
||||||
|
methods = res.json()
|
||||||
|
keys = {m["key"] for m in methods}
|
||||||
|
assert "generic_operating" in keys
|
||||||
|
assert "product_milestone_driven" in keys
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_initiative_method(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="member")
|
||||||
|
token = _login(client, user)
|
||||||
|
initiative_id = _create_initiative(client, token).json()["id"]
|
||||||
|
|
||||||
|
res = client.patch(
|
||||||
|
f"/api/steering/initiatives/{initiative_id}/context",
|
||||||
|
json={"method_key": "product_milestone_driven"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.json()["method_key"] == "product_milestone_driven"
|
||||||
|
assert res.json()["method_label"] == "Produkt / Meilenstein"
|
||||||
|
|
||||||
|
|
||||||
|
def test_product_milestone_next_action_prioritizes_milestone(client):
|
||||||
|
user = provision_user_in_tenant(tenant_role="member")
|
||||||
|
token = _login(client, user)
|
||||||
|
initiative_id = _create_initiative(client, token).json()["id"]
|
||||||
|
client.patch(
|
||||||
|
f"/api/steering/initiatives/{initiative_id}/context",
|
||||||
|
json={"method_key": "product_milestone_driven"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
client.post(
|
||||||
|
f"/api/initiatives/{initiative_id}/milestones",
|
||||||
|
json={"title": "Release MVP", "status": "at_risk"},
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
|
||||||
|
snap = client.get(
|
||||||
|
f"/api/initiatives/{initiative_id}/steering-snapshot",
|
||||||
|
headers=_auth(token),
|
||||||
|
)
|
||||||
|
assert snap.status_code == 200
|
||||||
|
next_actions = snap.json()["next_actions"]
|
||||||
|
assert len(next_actions) >= 1
|
||||||
|
assert next_actions[0]["kind"] == "review_milestone"
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
APP_VERSION = "0.11.0-ap1.0"
|
APP_VERSION = "0.11.1-ap1.1"
|
||||||
DB_SCHEMA_VERSION = "009"
|
DB_SCHEMA_VERSION = "009"
|
||||||
APP_NAME = "jinkendo-kairo"
|
APP_NAME = "jinkendo-kairo"
|
||||||
|
|
|
||||||
|
|
@ -51,8 +51,8 @@ Die Reihenfolge „Inventar → mehr UI → irgendwann Core“ hat **lose Steuer
|
||||||
AP0.10b Steering UI Minimum ✓ erledigt
|
AP0.10b Steering UI Minimum ✓ erledigt
|
||||||
Scope Lock aktiv ← keine neuen Heuristiken / kein AP0.10c
|
Scope Lock aktiv ← keine neuen Heuristiken / kein AP0.10c
|
||||||
AP1.0 Steering Foundation Skeleton ← NÄCHSTES Code-Paket
|
AP1.0 Steering Foundation Skeleton ← NÄCHSTES Code-Paket
|
||||||
AP0.10d MVP Validation (5 Vorhaben) ← parallel / unmittelbar nach AP1.0
|
AP0.10d MVP Validation (Vorlage bereit) ← Product Owner ausfüllen
|
||||||
AP1.1 Method Registry + 2. Methode
|
AP1.1 Method Registry + product_milestone ✓ erledigt
|
||||||
AP1.2 Signals konsolidieren; operating_phase entfernen
|
AP1.2 Signals konsolidieren; operating_phase entfernen
|
||||||
AP1.3 operating_transitions → Hook Orchestrator
|
AP1.3 operating_transitions → Hook Orchestrator
|
||||||
AP0.10c Initiative Flow UI ← erst nach AP1.2 (lifecycle-aware)
|
AP0.10c Initiative Flow UI ← erst nach AP1.2 (lifecycle-aware)
|
||||||
|
|
|
||||||
110
docs/sprints/Sprint0_AP0_10_Validation_Report_v0.1.md
Normal file
110
docs/sprints/Sprint0_AP0_10_Validation_Report_v0.1.md
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
# AP0.10d — MVP Validation Report
|
||||||
|
## v0.1
|
||||||
|
|
||||||
|
**Status:** Entwurf — vom Product Owner auszufüllen
|
||||||
|
**Stand:** 2026-07-05
|
||||||
|
**Kairo-Version getestet:** `0.11.x-ap1.x` · Schema `009`
|
||||||
|
**Tester:**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zusammenfassung
|
||||||
|
|
||||||
|
| Kriterium | Ergebnis |
|
||||||
|
|-----------|----------|
|
||||||
|
| ≥3/5 Vorhaben: tägliche Steuerung hilft | ☐ ja ☐ nein |
|
||||||
|
| Leitfrage Workspace ≤30s | ☐ ja ☐ nein |
|
||||||
|
| Go für AP1.2 / AP0.10c | ☐ ja ☐ nein ☐ bedingt |
|
||||||
|
|
||||||
|
**Kurzfazit:**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pro Vorhaben
|
||||||
|
|
||||||
|
### 1. Kairo Entwicklung
|
||||||
|
|
||||||
|
| Check | ja/nein | Notiz |
|
||||||
|
|-------|---------|-------|
|
||||||
|
| Nächste Aktion ≤30s | | |
|
||||||
|
| Blocker früh sichtbar | | |
|
||||||
|
| Meilenstein-Horizont | | |
|
||||||
|
| Backlog→Maßnahme | | |
|
||||||
|
| Besser als Todo-Tool | | |
|
||||||
|
|
||||||
|
**Empfohlene Methode:**
|
||||||
|
**Lücken:**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. Gewaltschutzkurs
|
||||||
|
|
||||||
|
| Check | ja/nein | Notiz |
|
||||||
|
|-------|---------|-------|
|
||||||
|
| Nächste Aktion ≤30s | | |
|
||||||
|
| Blocker früh sichtbar | | |
|
||||||
|
| Meilenstein-Horizont | | |
|
||||||
|
| Backlog→Maßnahme | | |
|
||||||
|
| Besser als Todo-Tool | | |
|
||||||
|
|
||||||
|
**Empfohlene Methode:**
|
||||||
|
**Lücken:**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Karate Training
|
||||||
|
|
||||||
|
| Check | ja/nein | Notiz |
|
||||||
|
|-------|---------|-------|
|
||||||
|
| Nächste Aktion ≤30s | | |
|
||||||
|
| Blocker früh sichtbar | | |
|
||||||
|
| Meilenstein-Horizont | | |
|
||||||
|
| Backlog→Maßnahme | | |
|
||||||
|
| Besser als Todo-Tool | | |
|
||||||
|
|
||||||
|
**Empfohlene Methode:**
|
||||||
|
**Lücken:**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Familienorganisation
|
||||||
|
|
||||||
|
| Check | ja/nein | Notiz |
|
||||||
|
|-------|---------|-------|
|
||||||
|
| Nächste Aktion ≤30s | | |
|
||||||
|
| Blocker früh sichtbar | | |
|
||||||
|
| Meilenstein-Horizont | | |
|
||||||
|
| Backlog→Maßnahme | | |
|
||||||
|
| Besser als Todo-Tool | | |
|
||||||
|
|
||||||
|
**Empfohlene Methode:**
|
||||||
|
**Lücken:**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Server / Mindnet
|
||||||
|
|
||||||
|
| Check | ja/nein | Notiz |
|
||||||
|
|-------|---------|-------|
|
||||||
|
| Nächste Aktion ≤30s | | |
|
||||||
|
| Blocker früh sichtbar | | |
|
||||||
|
| Meilenstein-Horizont | | |
|
||||||
|
| Backlog→Maßnahme | | |
|
||||||
|
| Besser als Todo-Tool | | |
|
||||||
|
|
||||||
|
**Empfohlene Methode:**
|
||||||
|
**Lücken:**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Abgeleitete Prioritäten
|
||||||
|
|
||||||
|
| Priorität | Paket | Begründung |
|
||||||
|
|-----------|-------|------------|
|
||||||
|
| 1 | | |
|
||||||
|
| 2 | | |
|
||||||
|
| 3 | | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Nach Ausfüllung: Status auf „abgeschlossen“ setzen.*
|
||||||
51
docs/sprints/Sprint0_AP0_10d_Validation_Assignment_v0.1.md
Normal file
51
docs/sprints/Sprint0_AP0_10d_Validation_Assignment_v0.1.md
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
# AP0.10d — MVP Validation
|
||||||
|
## Auftrag v0.1
|
||||||
|
|
||||||
|
**Status:** freigegeben
|
||||||
|
**Stand:** 2026-07-05
|
||||||
|
**Vorgänger:** AP1.0 ✓
|
||||||
|
**Gate für:** AP1.1+ (Methodenwahl), AP0.10c (UI)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Kairo mit **5 echten Vorhaben** prüfen — dokumentiert, nicht Bauchgefühl.
|
||||||
|
Output: ausgefüllter `Sprint0_AP0_10_Validation_Report_v0.1.md` + Go/No-Go.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testvorhaben
|
||||||
|
|
||||||
|
| # | Vorhaben | Typ (Vermutung) |
|
||||||
|
|---|----------|-----------------|
|
||||||
|
| 1 | Kairo Entwicklung | product_milestone_driven |
|
||||||
|
| 2 | Gewaltschutzkurs | program / content |
|
||||||
|
| 3 | Karate Training | routine / maturity |
|
||||||
|
| 4 | Familienorganisation | generic_operating |
|
||||||
|
| 5 | Server / Mindnet | routine_operations |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checkliste pro Vorhaben (~15 Min)
|
||||||
|
|
||||||
|
1. Vorhaben in Kairo anlegen (oder bestehendes nutzen)
|
||||||
|
2. Lifecycle-Badge prüfen (`/initiatives/{id}`)
|
||||||
|
3. Workspace: Nächste Aktion in **≤30s**?
|
||||||
|
4. Blocker früh sichtbar?
|
||||||
|
5. Meilenstein-Horizont hilfreich?
|
||||||
|
6. Backlog → Maßnahme sinnvoll?
|
||||||
|
7. Besser als Todo-Tool für *dieses* Vorhaben?
|
||||||
|
8. Welche **Methode** würde passen? (`generic_operating` / `product_milestone_driven` / …)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Go-Kriterien (Recovery Plan §6)
|
||||||
|
|
||||||
|
- ≥3 von 5: „Hilft bei täglicher Steuerung“ = ja (schwach ja reicht nicht)
|
||||||
|
- Leitfrage auf Workspace ohne Schulung beantwortbar
|
||||||
|
- Report dokumentiert Lücken explizit
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Nutze `Sprint0_AP0_10_Validation_Report_v0.1.md` als Vorlage.*
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
# AP1.1 — Method Registry minimal
|
||||||
|
## Implementierungsauftrag v0.1
|
||||||
|
|
||||||
|
**Status:** umgesetzt
|
||||||
|
**Stand:** 2026-07-05
|
||||||
|
**Vorgänger:** AP1.0 ✓
|
||||||
|
**Zielversion:** `0.11.1-ap1.1` · Schema `009`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ziel
|
||||||
|
|
||||||
|
Zweite Built-in-Methode **`product_milestone_driven`** — meilensteinorientierte NextAction-Strategie, wählbar pro Vorhaben.
|
||||||
|
|
||||||
|
## Geliefert
|
||||||
|
|
||||||
|
- Erweiterte `MethodDefinition` (label, next_action_strategy_key)
|
||||||
|
- NextAction-Strategy-Registry
|
||||||
|
- `product_milestone_driven`: Meilensteine at_risk/active zuerst
|
||||||
|
- `GET /api/steering/methods`, `PATCH .../steering-context` (method_key)
|
||||||
|
- UI: Methoden-Anzeige + Auswahl im Vorhaben-Detail
|
||||||
|
|
||||||
|
## Nicht-Scope
|
||||||
|
|
||||||
|
- Method Profiles, Structure Builder, Attention pro Methode (→ AP1.2)
|
||||||
|
- AP0.10c UI
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "kairo-jinkendo-frontend",
|
"name": "kairo-jinkendo-frontend",
|
||||||
"version": "0.11.0-ap1.0",
|
"version": "0.11.1-ap1.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
12
frontend/src/api/steering.js
Normal file
12
frontend/src/api/steering.js
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import { apiFetch } from './client.js'
|
||||||
|
|
||||||
|
export function listSteeringMethods() {
|
||||||
|
return apiFetch('/api/steering/methods')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateInitiativeSteeringMethod(initiativeId, methodKey) {
|
||||||
|
return apiFetch(`/api/steering/initiatives/${initiativeId}/context`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify({ method_key: methodKey }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -17,7 +17,15 @@ function formatDate(iso) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SteeringSnapshotPanel({ snapshot, loading, error }) {
|
export function SteeringSnapshotPanel({
|
||||||
|
snapshot,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
methods = [],
|
||||||
|
canManageMethod = false,
|
||||||
|
onMethodChange,
|
||||||
|
methodBusy = false,
|
||||||
|
}) {
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<section className="card steering-snapshot">
|
<section className="card steering-snapshot">
|
||||||
|
|
@ -42,6 +50,8 @@ export function SteeringSnapshotPanel({ snapshot, loading, error }) {
|
||||||
const {
|
const {
|
||||||
lifecycle_state,
|
lifecycle_state,
|
||||||
lifecycle_label,
|
lifecycle_label,
|
||||||
|
method_key,
|
||||||
|
method_label,
|
||||||
operating_phase,
|
operating_phase,
|
||||||
operating_phase_deprecated,
|
operating_phase_deprecated,
|
||||||
phase_signals,
|
phase_signals,
|
||||||
|
|
@ -66,6 +76,29 @@ export function SteeringSnapshotPanel({ snapshot, loading, error }) {
|
||||||
<span className="badge status-badge status-active">{displayLabel}</span>
|
<span className="badge status-badge status-active">{displayLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(method_label || canManageMethod) && (
|
||||||
|
<p className="steering-method-row">
|
||||||
|
<span className="muted">Methode: </span>
|
||||||
|
{canManageMethod && methods.length > 0 ? (
|
||||||
|
<select
|
||||||
|
className="inline-select steering-method-select"
|
||||||
|
value={method_key || 'generic_operating'}
|
||||||
|
disabled={methodBusy}
|
||||||
|
onChange={(e) => onMethodChange?.(e.target.value)}
|
||||||
|
aria-label="Steuerungsmethode"
|
||||||
|
>
|
||||||
|
{methods.map((m) => (
|
||||||
|
<option key={m.key} value={m.key}>
|
||||||
|
{m.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<strong>{method_label || method_key}</strong>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
{phaseDesc && <p className="steering-phase-desc">{phaseDesc}</p>}
|
{phaseDesc && <p className="steering-phase-desc">{phaseDesc}</p>}
|
||||||
|
|
||||||
{phase_signals?.length > 0 && (
|
{phase_signals?.length > 0 && (
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ import { DecisionsSection } from '../components/DecisionsSection.jsx'
|
||||||
import { ReviewsSection } from '../components/ReviewsSection.jsx'
|
import { ReviewsSection } from '../components/ReviewsSection.jsx'
|
||||||
import { RecurringSection } from '../components/RecurringSection.jsx'
|
import { RecurringSection } from '../components/RecurringSection.jsx'
|
||||||
import { SteeringSnapshotPanel } from '../components/SteeringSnapshotPanel.jsx'
|
import { SteeringSnapshotPanel } from '../components/SteeringSnapshotPanel.jsx'
|
||||||
|
import { listSteeringMethods, updateInitiativeSteeringMethod } from '../api/steering.js'
|
||||||
import { EmptyState } from '../components/EmptyState.jsx'
|
import { EmptyState } from '../components/EmptyState.jsx'
|
||||||
import { ErrorState } from '../components/ErrorState.jsx'
|
import { ErrorState } from '../components/ErrorState.jsx'
|
||||||
import { LoadingState } from '../components/LoadingState.jsx'
|
import { LoadingState } from '../components/LoadingState.jsx'
|
||||||
|
|
@ -93,6 +94,8 @@ export function InitiativeDetailPage() {
|
||||||
const [steeringSnapshot, setSteeringSnapshot] = useState(null)
|
const [steeringSnapshot, setSteeringSnapshot] = useState(null)
|
||||||
const [steeringSnapshotLoading, setSteeringSnapshotLoading] = useState(false)
|
const [steeringSnapshotLoading, setSteeringSnapshotLoading] = useState(false)
|
||||||
const [steeringSnapshotError, setSteeringSnapshotError] = useState(null)
|
const [steeringSnapshotError, setSteeringSnapshotError] = useState(null)
|
||||||
|
const [steeringMethods, setSteeringMethods] = useState([])
|
||||||
|
const [methodBusy, setMethodBusy] = useState(false)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
const [showActionForm, setShowActionForm] = useState(false)
|
const [showActionForm, setShowActionForm] = useState(false)
|
||||||
|
|
@ -124,6 +127,9 @@ export function InitiativeDetailPage() {
|
||||||
loads.push(listInitiativeDecisions(id).then(setDecisions).catch(() => setDecisions([])))
|
loads.push(listInitiativeDecisions(id).then(setDecisions).catch(() => setDecisions([])))
|
||||||
loads.push(listInitiativeReviews(id).then(setReviews).catch(() => setReviews([])))
|
loads.push(listInitiativeReviews(id).then(setReviews).catch(() => setReviews([])))
|
||||||
loads.push(listInitiativeRecurring(id).then(setRecurringItems).catch(() => setRecurringItems([])))
|
loads.push(listInitiativeRecurring(id).then(setRecurringItems).catch(() => setRecurringItems([])))
|
||||||
|
loads.push(
|
||||||
|
listSteeringMethods().then(setSteeringMethods).catch(() => setSteeringMethods([]))
|
||||||
|
)
|
||||||
loads.push(
|
loads.push(
|
||||||
(async () => {
|
(async () => {
|
||||||
setSteeringSnapshotLoading(true)
|
setSteeringSnapshotLoading(true)
|
||||||
|
|
@ -159,6 +165,18 @@ export function InitiativeDetailPage() {
|
||||||
(steeringSnapshot?.actions || []).map((a) => [a.id, a])
|
(steeringSnapshot?.actions || []).map((a) => [a.id, a])
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async function handleMethodChange(methodKey) {
|
||||||
|
setMethodBusy(true)
|
||||||
|
try {
|
||||||
|
await updateInitiativeSteeringMethod(id, methodKey)
|
||||||
|
setSteeringSnapshot(await getInitiativeSteeringSnapshot(id))
|
||||||
|
} catch (err) {
|
||||||
|
setSteeringSnapshotError(err.message)
|
||||||
|
} finally {
|
||||||
|
setMethodBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCreateAction(payload) {
|
async function handleCreateAction(payload) {
|
||||||
setFormBusy(true)
|
setFormBusy(true)
|
||||||
try {
|
try {
|
||||||
|
|
@ -498,6 +516,10 @@ export function InitiativeDetailPage() {
|
||||||
snapshot={steeringSnapshot}
|
snapshot={steeringSnapshot}
|
||||||
loading={steeringSnapshotLoading}
|
loading={steeringSnapshotLoading}
|
||||||
error={steeringSnapshotError}
|
error={steeringSnapshotError}
|
||||||
|
methods={steeringMethods}
|
||||||
|
canManageMethod={capabilities.has('kairo.initiative.manage')}
|
||||||
|
onMethodChange={handleMethodChange}
|
||||||
|
methodBusy={methodBusy}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -629,6 +629,15 @@
|
||||||
margin: 0.75rem 0 0;
|
margin: 0.75rem 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.steering-method-row {
|
||||||
|
margin: 0.5rem 0 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steering-method-select {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.steering-phase-desc {
|
.steering-phase-desc {
|
||||||
margin: 0.5rem 0 0;
|
margin: 0.5rem 0 0;
|
||||||
color: var(--jk-text);
|
color: var(--jk-text);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user