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`**
|
||||
|
||||
**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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
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
|
||||
|
||||
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
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"
|
||||
APP_NAME = "jinkendo-kairo"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
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",
|
||||
"version": "0.11.0-ap1.0",
|
||||
"version": "0.11.1-ap1.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"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) {
|
||||
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 && (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user