diff --git a/README.md b/README.md index f055168..3107225 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/data_layer/initiative_snapshot.py b/backend/data_layer/initiative_snapshot.py index 9ddd1ac..84bf4fe 100644 --- a/backend/data_layer/initiative_snapshot.py +++ b/backend/data_layer/initiative_snapshot.py @@ -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, diff --git a/backend/main.py b/backend/main.py index cdf4bc0..e3a1a66 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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) diff --git a/backend/routers/steering.py b/backend/routers/steering.py new file mode 100644 index 0000000..e8d86a4 --- /dev/null +++ b/backend/routers/steering.py @@ -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) diff --git a/backend/services/steering_context.py b/backend/services/steering_context.py index 7b0fd91..74d48f5 100644 --- a/backend/services/steering_context.py +++ b/backend/services/steering_context.py @@ -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, diff --git a/backend/steering/__init__.py b/backend/steering/__init__.py index 9368807..b389bb2 100644 --- a/backend/steering/__init__.py +++ b/backend/steering/__init__.py @@ -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() diff --git a/backend/steering/context.py b/backend/steering/context.py index 23600eb..23d10a2 100644 --- a/backend/steering/context.py +++ b/backend/steering/context.py @@ -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"], } diff --git a/backend/steering/methods/registrations/generic_operating.py b/backend/steering/methods/registrations/generic_operating.py index ef10683..741bc7d 100644 --- a/backend/steering/methods/registrations/generic_operating.py +++ b/backend/steering/methods/registrations/generic_operating.py @@ -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", ) ) diff --git a/backend/steering/methods/registrations/product_milestone_driven.py b/backend/steering/methods/registrations/product_milestone_driven.py new file mode 100644 index 0000000..a924385 --- /dev/null +++ b/backend/steering/methods/registrations/product_milestone_driven.py @@ -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", + ) + ) diff --git a/backend/steering/methods/registry.py b/backend/steering/methods/registry.py index eb13691..a424550 100644 --- a/backend/steering/methods/registry.py +++ b/backend/steering/methods/registry.py @@ -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: diff --git a/backend/steering/signals/engine.py b/backend/steering/signals/engine.py index 8046e96..a96db70 100644 --- a/backend/steering/signals/engine.py +++ b/backend/steering/signals/engine.py @@ -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 - ) - return default_rules.get_next_action_candidates(ctx, 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 + ) + strategy = get_next_action_strategy(strategy_key) or default_strategy + return strategy.evaluate(ctx, initiative_id=initiative_id, limit=limit) diff --git a/backend/steering/strategies/__init__.py b/backend/steering/strategies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/steering/strategies/next_action/__init__.py b/backend/steering/strategies/next_action/__init__.py new file mode 100644 index 0000000..259ac00 --- /dev/null +++ b/backend/steering/strategies/next_action/__init__.py @@ -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) diff --git a/backend/steering/strategies/next_action/default_strategy.py b/backend/steering/strategies/next_action/default_strategy.py new file mode 100644 index 0000000..ced6069 --- /dev/null +++ b/backend/steering/strategies/next_action/default_strategy.py @@ -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() diff --git a/backend/steering/strategies/next_action/product_milestone_driven.py b/backend/steering/strategies/next_action/product_milestone_driven.py new file mode 100644 index 0000000..53cc3d2 --- /dev/null +++ b/backend/steering/strategies/next_action/product_milestone_driven.py @@ -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() diff --git a/backend/steering/strategies/next_action/registry.py b/backend/steering/strategies/next_action/registry.py new file mode 100644 index 0000000..f3c0de5 --- /dev/null +++ b/backend/steering/strategies/next_action/registry.py @@ -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() diff --git a/backend/tests/test_ap1_1_methods.py b/backend/tests/test_ap1_1_methods.py new file mode 100644 index 0000000..b2dda8c --- /dev/null +++ b/backend/tests/test_ap1_1_methods.py @@ -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" diff --git a/backend/version.py b/backend/version.py index 4dabacb..f7db55d 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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" diff --git a/docs/product/Kairo_MVP_Usability_Recovery_Plan_v0.1.md b/docs/product/Kairo_MVP_Usability_Recovery_Plan_v0.1.md index 2291a6d..2c4c7c3 100644 --- a/docs/product/Kairo_MVP_Usability_Recovery_Plan_v0.1.md +++ b/docs/product/Kairo_MVP_Usability_Recovery_Plan_v0.1.md @@ -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) diff --git a/docs/sprints/Sprint0_AP0_10_Validation_Report_v0.1.md b/docs/sprints/Sprint0_AP0_10_Validation_Report_v0.1.md new file mode 100644 index 0000000..7e008da --- /dev/null +++ b/docs/sprints/Sprint0_AP0_10_Validation_Report_v0.1.md @@ -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.* diff --git a/docs/sprints/Sprint0_AP0_10d_Validation_Assignment_v0.1.md b/docs/sprints/Sprint0_AP0_10d_Validation_Assignment_v0.1.md new file mode 100644 index 0000000..ffe42cc --- /dev/null +++ b/docs/sprints/Sprint0_AP0_10d_Validation_Assignment_v0.1.md @@ -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.* diff --git a/docs/sprints/Sprint1_AP1_1_Method_Registry_Assignment_v0.1.md b/docs/sprints/Sprint1_AP1_1_Method_Registry_Assignment_v0.1.md new file mode 100644 index 0000000..97d8731 --- /dev/null +++ b/docs/sprints/Sprint1_AP1_1_Method_Registry_Assignment_v0.1.md @@ -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 diff --git a/frontend/package.json b/frontend/package.json index d9798bc..d052422 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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": { diff --git a/frontend/src/api/steering.js b/frontend/src/api/steering.js new file mode 100644 index 0000000..f505012 --- /dev/null +++ b/frontend/src/api/steering.js @@ -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 }), + }) +} diff --git a/frontend/src/components/SteeringSnapshotPanel.jsx b/frontend/src/components/SteeringSnapshotPanel.jsx index 899285d..a46c46f 100644 --- a/frontend/src/components/SteeringSnapshotPanel.jsx +++ b/frontend/src/components/SteeringSnapshotPanel.jsx @@ -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 (
@@ -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 }) { {displayLabel} + {(method_label || canManageMethod) && ( +

+ Methode: + {canManageMethod && methods.length > 0 ? ( + + ) : ( + {method_label || method_key} + )} +

+ )} + {phaseDesc &&

{phaseDesc}

} {phase_signals?.length > 0 && ( diff --git a/frontend/src/pages/InitiativeDetailPage.jsx b/frontend/src/pages/InitiativeDetailPage.jsx index 23fd7cd..6d66027 100644 --- a/frontend/src/pages/InitiativeDetailPage.jsx +++ b/frontend/src/pages/InitiativeDetailPage.jsx @@ -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} /> )} diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index 59dbaf0..53aa74d 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -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);