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

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:
Lars 2026-07-05 17:24:17 +02:00
parent 95feba6204
commit fc956d4ceb
27 changed files with 684 additions and 15 deletions

View File

@ -241,6 +241,15 @@ Operating Model Integration (AP0.10, Schema weiter `008`):
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`
**Attention Regel 10:** `action_review_required` — Maßnahme ohne geplantes Review

View File

@ -320,6 +320,7 @@ def get_initiative_steering_snapshot(
"lifecycle_state": steering["lifecycle_state"],
"lifecycle_label": steering["lifecycle_label"],
"method_key": steering["method_key"],
"method_label": steering.get("method_label"),
"method_version": steering["method_version"],
"operating_phase": phase,
"operating_phase_deprecated": True,

View File

@ -71,6 +71,7 @@ from routers import ( # noqa: E402
prompts,
recurring,
reviews,
steering,
workspace,
)
@ -88,6 +89,7 @@ app.include_router(evidence.router)
app.include_router(decisions.router)
app.include_router(reviews.router)
app.include_router(recurring.router)
app.include_router(steering.router)
app.include_router(actors.router)
app.include_router(workspace.router)

View 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)

View File

@ -194,6 +194,57 @@ def update_lifecycle_state(
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(
*,
tenant_id: str,

View File

@ -3,13 +3,16 @@
from __future__ import annotations
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:
"""Register built-in hooks and methods (idempotent)."""
"""Register built-in hooks, methods and strategies (idempotent)."""
register_builtin_hooks()
register_builtin_strategies()
generic_operating.register()
product_milestone_driven.register()
bootstrap_steering()

View File

@ -6,6 +6,7 @@ from typing import Any, Optional
from services import steering_context as sc_service
from steering.lifecycle.states import lifecycle_label
from steering.methods.registry import get_method
from tenant_context import TenantContext
@ -20,9 +21,11 @@ def get_steering_context_dto(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
)
method = get_method(row["method_key"])
return {
**row,
"lifecycle_label": lifecycle_label(row["lifecycle_state"]),
"method_label": method.label if method else row["method_key"],
}

View File

@ -1,4 +1,4 @@
"""Built-in method: generic_operating — AP1.0."""
"""Built-in method: generic_operating."""
from __future__ import annotations
@ -13,7 +13,9 @@ def register() -> None:
MethodDefinition(
key="generic_operating",
version="0.1.0",
label="Allgemeine Steuerung",
description="Standard-Lifecycle ohne methodenspezifische Spezialisierung",
default_lifecycle_steps=STANDARD_LIFECYCLE_STEPS,
next_action_strategy_key="default",
)
)

View File

@ -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",
)
)

View File

@ -1,4 +1,4 @@
"""Method Registry — built-in steering methods (AP1.0)."""
"""Method Registry — built-in steering methods."""
from __future__ import annotations
@ -11,8 +11,10 @@ _METHODS: dict[str, "MethodDefinition"] = {}
class MethodDefinition:
key: str
version: str
label: str
description: str
default_lifecycle_steps: tuple[str, ...]
next_action_strategy_key: str = "default"
def register_method(defn: MethodDefinition) -> None:

View File

@ -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 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.strategies.next_action.default_strategy import default_strategy
from steering.strategies.next_action.registry import get_next_action_strategy
from tenant_context import TenantContext
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(
ctx: TenantContext,
kind: SignalKind = "attention",
@ -19,8 +34,11 @@ def evaluate(
) -> list[dict[str, Any]]:
if kind == "attention":
return default_rules.get_attention_items(ctx)
if initiative_id:
return default_rules.get_next_action_candidates_for_initiative(
ctx, initiative_id=initiative_id, limit=limit
method_key = _resolve_method_key(ctx, initiative_id)
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)

View File

View 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)

View 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()

View File

@ -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()

View 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()

View 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"

View File

@ -1,3 +1,3 @@
APP_VERSION = "0.11.0-ap1.0"
APP_VERSION = "0.11.1-ap1.1"
DB_SCHEMA_VERSION = "009"
APP_NAME = "jinkendo-kairo"

View File

@ -51,8 +51,8 @@ Die Reihenfolge „Inventar → mehr UI → irgendwann Core“ hat **lose Steuer
AP0.10b Steering UI Minimum ✓ erledigt
Scope Lock aktiv ← keine neuen Heuristiken / kein AP0.10c
AP1.0 Steering Foundation Skeleton ← NÄCHSTES Code-Paket
AP0.10d MVP Validation (5 Vorhaben) ← parallel / unmittelbar nach AP1.0
AP1.1 Method Registry + 2. Methode
AP0.10d MVP Validation (Vorlage bereit) ← Product Owner ausfüllen
AP1.1 Method Registry + product_milestone ✓ erledigt
AP1.2 Signals konsolidieren; operating_phase entfernen
AP1.3 operating_transitions → Hook Orchestrator
AP0.10c Initiative Flow UI ← erst nach AP1.2 (lifecycle-aware)

View 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.*

View 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.*

View File

@ -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

View File

@ -1,6 +1,6 @@
{
"name": "kairo-jinkendo-frontend",
"version": "0.11.0-ap1.0",
"version": "0.11.1-ap1.1",
"private": true,
"type": "module",
"scripts": {

View 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 }),
})
}

View File

@ -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) {
return (
<section className="card steering-snapshot">
@ -42,6 +50,8 @@ export function SteeringSnapshotPanel({ snapshot, loading, error }) {
const {
lifecycle_state,
lifecycle_label,
method_key,
method_label,
operating_phase,
operating_phase_deprecated,
phase_signals,
@ -66,6 +76,29 @@ export function SteeringSnapshotPanel({ snapshot, loading, error }) {
<span className="badge status-badge status-active">{displayLabel}</span>
</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>}
{phase_signals?.length > 0 && (

View File

@ -62,6 +62,7 @@ import { DecisionsSection } from '../components/DecisionsSection.jsx'
import { ReviewsSection } from '../components/ReviewsSection.jsx'
import { RecurringSection } from '../components/RecurringSection.jsx'
import { SteeringSnapshotPanel } from '../components/SteeringSnapshotPanel.jsx'
import { listSteeringMethods, updateInitiativeSteeringMethod } from '../api/steering.js'
import { EmptyState } from '../components/EmptyState.jsx'
import { ErrorState } from '../components/ErrorState.jsx'
import { LoadingState } from '../components/LoadingState.jsx'
@ -93,6 +94,8 @@ export function InitiativeDetailPage() {
const [steeringSnapshot, setSteeringSnapshot] = useState(null)
const [steeringSnapshotLoading, setSteeringSnapshotLoading] = useState(false)
const [steeringSnapshotError, setSteeringSnapshotError] = useState(null)
const [steeringMethods, setSteeringMethods] = useState([])
const [methodBusy, setMethodBusy] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [showActionForm, setShowActionForm] = useState(false)
@ -124,6 +127,9 @@ export function InitiativeDetailPage() {
loads.push(listInitiativeDecisions(id).then(setDecisions).catch(() => setDecisions([])))
loads.push(listInitiativeReviews(id).then(setReviews).catch(() => setReviews([])))
loads.push(listInitiativeRecurring(id).then(setRecurringItems).catch(() => setRecurringItems([])))
loads.push(
listSteeringMethods().then(setSteeringMethods).catch(() => setSteeringMethods([]))
)
loads.push(
(async () => {
setSteeringSnapshotLoading(true)
@ -159,6 +165,18 @@ export function InitiativeDetailPage() {
(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) {
setFormBusy(true)
try {
@ -498,6 +516,10 @@ export function InitiativeDetailPage() {
snapshot={steeringSnapshot}
loading={steeringSnapshotLoading}
error={steeringSnapshotError}
methods={steeringMethods}
canManageMethod={capabilities.has('kairo.initiative.manage')}
onMethodChange={handleMethodChange}
methodBusy={methodBusy}
/>
)}

View File

@ -629,6 +629,15 @@
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 {
margin: 0.5rem 0 0;
color: var(--jk-text);