Compare commits

...

157 Commits

Author SHA1 Message Date
Lars
a05bd34af3 feat(A1): Übungen als AP mit Wochentagsplan
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 5m3s
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 19s
Test Suite / playwright-smoke (push) Successful in 13s
Gate- und Kit-Übungen legen ein Arbeitspaket plus Schedule an; Today zeigt nur fällige Wochentage, nicht die ganze Gate-Liste.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 18:06:41 +02:00
ea0aa18b8d feat(schedule): einheitliche Rhythmus-Schicht an Gate und Arbeitspaket
All checks were successful
Deploy Development / deploy (push) Successful in 49s
Test Suite / pytest-backend (push) Successful in 4m57s
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
Bestehende Recurring-Pfade bleiben kompatibel (interval_days-Fallback, Migration backfillt schedules), damit der aktuelle Betrieb nicht bricht und der Stand auf einem zweiten Rechner weitergebaut werden kann.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 09:23:00 +02:00
87ecee5301 feat(A1): Activity Set am Gate-Detail — Übungen an Zielzustand binden
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Test Suite / pytest-backend (push) Successful in 4m54s
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 19s
Test Suite / playwright-smoke (push) Successful in 13s
Gate-Detail: Übungen anlegen mit Rhythmus, Gate aktivieren, Link zu Today. API: /roadmap-items/{id}/practices und roadmap_item_id auf Recurring-Create. Damit ist A1 ohne Starter-Kit manuell durchspielbar.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 19:36:50 +02:00
17add13d9d refactor(A1): Progression-Lanes aus Graph statt Strang=Project
All checks were successful
Deploy Development / deploy (push) Successful in 53s
Test Suite / pytest-backend (push) Successful in 4m52s
Test Suite / lint-backend (push) Successful in 3s
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 31s
Ersetzt strand_project_id/Stream-Projects in der A1-Steuerung durch graph-abgeleitete Lanes (requires-Ketten + Join). Recurring bindet nur noch am Gate; Re-Abnahme-Docs und ADP dokumentieren die Korrektur.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 18:51:17 +02:00
88a03a65f2 fix(A1): Join-Ausgaenge nach Schalten aktivieren
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 4m52s
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 19s
Test Suite / playwright-smoke (push) Successful in 13s
Nach Join-reached item_by_id aktualisieren, damit _graph_ready fuer Ausgangs-Gates nicht mit veraltetem Join-Status scheitert.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 08:54:37 +02:00
cb165cd821 fix(A1): pytest Progression — legacy Stufen aus Join-Graph ausschließen
Some checks failed
Deploy Development / deploy (push) Successful in 49s
Test Suite / pytest-backend (push) Failing after 5m1s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Retire Starter-Kit-Stufen ohne Strang beim Progression-Demo; active_work_gates zählt nur stranggebundene Gates wenn Join existiert. E2E-Test an maturity_transition-Struktur angepasst.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 08:41:28 +02:00
15f87eada2 feat(plan): Gate-Designer UX — Typ ändern, Löschen, Kanten, Status
Some checks failed
Test Suite / lint-backend (push) Waiting to run
Test Suite / compose-smoke (push) Waiting to run
Test Suite / k6 /api/health Baseline (push) Blocked by required conditions
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Has been cancelled
Macht den Zielzustands-Designer bedienbar: Overlay-Panel statt schmalem Split, Gate-Typ im Edit, Löschen mit Schutz für erreichte Gates, Kantentyp-Wechsel mit Legende und visuelle Kennzeichnung aktiv/blockiert/abgeschlossen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 08:39:15 +02:00
b80f52cb50 feat(plan): Join-Gate im Gate-Designer für alle Archetypen
Some checks failed
Deploy Development / deploy (push) Successful in 48s
Test Suite / pytest-backend (push) Failing after 4m44s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
Typ Join/Schaltknoten anlegbar, Detail ohne Verify, visuelle Kennzeichnung im Graph.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 08:17:39 +02:00
3a0ef3a734 feat(A1): Progression Join-Graph und Multi-Strang-Horizon
Some checks failed
Deploy Development / deploy (push) Successful in 52s
Test Suite / pytest-backend (push) Failing after 5m3s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
join_gate, strand_project_id, Join-Auto-Schalten, multi-active Gates, Progression-Demo und PO-Lock-Spec. FE: Today strang-gruppiert, Join-Warteliste in Kontrolle.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 16:28:08 +02:00
192a65a798 fix(A1): enforce_single_active via roadmaps join (pytest)
All checks were successful
Deploy Development / deploy (push) Successful in 1m1s
Test Suite / pytest-backend (push) Successful in 4m43s
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 16s
roadmap_items hat kein initiative_id — Single-Active-Update join auf roadmaps. test_ap20e an Uebungssets angepasst.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 13:46:54 +02:00
19e962d8ec fix(A1): Übungen an Stufen binden, Gates entkoppeln, Tages-Abschluss
Some checks failed
Deploy Development / deploy (push) Successful in 49s
Test Suite / pytest-backend (push) Failing after 4m46s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Activity Set pro Stufe statt Gate-Titel-Recurring; Single-Active-Stufe bei Reopen; Complete heute schliesst Next Action; Heute-Panel fuer alle Uebungen der aktiven Stufe.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 13:00:06 +02:00
b7d88ce24a feat(A1): Minimum Usability Slice — CadenceInstance, Today-Übung, Gate-Fortschritt
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 4m54s
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 13s
PO No-Go A1 adressieren: Tages-Tracking mit Messnotiz, Work-UI für Übung, Next-Action-Link, Gate-Fortschritt in Kontrolle, Eingang aus A1-Nav entfernt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 12:32:23 +02:00
0baa5952bc fix(AP1.7b): Audit user_id NULL bei Service-Token statt leerer UUID
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 4m38s
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 13s
Operational convert-backlog und patch-recurring nutzen ctx.user_id or None;
log_audit normalisiert leere Strings fuer audit_log.user_id.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 09:33:35 +02:00
903304adf1 fix(AP1.7b): recurring-Capabilities fuer Service Tokens erlauben
Some checks failed
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Failing after 4m41s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
kairo.recurring.read/manage fehlten in ALLOWED_TOKEN_CAPABILITIES —
AP17b-Operational-Tests konnten Agent-Token nicht anlegen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 09:26:07 +02:00
be0090d513 docs(AP2.1): MVP Validation Assignment und Report v0.3
Some checks failed
Deploy Development / deploy (push) Successful in 48s
Test Suite / pytest-backend (push) Failing after 4m39s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
Bereitet PO-Abnahme Stufe A vor: Smoke-Checklisten fuer A1, A2, B2b+B3,
Go-Kriterien und Truth-Table/Execution-Plan auf Phase 5 aktualisiert.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 09:19:37 +02:00
69c516dba4 feat(AP1.7b): Op-API fuer Backlog-Commit, Recurring-Status und Sprint-Zuweisung
Some checks failed
Deploy Development / deploy (push) Successful in 52s
Test Suite / pytest-backend (push) Failing after 4m37s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 18:06:46 +02:00
252b3f05f2 feat(AP1.9d, AP2.2d): Work-Default-UI, Sprint-Planung und Composition Work-Slots
Some checks failed
Test Suite / lint-backend (push) Waiting to run
Test Suite / compose-smoke (push) Waiting to run
Test Suite / k6 /api/health Baseline (push) Blocked by required conditions
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 18:05:15 +02:00
89ae15ca4c chore(seeds): Dogfooding-Seed explizit dev-only (.dev.py) und MIGRATIONS-Doku
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 18:04:52 +02:00
84d912b8ba docs(AP-BP-0): Vorhaben-Template/Blueprint Konzeption und Scope Lock
All checks were successful
Deploy Development / deploy (push) Successful in 51s
Test Suite / pytest-backend (push) Successful in 4m32s
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 19s
Test Suite / playwright-smoke (push) Successful in 13s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 15:59:28 +02:00
02b6469c2d feat(AP2.2c): A1 Reifegrad E2E, Stufen-/Rhythmus-Panels und Composition-Kernel
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 15:59:18 +02:00
43d33d0edb feat(AP2.2b, UX): Linear E2E, Composition-Kernel und Steering-Paritaet
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 4m24s
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
AP2.2b: Integrationstests Neue-Kueche-Happy-Path, Truth Table und Execution Plan abgeschlossen. UX Composition Kernel (Control/Plan/Cockpit-Slots), CriticalPathPanel aus Kernel-Read-Model mit Gate-Horizont, Next Action elementgesteuert ohne Snapshot-Doppelung, Product-Continuous-Copy und Plan/Arbeit ueber Kernel planning_debt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 13:34:13 +02:00
b6434bcc50 feat(steering): K-Ext-5 Portfolio-Kernel-Aggregation ueber Initiativen
All checks were successful
Deploy Development / deploy (push) Successful in 48s
Test Suite / pytest-backend (push) Successful in 4m14s
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
GET /api/workspace/steering; Portfolio-Widget nutzt Kernel-Attention, Vorschlaege und Agent-Slots.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 13:13:36 +02:00
390161e224 test: Assertions auf Kernel v0.4 und tech_debt-Vokabular anpassen
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 4m15s
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 19s
Test Suite / playwright-smoke (push) Successful in 20s
Behebt 6 CI-Failures nach Agent-Slots/Tech-Debt-Release.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 13:06:20 +02:00
a5154cbd3b feat(steering): Kernel v0.4 Agent-Slots, Tech Debt und Review-Attention
Some checks failed
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Failing after 4m15s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
K-Ext-4/P8 deklarative Agent-Slots; P7 tech_debt Read Model und Vokabular; Review-Attention in den Kernel verlagert.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 11:54:48 +02:00
b747200f3a feat(steering): K-Ext-2 Attention-Registry, Gate/Intake-Proposals, generische UI
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 4m13s
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 19s
Test Suite / playwright-smoke (push) Successful in 12s
Methodenuebergreifende Provider auf Kernel v0.3; verbindliche Coding Rules fuer Agenten in ADP und .cursor/rules.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 11:48:10 +02:00
1bbc5dbfce refactor(steering): Sprint-Vorschlaege mit austauschbarem Ranker und Abhaengigkeiten
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 4m8s
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 19s
Test Suite / playwright-smoke (push) Successful in 13s
Keine feste Bug-zuerst-Policy: heuristic_v0 als Fallback, agent_v1-Slot vorbereitet, parent_action-Abhaengigkeiten und factors[] fuer spaetere KI-Steuerung.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 11:38:22 +02:00
20636f88d2 feat(steering): Kernel v0.3 mit Read-Model- und Proposal-Registry (K-Ext-1, P6)
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 4m11s
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 15s
Erweitert evaluate_steering um read_models und proposals, migriert epic_rollup auf Registry, liefert sprint_commit-Vorschlaege und UI auf Plan-Sprint.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 11:27:06 +02:00
c5e2c69b1e docs: Steering Kernel Extension Model fuer Read Models, Proposals und Agent-Slots
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 4m13s
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
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 11:14:19 +02:00
ef5ee0d19b feat(steering): Epic Roll-up Read Model und Attention (ADP P4)
Some checks failed
Test Suite / pytest-backend (push) Waiting to run
Test Suite / lint-backend (push) Waiting to run
Test Suite / compose-smoke (push) Waiting to run
Test Suite / k6 /api/health Baseline (push) Blocked by required conditions
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Has been cancelled
Schliesst den Backlog/Epic-Track mit kernel-basiertem Fortschritt pro Epic, Steuerungs-Attention und UI-Fortschrittsanzeige im Plan-Eingang ab.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 11:13:47 +02:00
a13913f1a0 feat(ui): Geführter Eingang und Steuerungskurzinfo auf Plan-Profil
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 4m0s
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 18s
BacklogIntakeGuide und PlanSteeringSummary leiten aus Operating Context — keine Archetyp-Ifs, klarere Commit-Pfade A/B.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 10:57:49 +02:00
a3b14d9c00 feat(steering): Backlog-Vokabular aus Methoden-Vertrag (ADP P3)
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 3m56s
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 19s
Test Suite / playwright-smoke (push) Successful in 13s
backlog_epic_hierarchy als Steuerungselement, effektive steering_elements im Operating Context, Backend-Guards und UI ohne Slice-Ifs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 10:44:38 +02:00
f33135b87f feat(backlog): Epic-Hierarchie mit parent_backlog_id (ADP P2)
All checks were successful
Deploy Development / deploy (push) Successful in 48s
Test Suite / pytest-backend (push) Successful in 3m54s
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 19s
Test Suite / playwright-smoke (push) Successful in 15s
Epics als Backlog-Container mit Baum-UI, Convert-Guard und pytest-Abdeckung — Roll-up bleibt für P4 offen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 09:22:20 +02:00
dc6f25b673 feat(product): Plan-Arbeit fuer Direct-AP-Pfad (ADP P1)
All checks were successful
Deploy Development / deploy (push) Successful in 49s
Test Suite / pytest-backend (push) Successful in 3m45s
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 19s
Test Suite / playwright-smoke (push) Successful in 12s
Product-Outline enthaelt work; planOutlineKeys-Reihenfolge steuert Sidebar; Product-spezifischer Lead auf Plan-Arbeit.
2026-07-26 18:25:12 +02:00
f7d65dde01 docs: Backlog-Hierarchie, Dual Commit Path und Epic-Zielbild
ADP v0.1 plus Updates in B2b-Spec, agile_iteration, Katalog und Truth Table.
2026-07-26 18:25:12 +02:00
1c10e5b798 fix(tests): omCapabilities nach UI-Profil-Merge normalisieren
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 3m45s
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 19s
Test Suite / playwright-smoke (push) Successful in 13s
test_resolve_ui_profile_merges_seed_slices_when_db_partial erwartet omCapabilities aus dataSlices wenn DB-Profil unvollstaendig ist.
2026-07-26 18:00:22 +02:00
97fab2c76d docs: Archetyp-gesteuerte UI-Navigation verankern
Some checks failed
Deploy Development / deploy (push) Successful in 50s
Test Suite / pytest-backend (push) Failing after 3m50s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
ARCHETYPE_UI_NAVIGATION_v0.1 plus Verweise in Spec-Template, README und Programm-Dokument.
2026-07-26 16:52:48 +02:00
11b7128204 fix(ui): Plan-Navigation nicht mehr durch leere data_slices blockieren
Some checks failed
Test Suite / lint-backend (push) Waiting to run
Test Suite / compose-smoke (push) Waiting to run
Test Suite / k6 /api/health Baseline (push) Blocked by required conditions
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Has been cancelled
Archetyp-UI-Profil und Methoden-Schnittmenge werden korrekt gemerged; Frontend faellt bei leerem data_slices auf ui_profile.dataSlices zurueck, damit Eingang/Sprint/Gates erreichbar bleiben.
2026-07-26 16:49:02 +02:00
fe1edb84f8 feat(steering): Kernel Spine v0.2 — Events, Slot-Provider, continuous_product
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Test Suite / pytest-backend (push) Successful in 3m49s
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 19s
Test Suite / playwright-smoke (push) Successful in 12s
apply_steering_event an Action done, Gate verify/reopen und Blocker resolve; lifecycle-Ketten methodenaware via slot_map; continuous_product slot_map; pytest v0.2.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 16:33:00 +02:00
606dd960b8 fix(steering): Gate-Horizont ignoriert aktive work_cycles
Some checks failed
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 3m37s
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 19s
Test Suite / playwright-smoke (push) Has been cancelled
first_active_gate_id schloss Sprint-Zeitboxen mit status=active ein und leerte Agile-Next bei Gate-Schnittmenge.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 16:22:44 +02:00
0443b734c1 fix(tests): pytest nach Kernel Spine und Starter-Kit bereinigen
Some checks failed
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Failing after 3m33s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
list_compatible_methods nur Primary-Methoden; Agile-Graph auf Sprint-Scope; Tests ohne Starter-Action-Kollision; TenantContext-Factory fuer Kernel-Tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 16:15:32 +02:00
75a6a63665 feat(steering): Kernel Spine v0.1.1 als zentraler Initiative-Einstieg
Some checks failed
Deploy Development / deploy (push) Successful in 48s
Test Suite / pytest-backend (push) Failing after 3m33s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
evaluate_steering() vereinheitlicht Horizon, Next Work und Attention; Snapshot und Signal Engine delegieren dorthin; slot_map deklarativ fuer sequential_dependency und generic_operating.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 16:08:58 +02:00
753f178b0c docs: Archetyp-Vollspecs und Methodenkern vorlaeufig freigeben.
Some checks failed
Deploy Development / deploy (push) Successful in 48s
Test Suite / pytest-backend (push) Failing after 3m23s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
Decision-Lock, Spec-D-Methoden und C1-Massstab-Vollspecs als Zielbild; Steering-Registry/Compat und Horizon-Tests an die Normierung anbinden. Implementierungsausreichendheit bleibt bewusst offen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 15:02:04 +02:00
005b371ac0 docs: AP2.3/AP2.4 Plugin-Architektur verbindlich dokumentieren.
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 3m15s
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 19s
Test Suite / playwright-smoke (push) Successful in 12s
Guardrails, Truth Table und ADPs auf gelieferten Stand bringen; Spezifikationsprogramm und Archetyp-Template um steering_elements ergänzen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 11:48:13 +02:00
4fbb3b3469 fix(AP2.4): product_milestone_driven ohne work_cycles ? generic-kompatibel.
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 3m13s
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 13s
om_capabilities-Pruefung blockierte Methodenwechsel auf initiative.generic; Gate-Slices reichen fuer Meilenstein-Steuerung.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 11:34:26 +02:00
d3bf3c36e0 AP2.4: Steuerungselement-Registry und Methoden-Vertrag.
Some checks failed
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Failing after 3m17s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
MethodDefinition traegt steering_elements/ui_features/graph_profile; operating-context und FE-Registry ohne Archetyp-Ifs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 11:27:59 +02:00
ac475ab92c fix(test): AP2.3f dispute_case-Profil in operating-context-Tests abbilden.
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 3m10s
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 19s
Test Suite / playwright-smoke (push) Successful in 13s
Generic-Fallback testet initiative.generic; dispute_case erwartet explizite processSteps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 10:58:28 +02:00
c79bfa844f AP2.3f: Restschuld ? uiFeatures, Route-Registry und slice-bewusste Mutationen.
Some checks failed
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Failing after 3m9s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
Archetyp-Profile und Seitenlogik auf Operating Context migriert; methodUiDefaults nur noch Fallback.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 10:52:12 +02:00
44fda7beb9 fix(AP2.3e): product_milestone_driven auch fuer generic/linear/program — pytest AP1.1/AP14.
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 3m10s
Test Suite / lint-backend (push) Successful in 7s
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 24s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 10:34:02 +02:00
38fed379ae AP2.3e: Method Plugin Contract mit data_slices und Archetyp-Kompatibilitaetspruefung.
Some checks failed
Deploy Development / deploy (push) Successful in 49s
Test Suite / pytest-backend (push) Failing after 3m12s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 10:27:40 +02:00
11e36e674e AP2.3d: Route Gating via OperatingRouteGate und Slice-basierte Seitenlogik.
Some checks failed
Test Suite / pytest-backend (push) Waiting to run
Test Suite / lint-backend (push) Waiting to run
Test Suite / compose-smoke (push) Waiting to run
Test Suite / k6 /api/health Baseline (push) Blocked by required conditions
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 10:27:20 +02:00
1b55d8558c AP2.3c: Slice-bewusstes Laden und hoisted InitiativeOperationsProvider.
Some checks failed
Test Suite / pytest-backend (push) Waiting to run
Test Suite / lint-backend (push) Waiting to run
Test Suite / compose-smoke (push) Waiting to run
Test Suite / k6 /api/health Baseline (push) Blocked by required conditions
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 10:26:56 +02:00
90934453e6 AP2.3b: Frontend Profile Resolver — Operating Context statt methodUiDefaults.
Some checks failed
Test Suite / pytest-backend (push) Waiting to run
Test Suite / lint-backend (push) Waiting to run
Test Suite / compose-smoke (push) Waiting to run
Test Suite / k6 /api/health Baseline (push) Blocked by required conditions
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 10:26:37 +02:00
4ad908bfbf AP2.3a: Operating Context API mit ui_profile_json und Archetyp-Sync.
Some checks failed
Test Suite / pytest-backend (push) Waiting to run
Test Suite / lint-backend (push) Waiting to run
Test Suite / compose-smoke (push) Waiting to run
Test Suite / k6 /api/health Baseline (push) Blocked by required conditions
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-25 10:26:17 +02:00
ad6740ffa8 ADP: Archetyp- und Methoden-Plugin-Architektur v0.1.
All checks were successful
Deploy Development / deploy (push) Successful in 49s
Test Suite / pytest-backend (push) Successful in 2m57s
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 13s
Leitplan vor weiteren Archetypen — Operating Context, UI/Data-Slices, Registry-Erweiterung ohne Core-Änderungen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 18:05:11 +02:00
ee1e1c0671 fix: Sprint-AP Bearbeiten, Sprint-Verschiebung und klarere Aktionsleiste.
Some checks failed
Test Suite / pytest-backend (push) Waiting to run
Test Suite / lint-backend (push) Waiting to run
Test Suite / compose-smoke (push) Waiting to run
Test Suite / k6 /api/health Baseline (push) Blocked by required conditions
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Has been cancelled
Behebt editingActionId-Bug in Plan/Work-Sprint; Schnellzuweisung AP zu anderem Sprint ohne Voll-Reload.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 18:04:26 +02:00
04d1dbf1f9 AP2.2f: Scrum Sprint-Review, Reaktivierung und Carryover beim Abschluss.
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 2m58s
Test Suite / lint-backend (push) Successful in 4s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 19s
Test Suite / playwright-smoke (push) Successful in 1m1s
Offene APs können beim Abschluss in den Eingang oder einen anderen Sprint überführt werden; abgeschlossene Sprints sind reaktivierbar.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-24 17:40:59 +02:00
e65838118c AP2.2e: Unplan, Bug/Issue-Referenz und Sprint-Lebenszyklus.
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 2m52s
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 13s
Ermöglicht APs zurück in den Eingang zu schieben, Bugs/Issues mit Feature-Bezug zu planen und den Scrum-Sprint-Ablauf in der UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 08:07:02 +02:00
e221b5b09f fix: Sprint-Planung einsehbar und Eingang ohne Voll-Reload.
All checks were successful
Deploy Development / deploy (push) Successful in 52s
Test Suite / pytest-backend (push) Successful in 2m46s
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
Geplante Sprints in Plan anwaehlbar, lokales Update beim Commit, Sprint-Auswahl bleibt erhalten.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 19:38:39 +02:00
c19b6296ed fix: Sprint-Zuordnung im Eingang sichtbarer machen.
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 2m52s
Test Suite / lint-backend (push) Successful in 3s
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 13s
Ziel-Sprint-Dropdown immer wenn Sprint existiert, Setup-Hinweis ohne Sprint, Planen fuer alle commit-faehigen Backlog-Status.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 19:30:36 +02:00
00567bb097 AP2.2b: Kritischer Pfad in Kontrolle für A2 Linear.
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 2m57s
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 20s
CriticalPathPanel aus Execution Graph, Next-Action-Begruendung und Integrationstest fuer sequential_dependency.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 19:26:08 +02:00
f6aba94864 AP2.2d: Sprint-Planung, Abschluss und Product-Fallback.
Some checks failed
Deploy Development / deploy (push) Successful in 48s
Test Suite / pytest-backend (push) Successful in 2m56s
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) Has been cancelled
Backlog committet in gewaehlten Sprint, Sprint complete-Endpoint, continuous_product-Ansicht ohne aktiven Sprint.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 19:21:49 +02:00
09826d146e AP1.9d: Methoden-Default-UI und Product Language für Product-Flow.
Some checks failed
Test Suite / lint-backend (push) Waiting to run
Test Suite / compose-smoke (push) Waiting to run
Test Suite / k6 /api/health Baseline (push) Blocked by required conditions
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Has been cancelled
Prozessleiste, archetypgebundene Modus-Defaults und Sprint/Arbeitspaket-Begriffe schließen den GUI-Kern für continuous_product vor Sprint-Planungs-Flows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 19:20:06 +02:00
82a5b9adec AP2.2d: B2b Product-Backlog und B3 Sprint-Backlog in UI.
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 2m40s
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 19s
Test Suite / playwright-smoke (push) Successful in 13s
Zeitbox-API im Frontend, Plan/Ausführen Sprint-Ansichten, Backlog-Convert in aktiven Sprint. agile_iteration bei aktiver Zeitbox für continuous_product.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 16:27:34 +02:00
0b57e2ec75 AP2.2b/c: A1-Stufenwechsel, Team-Fix, Agent-Anlage, Steuerungs-Hinweise.
AP2.0e rotiert Recurring bei maturity_stage reached. Maturity-Strategie priorisiert Routinen. Team zeigt Namen und Agent-Formular. Archetyp-Hints in Kontrolle und Stufe-A-Gruppierung bei Anlage.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 16:19:55 +02:00
2b0d5dd3d9 AP2.2a: Archetyp-Starter-Kits und MVP Execution Plan v0.2.
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 2m40s
Test Suite / lint-backend (push) Successful in 3s
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 13s
Referenz-Vorhaben A1/A2/B2b/B2a per UI anlegbar mit gefuehrter Starter-Struktur; Dogfooding-Seed deprecated. Plan-Korrektur: Execution Plan v0.2 statt Seed-Spiegel als Prioritaet.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 16:02:09 +02:00
a097a82e17 fix: Operational context nutzt korrekte Snapshot-Feldnamen.
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 2m30s
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 19s
Test Suite / playwright-smoke (push) Successful in 12s
initiative_title/initiative_status statt title/status — behebt test_operational_api_with_service_token.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 15:22:31 +02:00
b8945556ad fix: Dogfooding-Seed ueberspringt bereits erreichte Gates.
Some checks failed
Deploy Development / deploy (push) Successful in 50s
Test Suite / pytest-backend (push) Failing after 2m33s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
verify_reached wird bei Re-Deploy nicht erneut auf terminalen Gates aufgerufen — behebt unhealthy Backend beim Seed-Lauf.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 12:56:09 +02:00
d1bc725ab5 AP2.0f: work_cycle Zeitbox und actions.work_cycle_id.
Some checks failed
Deploy Development / deploy (push) Failing after 48s
Test Suite / pytest-backend (push) Failing after 1s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
RoadmapItem type work_cycle, Sprint-API, agile_iteration Next-Action-Strategie; Steering-Snapshot zeigt active_work_cycle.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 12:34:36 +02:00
5dc372c043 AP1.7: Operational API und Actor Service Tokens fuer Vibe-Coder.
Some checks failed
Deploy Development / deploy (push) Failing after 42s
Test Suite / pytest-backend (push) Failing after 1s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
Agenten authentifizieren per X-Actor-Token; /api/operational/ Fassade mit context, next-action, status, evidence; Token-Verwaltung unter /api/actors/{id}/service-tokens.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 12:21:16 +02:00
2359cc8b59 Dogfooding R2: Seed spiegelt AP1.9c, AP1.16 und AP2.0d als done.
Some checks failed
Deploy Development / deploy (push) Failing after 52s
Test Suite / pytest-backend (push) Failing after 1s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
seed_004 aktualisiert Actions/Evidence und offene naechste Schritte; Gate-Beschreibungen und Docs sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 12:06:04 +02:00
284b9d50fe AP2.0d: Next-Action-Strategien nutzen Execution-Graph ready_actions.
Some checks failed
Test Suite / lint-backend (push) Waiting to run
Test Suite / compose-smoke (push) Waiting to run
Test Suite / k6 /api/health Baseline (push) Blocked by required conditions
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Has been cancelled
Archetyp-Strategien priorisieren ausführungsbereite APs mit Begründung execution_ready/critical_path; continuous_product und program_delivery integriert.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 12:04:40 +02:00
b9bfa9e5bd AP1.16d + AP1.9c: Planning Debt in Attention und Cockpit-Signale.
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 2m18s
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 19s
Test Suite / playwright-smoke (push) Successful in 12s
Attention-Regeln planning_debt/execution_waiting; Portfolio-Kacheln zeigen Steuerungssignale.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 11:48:34 +02:00
8546c7021b AP1.16c: Durchführungsplan in Plan-Outline und Action-Detail.
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 2m19s
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 19s
Test Suite / playwright-smoke (push) Successful in 12s
Ready/blocked-Badges, Planning Debt, Vorgänger-Verwaltung — getrennt vom Gate-Designer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 11:25:55 +02:00
1166a0c7f1 AP1.16a-b: Execution-Graph für Arbeitspaket-Abhängigkeiten.
All checks were successful
Deploy Development / deploy (push) Successful in 48s
Test Suite / pytest-backend (push) Successful in 2m20s
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 19s
Test Suite / playwright-smoke (push) Successful in 14s
Migration 023, Engine, API und ADP für Durchführungsplan getrennt vom Gate-Graph; Docs und Sprint-Assignment synchronisiert.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-12 10:38:01 +02:00
a1b353fcee fix: Dogfooding-Seed nutzt list_roadmap_items_for_initiative.
All checks were successful
Deploy Development / deploy (push) Successful in 51s
Test Suite / pytest-backend (push) Successful in 2m27s
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 14s
Behebt Backend-Startup-Fehler beim Deploy durch falschen Import in seed_004.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 10:24:44 +02:00
a26b534c34 DOC: Status-Review, Truth Table Sync und Dogfooding R1 Seed.
Some checks failed
Deploy Development / deploy (push) Failing after 45s
Test Suite / pytest-backend (push) Failing after 1s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Review-Auswertung, aktualisierte Roadmap/Gap-Analyse und idempotenter Seed fuer Kairo-Jinkendo als Referenz-Vorhaben mit Gates, Actions und Gitea-Evidence.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 10:17:57 +02:00
b85a6ec0c7 AP1.6b: Journey um Gate-Fortschritt und Ist-Graph erweitern.
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 2m23s
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 13s
Fokus-Gates mit Kriterien-Checkliste und eingebetteter Graph-Sicht in Kontrolle; Abnahme-Reihenfolge in Roadmap dokumentiert.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 09:48:14 +02:00
a19c6abd26 AP1.8a: Portfolio-Rang im Cockpit mit Reorder und Next-Action-Sortierung.
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Test Suite / pytest-backend (push) Successful in 2m21s
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 21s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 09:39:49 +02:00
d7aefed0de AP1.15c: Join/Branch-Topologie mit parallel_group und optional_branch.
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Test Suite / pytest-backend (push) Successful in 2m18s
Test Suite / lint-backend (push) Successful in 3s
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 13s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 09:27:56 +02:00
364ad44759 AP1.15b: Designer UX mit Drag-Kanten, Gate anlegen und Info-Panel.
Some checks failed
Test Suite / lint-backend (push) Waiting to run
Test Suite / compose-smoke (push) Waiting to run
Test Suite / k6 /api/health Baseline (push) Blocked by required conditions
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 09:26:16 +02:00
0c2ca4a69d AP1.15a: Zielzustands-Designer mit Pan/Zoom und Kanten im Plan-Tab.
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 2m19s
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
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 08:28:15 +02:00
2843d8775a fix: graph-state Test beruecksichtigt Methodenprofil AP1.4e.
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 2m16s
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
generic_operating liefert would_block statt blocked; harte Blockade erst unter product_milestone_driven pruefen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 08:14:34 +02:00
064779e187 AP1.14: Plan-Snapshot und Plan/Ist-Overlay in Kontrolle.
Some checks failed
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Failing after 2m18s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
Revisionen mit Audit, getrennte Soll/Ist/Diff-Ansicht unter Kontrolle/Plan-Ist; API plan-ist-view und plan-snapshots.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 08:10:15 +02:00
1fa61b2dac AP1.4e: Methodenprofile am Gate-Graph.
Some checks failed
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Failing after 2m18s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
generic_operating ohne harte Blockade, Reifegrad-Methoden mit Erfuellungsgrad; Attention gate_graph_blocked fuer gate-orientierte Methoden.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 08:06:42 +02:00
4f8725e234 AP1.4d: Graph Engine mit blocked/ready Read Models.
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 2m17s
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 13s
Migration 020 fuer Kantenarten und Parallelgruppe; steering/graph berechnet Fortschritt; UI zeigt Bereit/Blockiert in Liste und Graph.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 07:59:18 +02:00
bd64340ca1 AP1.4c: Gate-Checkliste statt Freitext-DoD.
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 2m13s
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
Kriterien-Fortschritt in der Gate-Liste, Modal-UX fuer Checklisten und optionales Erst-Kriterium beim Anlegen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 07:53:38 +02:00
dbe6f4f684 AP1.10c: Profil-Modal mit Archetyp, EFS und Owner.
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 2m13s
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
InitiativeForm laedt Felder nach gewaehltem Archetyp; Plan-Profil zeigt Standardfelder und Zusatzfelder; actor_ref und Owner-Auswahl.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 07:48:00 +02:00
43d30944aa AP1.5d: Aufgaben-Baum in der Plan-Outline unter Arbeit.
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Test Suite / pytest-backend (push) Successful in 2m12s
Test Suite / lint-backend (push) Successful in 3s
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
Offene Tasks erscheinen unter dem Arbeitspaket (max. 3 Ebenen, Tiefe 2 eingeklappt); Links fuehren zur Action-Detailseite #tasks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 07:43:33 +02:00
075cff00a2 Docs: Zielzustands-Graph ist kein Workflow; Plan vs. Ist.
Some checks failed
Test Suite / lint-backend (push) Waiting to run
Test Suite / compose-smoke (push) Waiting to run
Test Suite / k6 /api/health Baseline (push) Blocked by required conditions
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Has been cancelled
Plan-Mode-Design und ADP praezisieren Graph-Semantik, MVP-Zwischenstand und vorgeschlagene Pakete AP1.14/15 fuer Plan-Ist-Parallelitaet und Modellierungswerkzeug.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 07:42:14 +02:00
7cd8f4b90e AP1.13b: Gate-Abhaengigkeiten auf der Detailseite pflegen.
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 2m11s
Test Suite / lint-backend (push) Successful in 3s
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
DELETE fuer Roadmap-Kanten, Duplikat-Schutz beim Anlegen, GateDependenciesSection mit ausgehenden/eingehenden Kanten auf der Gate-Detailseite.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 15:53:03 +02:00
7f03504c2d AP1.13a: Read-only Gate-Graph aus Abhaengigkeiten.
Some checks failed
Test Suite / lint-backend (push) Waiting to run
Test Suite / compose-smoke (push) Waiting to run
Test Suite / k6 /api/health Baseline (push) Blocked by required conditions
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Has been cancelled
Initiative-API fuer Roadmap-Dependencies, SVG-Layout mit Liste/Graph-Umschalter auf der Zielzustaende-Seite.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 15:50:20 +02:00
83fdb9a2a2 AP1.12c: Reihenfolge der Gates per DnD und Pfeiltasten.
All checks were successful
Deploy Development / deploy (push) Successful in 50s
Test Suite / pytest-backend (push) Successful in 2m6s
Test Suite / lint-backend (push) Successful in 3s
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 15s
RoadmapPlanSection nutzt sort_order wie Backlog und Projekte; handleReorderRoadmapItems im Operations-Context.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 15:43:08 +02:00
b1686f3a98 AP1.12b/d: Gates per Modal und Actions in der Plan-Outline.
Some checks failed
Test Suite / lint-backend (push) Waiting to run
Test Suite / compose-smoke (push) Waiting to run
Test Suite / k6 /api/health Baseline (push) Blocked by required conditions
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Has been cancelled
RoadmapPlanSection ist read-only mit Modal-Anlage; Stammdaten auf der Gate-Detailseite. Unter Arbeit zeigt die Outline offene Actions wie der Projektbaum unter Struktur.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 15:41:42 +02:00
bcf6f83813 AP1.12a: Plan-Outline mit Projektbaum unter Struktur.
All checks were successful
Deploy Development / deploy (push) Successful in 51s
Test Suite / pytest-backend (push) Successful in 2m7s
Test Suite / lint-backend (push) Successful in 3s
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
Linke Outline zeigt rekursive Projects im Scope; Deep-Links zu /projects/:id markieren Struktur aktiv.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 14:27:28 +02:00
5e2ae9de31 Mitai analysis-split Sub-Nav und weniger Blend-UI bei Auspraegungen.
Some checks failed
Test Suite / lint-backend (push) Waiting to run
Test Suite / compose-smoke (push) Waiting to run
Test Suite / k6 /api/health Baseline (push) Blocked by required conditions
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Has been cancelled
Modus-Subnav als linke Spalte (Plan/Kontrolle/Ausfuehren), Referenz-Auspraegungen aus Formularen entfernt, Hub-Karten auf Steuerung gestrichen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 14:26:14 +02:00
b0594df5f2 Fix IndentationError in initiative_snapshot imports that blocked backend startup.
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 2m6s
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Successful in 19s
Test Suite / playwright-smoke (push) Successful in 12s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 12:45:00 +02:00
efb45a3ee6 Erfassen und Transparent: Archetyp, Methode und Auspraegung in der UI sichtbar.
Some checks failed
Test Suite / pytest-backend (push) Failing after 2s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
Deploy Development / deploy (push) Failing after 50s
Method-Profiles-API und Auswahl beim Anlegen/Profil; Steuerungs-Meta in Listen und Plan; Projekt-Archetyp-Spiegel, Next-Action-Links und Umlaut-Fix fuer testbare Guidance.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 12:36:12 +02:00
95b743d6fd Fix: AP2.0 pytest — Snapshot statt nicht vorhandenem GET context.
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 2m2s
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 17s
Steering PATCH akzeptiert alle registrierten method_keys (nicht nur Legacy-Literal).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 10:56:26 +02:00
3bd983297b AP2.0: Archetyp-Katalog, Method-Stubs und sichtbare Steuerung in Kontrolle.
Some checks failed
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Failing after 2m2s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
PO-Dokumente (ADP v0.2, MVP v0.3) und Minimal Complete Slice: Registry-Methoden, Initiative/Project-Spiegel, Default-Methode bei Anlage, Lagebild mit Archetyp und Guidance.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 10:44:38 +02:00
21d948634e Fix: Plan-Eingang crashte wegen fehlender Context-Handler.
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 2m0s
Test Suite / lint-backend (push) Successful in 3s
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 15s
handleUpdateBacklog und handleReorderBacklog waren in InitiativeInboxPage nicht destructured — ReferenceError, leerer Screen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 06:39:03 +02:00
3e96b9c1e9 Fix: Task-Roll-up mit RealDictCursor kompatibel.
All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 1m58s
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 14s
rollup_parent_status nutzte Index-Zugriff row[0]; pytest test_nested_tasks_and_rollup schlug fehl.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 06:31:41 +02:00
ba28a86fe5 AP1.5d: Rekursive Tasks im Plan-Knoten Arbeit.
Some checks failed
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Failing after 1m57s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
parent_task_id mit Validierung und Roll-up; aufklappbarer Task-Baum unter Arbeitspaketen im Plan-Modus (max. 3 UI-Ebenen).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 06:18:55 +02:00
a6fc86025c AP1.10b/10c: Entity Field System mit dynamischen Vorhaben-Zusatzfeldern.
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 1m54s
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 19s
Test Suite / playwright-smoke (push) Successful in 15s
EFS-Tabellen, Registry-Sync und Fields-API ermöglichen archetyp-spezifische Felder; Frontend rendert sie im Profil-Modal per FieldRenderer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 13:53:07 +02:00
408050bcb8 AP1.10a: Initiative-Standardfelder vision, Zielzustand und Archetyp-Schlüssel.
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 1m51s
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
Migration 016, Archetyp-Registry-API, erweitertes Profil-Modal und Plan-Profil-Ansicht.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 11:48:54 +02:00
495993cad6 Fix: test_backlog_sort_order setzt beide sort_order-Werte ohne Tie.
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 1m49s
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 19s
Test Suite / playwright-smoke (push) Successful in 12s
Einzel-PATCH auf 0 kollidierte mit dem ersten Item; entspricht dem Batch-Reorder aus AP1.12c.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 11:42:42 +02:00
4f2f801889 AP1.12d: Plan-Outline-Knoten Arbeit mit Actions-Liste im Plan-Kontext.
Some checks failed
Deploy Development / deploy (push) Successful in 56s
Test Suite / pytest-backend (push) Failing after 1m48s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Route /plan/work, Modal-Anlage, Links zu Objektseiten; Outline zeigt Zähler für Eingang und Arbeit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 11:38:17 +02:00
a99d4a8974 AP1.12c: Reorder für Projekte und Backlog per sort_order.
Some checks failed
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Failing after 1m46s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Migration 015 für Backlog sort_order; DnD auf Desktop, Pfeile auf Mobile; PATCH-Batches für Geschwister.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 11:35:08 +02:00
e50c04c96f AP1.12b: Modal-Bearbeitung für Profil, Projekte und Backlog im Plan-Modus.
All checks were successful
Deploy Development / deploy (push) Successful in 53s
Test Suite / pytest-backend (push) Successful in 1m47s
Test Suite / lint-backend (push) Successful in 3s
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
Ersetzt Inline-Formulare durch Dialoge; Scope-Breadcrumb aktualisiert sich nach Profil-Speichern.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 11:26:48 +02:00
bbadf22d39 AP1.12a: Plan-Outline-Shell ersetzt Untertabs im Planen-Modus.
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 2m4s
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 13s
Linke Outline mit Profil, Struktur, Zielzuständen und Eingang; Portfolio-Einstieg ohne Scope; Vorhabens-Profil als Read-only-Vorschau.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 11:16:42 +02:00
aa7e1626ea AP1.9b: Scope-Breadcrumb modus-sensitiv für Planen, Ausführen und Kontrolle.
Some checks failed
Test Suite / lint-backend (push) Waiting to run
Test Suite / compose-smoke (push) Waiting to run
Test Suite / k6 /api/health Baseline (push) Blocked by required conditions
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Has been cancelled
Portfolio- und Vorhaben-Klicks bleiben im aktiven Modus; Objekt-Routen leiten auf passende Plan-/Work-Hubs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 11:14:33 +02:00
afb07f70b9 docs: Plan-Modus-Design und ADP AP1.10 (Archetypen, Entity Field System).
Festlegung Vorhaben als Wurzel, Plan-Outline, Modal/DnD, Gate-Graph und Roadmap AP1.9b–AP1.13.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 11:13:54 +02:00
09feddae4a fix: Planen/Struktur — fehlender projectPath-Import verursachte weißen Bildschirm.
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 1m51s
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 20s
Nach Vorhaben-Wahl in Planen crashte ProjectsSection; Ladezustand und Projekt-Löschen ergänzt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 07:53:28 +02:00
d00ae070ec fix: Backlog-Konvertierung — fehlendes f-String in RETURNING-Klausel.
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 1m47s
Test Suite / lint-backend (push) Successful in 3s
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 23s
convert_backlog_to_action schickte {_BACKLOG_COLUMNS} uninterpoliert an PostgreSQL; zwei pytest-Tests schlugen fehl.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 07:48:41 +02:00
2734215833 fix: WidgetCard-Import wiederherstellen — weißer Bildschirm nach Login.
Some checks failed
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Failing after 1m48s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Beim AP1.9a-Routing-Umbau fehlten die Imports in zwei Cockpit-Widgets; React brach nach Anmeldung ab.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 13:01:46 +02:00
e42edd86fa AP1.9a: Arbeitsmodi-Shell mit Scope, Objekt-Routen und Legacy-Redirects.
Some checks failed
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Failing after 1m46s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Ersetzt die Vorhaben-Tab-Navigation durch Cockpit/Ausführen/Planen/Kontrolle/Team, Deep Links für APs/Projekte/Gates und Programm-Scope im Header.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 12:55:37 +02:00
33831a8c2c AP1.5e: Projekt-Detail, Bearbeitung und Lifecycle in der Ausführung.
Some checks failed
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Failing after 1m48s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 3s
Test Suite / compose-smoke (push) Has been skipped
Detail-Route mit vollem Formular, Struktur-Navigator statt Inline-CRUD, Lifecycle sichtbar im Vorhaben-Header und auf Ausführung (0.15.1-ap1.5e).
2026-07-08 11:06:10 +02:00
94e815e88a fix: Projekt-Anlegen robuster — klare Netzwerkfehler und gezielter Listen-Refresh.
Some checks failed
Deploy Development / deploy (push) Successful in 50s
Test Suite / pytest-backend (push) Failing after 1m45s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 8s
Test Suite / compose-smoke (push) Has been skipped
Vermeidet irreführendes Fail to fetch nach erfolgreichem POST und hält das Formular bei Fehlern offen.
2026-07-08 10:54:58 +02:00
bff1248e32 fix: doppelte roadmapItems-Deklaration blockierte Frontend-Build.
Some checks failed
Deploy Development / deploy (push) Successful in 51s
Test Suite / pytest-backend (push) Failing after 1m47s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 08:46:42 +02:00
7e4936a2e4 AP1.6: Plan/Ist-Verknuepfung und Journey-Timeline.
Some checks failed
Deploy Development / deploy (push) Failing after 37s
Test Suite / pytest-backend (push) Successful in 1m41s
Test Suite / lint-backend (push) Successful in 1s
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 13s
Backlog, Actions, Projects und Tasks koennen Gates zugeordnet werden; Gate-Beitragsliste und chronologische Journey machen Plan/Ist nachvollziehbar.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 08:43:11 +02:00
4ca0a2c460 AP1.5c: Rekursive Projects fuer Mega-Programme ohne Stream-Typ.
All checks were successful
Deploy Development / deploy (push) Successful in 53s
Test Suite / pytest-backend (push) Successful in 1m42s
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 21s
Test Suite / playwright-smoke (push) Successful in 14s
parent_project_id und container_kind ermoeglichen verschachtelte Projektstruktur; Arbeitspakete bleiben an Blatt-Projekten gebunden.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 08:38:58 +02:00
6eeddfdc76 AP1.5: Project, Task und Ausführung als Ist-Kern
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 1m39s
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
Schema 013; Projekte/Arbeitspakete/Aufgaben pflegbar; Tab Zielzustände vs Ausführung.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 08:19:05 +02:00
2e7651812a docs: PO-Schichtung Gates vs. Plan — AP1.5 vor Gate-UI
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 1m34s
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
ADP Product Layering, Roadmap-Reihenfolge, Sprint-Assignment AP1.5; Gate-UI eingefroren.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 07:53:07 +02:00
9bf6ccee36 AP1.4b: Gate-Checkliste, Verify, Reopen und Plan-Detail-UI
All checks were successful
Deploy Development / deploy (push) Successful in 48s
Test Suite / pytest-backend (push) Successful in 1m36s
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
Kriterien mit waive/defer per Decision; Verify gegen Checkliste; Reopen active mit Audit; Schema 012.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 06:53:49 +02:00
e70e3196ec docs: PO-Freigabe AP1.4b — Kriterien, Reopen-Rollen, Audit
Waive/defer per Decision; Reopen active mit unveraenderten Kriterien; Sprint-Assignment AP1.4b.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 22:33:44 +02:00
4d1c33bf18 fix: Snapshot-SQL — RoadmapItem-Spalten nach JOIN qualifizieren
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 1m29s
Test Suite / lint-backend (push) Successful in 1s
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 15s
Behebt psycopg2 AmbiguousColumn (id/title) bei steering-snapshot nach AP1.4.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 22:24:00 +02:00
0addcfb382 docs: Gate-Checkliste, Roadmap-Graph und Vision §6 präzisiert
Neues ADP für Checkliste, Graph Engine und Methodenprofile; sequenziell/parallel nur über Kanten.
Roadmap AP1.4b–4e; Vision und Canonical OM an PO-Richtung angeglichen.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 19:37:34 +02:00
fdda8a9fae fix: Gate-Verify — Evidence an Plan-Element verknüpfen
Some checks failed
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Failing after 1m31s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 1s
Test Suite / compose-smoke (push) Has been skipped
Verify prüft akzeptiertes Evidence am RoadmapItem. Plan-UI schließt Gates mit Nachweis;
Journey erlaubt Plan-Zuordnung. Migration 011 roadmap_item_id auf evidence/reviews.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 19:24:18 +02:00
09433b5d28 AP1.2 + AP1.4: Signals konsolidieren, RoadmapItem mit Gate-Verify
Some checks failed
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Failing after 1m30s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Entfernt operating_phase aus dem Snapshot zugunsten von Lifecycle + signals.
Führt methodenneutrale RoadmapItems (Migration 010), Verify-Pfad und Plan-UI ein.
Milestone-API bleibt als Compat-Wrapper; Version 0.13.0-ap1.4.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 18:45:36 +02:00
a5d9eeb338 docs: Methodische Flexibilität als verbindliche Leitplanke verankern
PO-Leitplanken und ADP AP1.4 stellen klar, dass RoadmapItem und Plan-UI methodenneutral bleiben.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 18:37:27 +02:00
687aad6cbd AP1.2c: IA-Skeleton — Workspace-Portfolio, Initiative-Übersicht, Unterseiten
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 1m26s
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 17s
Test Suite / playwright-smoke (push) Successful in 12s
Neue Navigation: Steuerung/Plan/Ausführung/Eingang/Journey. InitiativeOperationsContext, Action-Detail-Route, NextActionWidget portfolio/initiative, Portfolio-Widget auf Workspace. Omnibus InitiativeDetail entfernt. Version 0.12.0-ap1.2c.
2026-07-05 18:29:47 +02:00
2796965aed docs: Welle 2 — Roadmap v0.2, ADPs, AP1.2c Assignment
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 1m23s
Test Suite / lint-backend (push) Successful in 1s
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
Corrected MVP Roadmap v0.2, Recovery Plan v0.2, Gap Analysis v0.2, Target State Implementation Map. ADPs für RoadmapItem/Quality Gates und Operational Actor Interface (Vibe-Coder). Handover v0.2 und Sprint-Auftrag AP1.2c IA-Skeleton.
2026-07-05 18:17:25 +02:00
8c31802662 docs: Vision v0.2 und Dokumentations-Überarbeitung Welle 1
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 1m22s
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 19s
Test Suite / playwright-smoke (push) Successful in 12s
Führendes Zielbild: Workspace-Portfolio, Initiative-Übersicht vs. Unterseiten, Vibe-Coder-Schnittstelle, Next-Action-Widget auf beiden Ebenen, Portfolio-Priorität und situativer Steuerungskontext. Canonical OM v0.2, Truth Table, Revision Program; v0.1-Docs mit Superseded-Banner; CLAUDE.md und Cursor-Rules aktualisiert.
2026-07-05 18:06:26 +02:00
a520d54bcf AP1.1b: Vorhaben-Detail als Steuerungsfläche — Action-Hub statt paralleler Listen
All checks were successful
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Successful in 1m26s
Test Suite / lint-backend (push) Successful in 1s
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
Leitfrage oben, Maßnahmen mit verknüpftem Kontext aus dem Snapshot, vollständiges Meilenstein-Formular, Backlog/Entscheidungen/Nachweise unter Erweitert. Version 0.11.2-ap1.1b.
2026-07-05 17:39:18 +02:00
fc956d4ceb 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>
2026-07-05 17:24:17 +02:00
95feba6204 AP1.0: Steering Foundation — Lifecycle, Context, Signal Engine
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 1m19s
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 11s
Persistierter Standard Lifecycle pro Initiative, backend/steering/ Skeleton, Attention-Refactor und Hook-Dispatch als Basis für Method Registry.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 17:09:20 +02:00
451d62ed46 AP0.10b: Steering UI Minimum — Next Actions, Heute, Snapshot v2
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 1m13s
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 15s
Workspace beantwortet die Leitfrage mit prominenten Next-Action- und Heute-Widgets sowie erweitertem Steuerungszustand pro Vorhaben.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 15:55:05 +02:00
6ea663b4c4 fix(tests): AP0.10 pytest — blockers ohne milestone_id, COUNT mit RealDictCursor
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 1m10s
Test Suite / lint-backend (push) Successful in 1s
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
Behebt test_steering_snapshot_graph und test_blocker_resolve_unblocks_action.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 15:38:57 +02:00
1b19deb728 fix(ui): Steuerungszustand sichtbar auch bei Lade-/API-Fehler
Some checks failed
Deploy Development / deploy (push) Successful in 46s
Test Suite / pytest-backend (push) Failing after 1m12s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Vorher wurde das Panel bei fehlendem Snapshot komplett ausgeblendet.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 15:36:15 +02:00
e25ff86420 AP0.10a: OM-Integration — Steering-Snapshot, Transitions, Verknüpfungen
Some checks failed
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Failing after 1m10s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 1s
Test Suite / compose-smoke (push) Has been skipped
Beantwortet die Lücke zwischen parallelen Tabellen und späterem Steering Core: Graph-Read-Model, minimale Flows, sichtbarer Steuerungszustand.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 15:32:33 +02:00
a025542545 AP0.9: Operating Model Extension II (Evidence, Decision, Review, Recurring, due_at)
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 1m6s
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 21s
Test Suite / playwright-smoke (push) Successful in 14s
Schließt den zweiten OM-Slice entlang der Roadmap: neue Entitäten parallel im Vorhaben, erweiterte Action-Status/Fälligkeit und Attention-Regeln 7–9.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 15:27:03 +02:00
c9101db1e1 fix(AP0.8): UI-Sichtbarkeit Attention und OM-Sektionen verbessern
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 57s
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
Attention-Widget an workspace.read koppeln, Vorhaben-Detail-Sektionen bei initiative.read anzeigen, App-Version im Kontext-Widget, Session-Loading-Race behoben.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 15:15:27 +02:00
fe3bc4f057 AP0.8: Operating Model Extension I (Attention, Blocker, Backlog, Milestone)
All checks were successful
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 58s
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
Schema 007, regelbasierte Attention/NextAction im Data Layer, CRUD fuer Blocker/Backlog/Meilensteine, Workspace-Widget und Initiative-Detail-Sektionen. 25 Capabilities. Tests fuer Remote-Pytest auf Pi angepasst (conftest Session-Guard).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 15:11:15 +02:00
6dd9dd4f62 Doku: AP0.8 Implementierungsauftrag v0.2 (Überarbeitung und Feedback integriert)
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 43s
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 25s
2026-07-05 14:52:33 +02:00
c4a66e9773 Doku: AP0.8 Implementierungsauftrag v0.1 (Entwurf zur Prüfung)
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 42s
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 13s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 08:25:20 +02:00
01b3c594a1 AP0.R1: Product Reset Dokumente ins Repository aufgenommen
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 43s
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 11s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 08:18:13 +02:00
cc3129116a AP0.R1: Product Reset Integration — CLAUDE, README, Cursor Rule, Abschlussbericht
Some checks failed
Test Suite / pytest-backend (push) Waiting to run
Test Suite / lint-backend (push) Waiting to run
Test Suite / compose-smoke (push) Waiting to run
Test Suite / k6 /api/health Baseline (push) Blocked by required conditions
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 08:17:51 +02:00
a01457debb Doku AP0.7: Abschlussbericht v0.2 (final) mit DoD-Matrix und QA-Fix
All checks were successful
Deploy Development / deploy (push) Successful in 48s
Test Suite / pytest-backend (push) Successful in 42s
Test Suite / lint-backend (push) Successful in 1s
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
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 08:14:28 +02:00
02b3b17269 fix: Überblick-Widget Overflow — volle Breite, 2x2 Grid in schmalen Karten
Some checks failed
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Successful in 45s
Test Suite / pytest-backend (push) Successful in 43s
Test Suite / lint-backend (push) Successful in 2s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 08:12:45 +02:00
db06af0489 AP0.7: Tenant Hardening, Actor Directory und Data Layer Minimum
All checks were successful
Deploy Development / deploy (push) Successful in 47s
Test Suite / pytest-backend (push) Successful in 42s
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
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 07:25:01 +02:00
36487d2869 Doku: Abschlussberichte AP0.4 v0.2 und AP0.6b v0.2 (final)
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 29s
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
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 07:09:40 +02:00
842c39081b Doku AP0.6b: Abschlussbericht v0.2 mit DoD-Matrix und Testbestätigung
Some checks failed
Test Suite / playwright-smoke (push) Blocked by required conditions
Deploy Development / deploy (push) Successful in 41s
Test Suite / pytest-backend (push) Successful in 30s
Test Suite / lint-backend (push) Successful in 7s
Test Suite / compose-smoke (push) Has been skipped
Test Suite / k6 /api/health Baseline (push) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 07:08:02 +02:00
26cd9feeb2 fix: fehlender actorsFromContext-Import auf Vorhaben-Detailseite
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 29s
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 11s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 07:02:15 +02:00
48a599e76c AP0.6b: Jinkendo UX/PWA Remediation — App Shell, Design-Tokens, responsive Nav, PWA-Manifest
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 29s
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 19s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 06:58:48 +02:00
0e7184a59e AP0.6: Kairo Workspace UX mit Frontend Widget/View Registry und Vitest.
All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 29s
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
2026-07-05 06:41:23 +02:00
802c9259dc Doku AP0.5: ADP Scope-Verschiebung, MIGRATIONS 006, Abschlussbericht v0.2.
All checks were successful
Deploy Development / deploy (push) Successful in 33s
Test Suite / pytest-backend (push) Successful in 29s
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
2026-07-05 06:23:24 +02:00
a34a61458e Fix: Cleanup-Seed löscht Initiatives vor pytest-Actors (FK RESTRICT).
All checks were successful
Deploy Development / deploy (push) Successful in 32s
Test Suite / pytest-backend (push) Successful in 28s
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
2026-07-05 06:17:56 +02:00
afa68150c0 AP0.5: Minimaler Vorhaben- und Maßnahmen-Slice mit Migration 006, CRUD, Assignments und Tests.
Some checks failed
Deploy Development / deploy (push) Successful in 33s
Test Suite / pytest-backend (push) Failing after 28s
Test Suite / k6 /api/health Baseline (push) Has been skipped
Test Suite / playwright-smoke (push) Has been skipped
Test Suite / lint-backend (push) Successful in 7s
Test Suite / compose-smoke (push) Has been skipped
2026-07-05 06:15:40 +02:00
650 changed files with 89784 additions and 587 deletions

View File

@ -1,30 +1,45 @@
--- ---
description: Kairo Sprint-0 Architektur-Leitplanken und Dokumentenpriorität description: Kairo Architektur-Leitplanken und Dokumentenpriorität
globs: backend/**,frontend/**,docs/**,.gitea/** globs: backend/**,frontend/**,docs/**,.gitea/**
alwaysApply: true alwaysApply: true
--- ---
# Kairo Architecture Rules # Kairo Architecture Rules
Du arbeitest an **Jinkendo Kairo** (Sprint 0). Kairo ist mandantenfähig und Actor-first. Du arbeitest an **Jinkendo Kairo**. Kairo ist mandantenfähig und Actor-first.
## Führende Product-Dokumente (ab 2026-07-05)
1. `docs/product/Kairo_Vision_and_Product_Direction_v0.2.md`
2. `docs/product/Kairo_Canonical_Operating_Model_v0.2.md`
3. `docs/product/Kairo_Implementation_Truth_Table_v0.1.md`
## Product Vision
Kairo ist Program Director — nicht To-do-Tool, nicht CRUD-Omnibus.
- **Plan:** Roadmap / RoadmapItem (Gates, Meilensteine, Reifegrade)
- **Ist:** committete Actions (Arbeitspakete), später Tasks
- **IA:** Workspace (Ausführung), Initiative-Unterseiten (Steuerung, Ausführung, Plan, Eingang, Journey)
- **Bearbeitung:** Modal oder Objekt-Detail — nicht Inline-CRUD auf Übersichtsseiten
Do not reduce Kairo to Initiative → Action.
Do not expand InitiativeDetail as all-in-one CRUD wall.
Do not continue prompt/AI/workflow/MCP before Plan/Ist and Gates are modeled.
## Dokumentenpriorität bei Konflikten ## Dokumentenpriorität bei Konflikten
1. `docs/architecture/Kairo_Sprint0_Principle_Gate_v0.1.md` 1. Vision & Product Direction v0.2
2. `docs/sprints/Jinkendo_Kairo_04_Sprint0_Foundation_v0.3.md` 2. Canonical Operating Model v0.2
3. `docs/product/Jinkendo_Kairo_Product_Spec_v0.2.md` 3. Implementation Truth Table
4. `docs/reference/design-principles/alignment/*` 4. System Target State, ADPs (Scope Lock)
5. Mitai/Shinkan-Einzelprinzipien (nur Begründung, kein Scope) 5. Tenant Invariants, Principle Gate
6. Product Spec v0.2 (Referenz)
7. Sprint History (Completion Reports — nicht als Zielbild)
## Reference design principles ## Reference design principles
Referenzmaterial: Referenzmaterial unter `docs/reference/design-principles/`. Nicht automatisch Scope.
- `docs/reference/design-principles/mitai/`
- `docs/reference/design-principles/shinkan/`
- `docs/reference/design-principles/alignment/`
Nicht automatisch Implementierungs-Scope. Übernahme nur via Principle Gate oder Architecture Decision.
## Harte Guardrails ## Harte Guardrails
@ -34,8 +49,10 @@ Nicht automatisch Implementierungs-Scope. Übernahme nur via Principle Gate oder
- Keine hardcodierten Rechte, Prompts oder Fachkonfiguration - Keine hardcodierten Rechte, Prompts oder Fachkonfiguration
- Nummerierte SQL-Migrationen; kein ad-hoc DDL in Routern - Nummerierte SQL-Migrationen; kein ad-hoc DDL in Routern
- Keine Mitai-/Shinkan-Domänenlogik kopieren - Keine Mitai-/Shinkan-Domänenlogik kopieren
- Keine Vorhaben-/Projektlogik vor Sprint-0-Abschluss - Keine parallele Steuerungslogik außerhalb `backend/steering/`
- Keine neuen OM-Tabellen ohne ADP (Scope Lock bis RoadmapItem-ADP)
- **Archetyp-/Methoden-Plugin (AP2.3/AP2.4):** siehe `.cursor/rules/kairo-plugin-architecture.mdc` — keine Archetyp-Ifs in Pages, keine neuen Features in `methodUiDefaults.js`
## Abweichungen ## Abweichungen
Architecture Decision Proposal nach Vorlage in `docs/architecture/ARCHITECTURE_DECISION_PROPOSAL_TEMPLATE.md`. Architecture Decision Proposal nach `docs/architecture/ARCHITECTURE_DECISION_PROPOSAL_TEMPLATE.md`.

View File

@ -0,0 +1,40 @@
---
description: Deployment und Tests — kein lokaler Lauf; Verifikation über develop auf Raspberry
alwaysApply: true
---
# Kairo — Deployment & Tests
## Kein lokaler Test-/DB-Lauf
Backend-Tests (pytest), Migrationen und Integrationstests **können auf der Entwicklungsmaschine in der Regel nicht ausgeführt werden** — PostgreSQL/Docker ist lokal oft nicht verfügbar.
Versuche nicht wiederholt, lokale pytest-/Migration-Läufe als Abnahme zu erzwingen, wenn die DB nicht erreichbar ist.
## Verifikation über Remote-Deploy
Praktische Verifikation erfolgt durch **Deployment auf ein entferntes System (Raspberry Pi)**:
1. Änderungen **committen** und auf Branch **`develop`** pushen
2. Deployment wird dadurch ausgelöst (CI/Deploy-Pipeline auf dem Raspberry)
3. Dort laufen Migrationen, Backend-Tests und Smoke-Checks gegen echte Infrastruktur
**Dev-Instanz (kein localhost):** [https://dev.kairo.jinkendo.de](https://dev.kairo.jinkendo.de)
Schnellcheck: `GET https://dev.kairo.jinkendo.de/api/health` (Version + DB).
**Gitea Actions** (Test-Suite läuft nach Deploy automatisch):
- Übersicht: `http://192.168.2.144:3000/Lars/Kairo-Jinkendo/actions`
- Workflows: `deploy-dev.yml` (Deploy), `test.yml` (pytest, lint, k6, Playwright)
- Filter z.B. `?workflow=test.yml` oder `?workflow=deploy-dev.yml`
- Run-Detail: `/Lars/Kairo-Jinkendo/actions/runs/{id}`
Nach Push auf `develop`: Deploy-Run grün → Health auf Dev → Test-Suite in Gitea Actions prüfen.
## Konsequenz für Agenten
- Code und Tests **schreiben** und dokumentieren — aber fehlende lokale DB nicht als Implementierungsblocker behandeln
- Frontend: `npm test` / `vite build` lokal weiterhin sinnvoll, wenn Node verfügbar
- Backend: Abnahme über Gitea Actions (`test.yml`) + Dev-Health — nicht lokales pytest erzwingen
- Funktionale Checks gegen **https://dev.kairo.jinkendo.de**, nicht localhost
- **Nicht** proaktiv committen/pushen, es sei denn, der Nutzer bittet explizit darum

View File

@ -0,0 +1,61 @@
---
description: Archetyp-/Methoden-Plugin-Architektur (AP2.3/AP2.4) — verbindlich für neue Implementierungen
globs: backend/steering/**,backend/entity_archetypes/**,backend/services/operating_context.py,frontend/src/registry/**,frontend/src/pages/**,frontend/src/context/InitiativeOperationsContext.jsx
alwaysApply: true
---
# Kairo Plugin-Architektur (AP2.3 / AP2.4)
Verbindliche ADPs: `docs/architecture/ADP_Archetype_Method_Plugin_Architecture_v0.1.md`, `docs/architecture/ADP_AP2_4_Steering_Elements_and_Method_Contract_v0.1.md`, `docs/architecture/ADP_Steering_Kernel_Spine_v0.1.md`, **`docs/architecture/ADP_Steering_Kernel_Coding_Rules_v0.1.md`**, **`.cursor/rules/kairo-steering-kernel.mdc`**
## Vier Schichten — Auflösung zur Laufzeit
| Schicht | Registry | Liefert |
|---------|----------|---------|
| **Archetyp** | `entity_archetypes` + `ui_profile_json` | Nav, Routen, Plan-Outline, `om_capabilities` |
| **Methode** | `steering/methods/registrations/` | `data_slices`, `steering_elements`, `ui_features`, `graph_profile`, Strategie |
| **Element** | `steering/elements/registry.py` + FE `steeringElementRegistry.js` | Control-/Work-Bausteine |
| **Runtime** | `GET /api/initiatives/:id/operating-context` + **`steering/kernel/evaluate_steering()`** | Plugin-Auflösung + **Herzmaschine** |
```text
data_slices = archetype.om_capabilities ∩ method.data_slices
steering_elements = method.steering_elements (nur Methode)
ui_features = method.ui_features (nur Methode)
nav / routes = archetype.ui_profile (nur Archetyp)
```
Archetyp-Default-Methode ist **Empfehlung**, nicht Lock — kompatible Methoden über `compatible_archetype_keys` + `om_capabilities`.
## Erweitern (Open/Closed)
**Neue Methode:** neues Modul unter `steering/methods/registrations/` → `MethodDefinition` vollständig → Import in `steering/__init__.py` → pytest. **Kein** Core-Edit.
**Neuer Archetyp:** Seed in `entity_archetypes/registry.py` + `ui_profiles.py` + optional EFS — **kein** React-If in Pages.
**Neues Steuerungselement:** Eintrag in `steering/elements/registry.py` + FE `steeringElementRegistry.js`; Methoden referenzieren nur registrierte Keys.
## Frontend — verbindlich
- UI aus `operating-context` via `resolveOperatingProfile.js`
- Control/Work: `hasSteeringElement(steeringElements, '…')` — **keine** `archetype_key === 'initiative.…'`-Ifs
- Daten: `loadOperatingSlices` / `refreshOm([…])` — **kein** Full-Reload nach Mutationen
- Route Gating: `OperatingRouteGate` + `MODE_ROUTE_GROUPS`
- Provider: `ScopedOperationsBridge` (ein Provider pro Vorhaben)
## Verboten
- `uiFeatures` am Archetyp-Profil (deprecated — nur Methode)
- Hardcodierte Methoden-UI in `methodUiDefaults.js` für **neue** Archetypen
- Parallele Steuerungslogik außerhalb `backend/steering/`
- Methoden-Dropdown ohne Archetyp-Filter (`GET /steering/methods?archetype_key=…`)
- **Initiative-Steuerung** (Next, Attention) außerhalb `steering/kernel/evaluate_steering()` — siehe `ADP_Steering_Kernel_Spine_v0.1.md`
## Fallback (legacy)
`methodUiDefaults.js` nur wenn Operating Context fehlt — nicht für neue Features erweitern.
## Abnahme neuer Archetyp-/Methoden-Slices
- Operating Context liefert `steering_elements` + `ui_features`
- pytest für Methoden-Vertrag (`test_ap24_*`) grün
- Spec unter `docs/product/archetypes/` referenziert Elemente und kompatible Methoden

View File

@ -0,0 +1,77 @@
---
description: Steering Kernel v0.4 — verbindliche Regeln für Read Models, Proposals, Attention, Agent-Slots, Ranker (alle Coding-Agenten)
globs: backend/steering/**,backend/data_layer/initiative_snapshot.py,frontend/src/components/SteeringProposalsPanel.jsx,frontend/src/components/AgentSlotsPanel.jsx,frontend/src/utils/steeringProposals.js,frontend/src/utils/agentSlots.js,frontend/src/utils/sprintCommitProposal.js,frontend/src/pages/initiative/InitiativeInboxPage.jsx,frontend/src/pages/initiative/InitiativePlanPage.jsx,frontend/src/pages/initiative/InitiativeOverviewPage.jsx,frontend/src/pages/modes/PlanSprintPage.jsx
alwaysApply: true
---
# Kairo Steering Kernel — Coding Rules (verbindlich)
**Primärdokument:** `docs/architecture/ADP_Steering_Kernel_Coding_Rules_v0.1.md`
**Architektur:** `docs/architecture/ADP_Steering_Kernel_Extension_Model_v0.1.md`
## Herzmaschine
- Initiative-Steuerung **nur** via `evaluate_steering()` in `backend/steering/kernel/evaluate.py`
- Snapshot spiegelt `evaluation.read_models` + `evaluation.proposals` + `evaluation.agent_slots` — **kein** Duplikat-Rechnen
## Neue Fähigkeit = Provider registrieren
| Was | Wo |
|-----|-----|
| Read Model | `steering/read_models/registry.py` |
| Proposal | `steering/proposals/registry.py` |
| Attention | `steering/attention/contributors/*.py` + Registry |
| Agent-Slot | `steering/agent_slots/registry.py` |
| Sprint-Ranker | `register_sprint_commit_ranker()` — weitere Ranker analog |
Aktivierung über `steering_elements` / `data_slices` im Methoden-Vertrag — **nicht** über Page-Ifs oder `method_key`-Branches im Kernel.
## Proposals
- **Kein Auto-Commit** — Accept in UI/API
- **Keine feste Bug/Story-Policy** — `ranker_key` + `factors[]`; Heuristik = Fallback (`heuristic_v0`)
- Abhängigkeiten: `dependency_refs`, `dependency_blocked`
- KI: `agent_v1` Ranker + Actor-Slot — **keine** hardcodierten Prompts (Principle Gate)
## Agent-Slots
- Deklarative Fenster für Actors — **kein** Prompt, **keine** Auto-Ausführung
- Config: `operatingContext.agent_slot_config` (`enabled_slots`, `guardrail_pack`, `stale_debt_days`)
- UI: `AgentSlotsPanel` + Snapshot `steering_kernel.agent_slots`
## Portfolio (K-Ext-5)
- Aggregiert Kernel-Output über Initiativen: `GET /api/workspace/steering`
- Modul: `steering/portfolio/aggregate.py` — **kein** Duplikat-Rechnen außerhalb Kernel
- Legacy `/api/workspace/attention` bleibt (SQL-Regeln) bis vollständige Migration
## Frontend
- `SteeringProposalsPanel` + `PROPOSAL_UI_CONFIG` in `utils/steeringProposals.js`
- Daten aus `steeringSnapshot.steering_kernel.proposals`
- Keine neue Proposal-Page pro Methode
## Methoden-Parität (Ist)
| Provider | Element-Gate |
|----------|--------------|
| `sprint_commit` | `work_cycle_scope` |
| `gate_next_actions` | `gate_fulfillment` |
| `intake_triage` | `backlog` slice, **excludes** `work_cycle_scope` |
| `epic_rollup` | `backlog_epic_hierarchy` |
| `planning_debt` / `execution_graph` | `gate_fulfillment` / `critical_path` |
| `tech_debt_summary` | `backlog` + `actions` slices |
| `review.due` / `recurring.due` / `review.guardrail` | Agent-Slots (data_slices + elements) |
## Verboten
- Steuerungslogik außerhalb `backend/steering/`
- Archetyp-Ifs für Steuerungs-UI
- `evaluate_next_work` für Commit-Vorschläge (Plan ≠ Ist)
- OM-Tabellen für Vorschläge
## Tests
- Unit-Test pro Provider/Ranker
- Snapshot-Integration wenn DB-Test verfügbar
- Truth Table aktualisieren

View File

@ -0,0 +1,76 @@
---
description: Vibe-Coder Agent — Kairo Operational API mit Actor Service Token
alwaysApply: false
globs:
- "**/*"
---
# Kairo — Vibe-Coder API (AP1.7)
Du arbeitest als **Agent-Actor** an Kairo. Nutze die **Operational API** — nicht die volle UI-REST-API.
## Einmal-Setup (Nutzer, einmalig)
1. In Kairo als Tenant-Admin einloggen (Browser).
2. Actor `Cursor Agent` anlegen (Typ `agent`) — oder vorhandenen aus Actor-Verzeichnis nutzen.
3. Service Token erstellen:
```http
POST /api/actors/{actor_id}/service-tokens
X-Auth-Token: {session}
Content-Type: application/json
{"label": "Cursor Agent", "capabilities": []}
```
Antwort enthält **`token`** (nur einmal sichtbar) — Format: `kairo_at_{prefix}_{secret}`.
4. In Cursor **Environment Variables** (oder `.env` lokal, nicht committen):
```text
KAIRO_API_BASE=http://192.168.2.144:3097
KAIRO_ACTOR_TOKEN=kairo_at_...
```
Optional für Dogfooding:
```text
KAIRO_INITIATIVE_ID={uuid von „Jinkendo Kairo“}
```
## Authentifizierung
Jeder Operational-Request:
```http
X-Actor-Token: {KAIRO_ACTOR_TOKEN}
```
Alternativ: `Authorization: Bearer {KAIRO_ACTOR_TOKEN}`
**Nicht** Session-Token in Agent-Skripten hardcoden — Service Token ist tenant-scoped, capability-gebunden, widerrufbar.
## Typischer Agent-Loop
```text
GET /api/operational/me
GET /api/operational/initiatives/{id}/context → enthält active_work_cycle
GET /api/initiatives/{id}/work-cycles/active → aktive Sprint-Zeitbox
POST /api/initiatives/{id}/work-cycles → {"title","status":"active"}
GET /api/operational/next-action?initiative_id={id}&limit=5
… Arbeit …
PATCH /api/operational/actions/{id}/status {"status":"in_progress"}
PATCH /api/operational/actions/{id}/status {"status":"done","note":"Commit …"}
POST /api/operational/evidence {"initiative_id","action_id","title","body":"Commit-Link"}
```
## Guardrails
- Kein Gate `reached`, kein Lifecycle-Wechsel, kein Portfolio-Reorder über Operational API.
- Mutierende Calls werden auditiert (`actor_id` aus Token).
- Token-Leak → Token widerrufen: `DELETE /api/actors/service-tokens/{token_id}` (Session-Auth).
## Referenz
- `docs/architecture/ADP_Operational_Actor_Interface_Vibe_Coder_v0.1.md`
- OpenAPI: `{KAIRO_API_BASE}/api/docs` (Nicht-Prod)

130
CLAUDE.md
View File

@ -12,24 +12,27 @@ Welcher nächste Schritt bringt ein Vorhaben aktuell am wirkungsvollsten voran?
## 1. Aktueller Entwicklungsstand ## 1. Aktueller Entwicklungsstand
Sprint 0. Foundation AP0.1AP0.7 abgeschlossen. Operating Model AP0.8AP0.10, Steering AP1.0AP1.1b technisch geliefert.
Es geht noch nicht um die vollständige Fachanwendung, sondern um das Fundament. **Technisch vorhanden:**
Sprint 0 baut: - Tenant, User, Actor, TenantContext, Auth, Capabilities
- OM-Entitäten (Backlog, Blocker, Milestone-Brücke, Evidence, Decision, Review, Recurring)
- `backend/steering/` (Lifecycle, Snapshot, Method Registry, **Plugin-Architektur AP2.3/AP2.4**)
- **Operating Context API** + slice-bewusstes Frontend-Laden (AP2.3)
- **Steuerungselement-Registry** + Methoden-Vertrag (AP2.4)
- Workspace-GUI, Data Layer, Actor Directory
- Tenant **Produktlich fehlt (Vision):**
- User
- Actor - Getrennte IA (Ausführung / Plan / Eingang / Journey) — nicht Omnibus-InitiativeDetail
- TenantContext - RoadmapItem / Quality Gates — Milestone heute nur CRUD
- Auth-Gates - Plan vs. Ist, Abhängigkeiten, Task-Hierarchie
- Capability / Rights Registry - Modal/Detail-Bearbeitung statt Inline-CRUD
- minimale Feature Registry
- minimale Prompt Registry **Ist-Stand:** `docs/product/Kairo_Implementation_Truth_Table_v0.1.md`
- Placeholder Validation
- Configuration-Grundmodell Nächster Fokus: **Specs Welle 1 finalisieren**, dann **Stufe-A End-to-End** (AP2.2b/c/e, AP1.9d) und **AP2.1 Validation** — auf Plugin-Architektur AP2.3/4 aufsetzen, kein Layout-Patching auf InitiativeDetail.
- Audit
- Migration/Deploy-Grundlage
--- ---
@ -37,12 +40,60 @@ Sprint 0 baut:
Lies bei Projektstart in dieser Reihenfolge: Lies bei Projektstart in dieser Reihenfolge:
1. `docs/product/Jinkendo_Kairo_Product_Spec_v0.2.md` 1. `docs/product/Kairo_Vision_and_Product_Direction_v0.2.md`
2. `docs/architecture/Kairo_Sprint0_Principle_Gate_v0.1.md` 2. `docs/product/Kairo_Canonical_Operating_Model_v0.2.md`
3. `docs/sprints/Jinkendo_Kairo_04_Sprint0_Foundation_v0.3.md` 3. `docs/product/Kairo_Implementation_Truth_Table_v0.1.md`
4. `docs/sprints/Sprint0_Vibe_Coder_Handover_v0.1.md` 4. `docs/product/DOCUMENTATION_REVISION_PROGRAM_v0.2.md`
5. `docs/architecture/Kairo_Architecture_References_v0.1.md` 5. `docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md`
6. `.cursor/rules/kairo-architecture.mdc` 6. `docs/architecture/Kairo_System_Target_State_v0.1.md`
7. `docs/architecture/ADP_AP1_0_Steering_Foundation_Scope_Lock_v0.1.md`
8. `docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
9. `docs/architecture/Kairo_Sprint0_Principle_Gate_v0.1.md`
10. `.cursor/rules/kairo-architecture.mdc`
11. **`.cursor/rules/kairo-steering-kernel.mdc`** + `docs/architecture/ADP_Steering_Kernel_Coding_Rules_v0.1.md` (Steering Kernel v0.3 — Read Models, Proposals, Ranker)
11. `docs/architecture/ADP_Archetype_Method_Plugin_Architecture_v0.1.md` (AP2.3 — **geliefert**)
12. `docs/architecture/ADP_AP2_4_Steering_Elements_and_Method_Contract_v0.1.md` (AP2.4 — **geliefert**)
13. `.cursor/rules/kairo-plugin-architecture.mdc`
Historisch / Referenz (nicht führend bei Konflikt):
- `docs/product/Kairo_Product_Definition_and_MVP_Reset_v0.1.md` → superseded by Vision v0.2
- `docs/product/Kairo_Canonical_Operating_Model_v0.1.md` → superseded by v0.2
- `docs/product/Jinkendo_Kairo_Product_Spec_v0.2.md` — Referenz
- Sprint Completion Reports — historisch
Bei Konflikten gilt folgende Auslegungsreihenfolge:
1. Vision & Product Direction v0.2
2. Canonical Operating Model v0.2
3. Implementation Truth Table
4. System Target State / ADPs
5. Tenant Invariants, Principle Gate
6. Product Spec v0.2 (Referenz)
7. Sprint History
8. Mitai/Shinkan Designprinzipien (Referenz, kein Scope)
---
## Aktuelle Produktleitlinie
Kairo darf nicht auf `Initiative → Action` reduziert werden.
Der aktuelle Vorhaben-/Maßnahmen-Slice ist nur ein technischer Startpunkt.
Kairo ist ein operativer Program Director.
Das kanonische Operating Model umfasst (Zielbild):
- Initiative / Programm, Project optional
- **Roadmap / RoadmapItem** (Gates, Meilensteine, Reifegrade) — Plan-Struktur
- BacklogItem (Eingang) → Action (Arbeitspaket) → Task (später) — Ist-Struktur
- Assignment, Blocker, Evidence, Decision, Review, RecurringElement
- AttentionItem, NextActionCandidate (Read Models via `backend/steering/`)
**UI-Regel:** Workspace = Portfolio aller Initiativen. Initiative-Übersicht = operative Steuerung (Alltag). Pflege/Anlage auf Unterseiten. Vibe-Coder über Operational Actor Interface (API).
Prompt-/KI-/Workflow-/MCP-Themen bleiben eingefroren, bis der MVP-Fachkern entlang des Operating Models trägt.
--- ---
@ -67,14 +118,12 @@ Sie dürfen nicht dazu verwendet werden, Kairo mit Mitai- oder Shinkan-Domänenl
## 4. Auslegungsreihenfolge bei Konflikten ## 4. Auslegungsreihenfolge bei Konflikten
Bei Konflikten gilt: Siehe **Abschnitt 2** — die Product-Reset-Reihenfolge ist verbindlich.
1. `Kairo_Sprint0_Principle_Gate_v0.1.md` Bei technischen Foundation-Konflikten zusätzlich:
2. `Jinkendo_Kairo_04_Sprint0_Foundation_v0.3.md`
3. `Jinkendo_Kairo_Product_Spec_v0.2.md` - `Kairo_Sprint0_Principle_Gate_v0.1.md`
4. `Jinkendo_Foundation_Minimum_Viable_Foundation_v0.2.md` - `Jinkendo_Foundation_Minimum_Viable_Foundation_v0.2.md`
5. `DESIGN_PRINCIPLES_ALIGNMENT.md`
6. Mitai/Shinkan Einzelprinzipien
Mitai/Shinkan-Prinzipien dürfen Kairo nicht überstimmen. Mitai/Shinkan-Prinzipien dürfen Kairo nicht überstimmen.
@ -95,19 +144,34 @@ Mitai/Shinkan-Prinzipien dürfen Kairo nicht überstimmen.
11. Admin- und Agentenaktionen werden auditiert. 11. Admin- und Agentenaktionen werden auditiert.
12. Migrationen sind nummeriert und reproduzierbar. 12. Migrationen sind nummeriert und reproduzierbar.
### Plugin-Architektur (AP2.3 / AP2.4 — verbindlich)
13. **Archetyp ≠ Methode:** Archetyp = IA-Hülle + `om_capabilities`; Methode = Steuerungsmotor (`steering_elements`, `ui_features`, Strategie).
14. Runtime-Auflösung über `GET …/operating-context` — kein hardcodiertes Methoden-/Archetyp-UI in React-Pages.
15. Neue Archetypen/Methoden nur über **Registrierungsmodule** (`entity_archetypes/`, `steering/methods/registrations/`) — Open/Closed.
16. Control-/Work-UI aus `steering_elements` + `steeringElementRegistry.js`, nicht aus Archetyp-Ifs.
17. Siehe `.cursor/rules/kairo-plugin-architecture.mdc` und ADP AP2.3 / AP2.4.
--- ---
## 6. Nicht tun ## 6. Nicht tun
- Kairo auf `Initiative → Action` reduzieren oder als To-do-Tool behandeln
- InitiativeDetail weiter als Omnibus-CRUD-Seite ausbauen (Layout-Patches)
- Inline-Listenformulare als Haupt-Bearbeitungs-Pattern einführen
- Meilensteine als Status-Dropdown ohne Gate-Verifikation behandeln
- Prompt-/KI-/Workflow-/MCP-Arbeit vor Operating-Model-MVP
- keine Mitai-Domänenlogik kopieren - keine Mitai-Domänenlogik kopieren
- keine Shinkan-Domänenlogik kopieren - keine Shinkan-Domänenlogik kopieren
- keine Trainingsplanung bauen - keine Trainingsplanung bauen
- keine Gesundheitslogik bauen - keine Gesundheitslogik bauen
- keine Lebensmanager-/Seichō-Logik in Kairo einbauen - keine Lebensmanager-/Seichō-Logik in Kairo einbauen
- keine Vorhaben-/Projektlogik in AP0.1 vorziehen
- kein Billing oder SSO bauen - kein Billing oder SSO bauen
- keine strategischen Produktentscheidungen eigenmächtig ändern - keine strategischen Produktentscheidungen eigenmächtig ändern
- keine Designprinzipien aus `docs/reference/` ohne Principle-Gate oder Architecture Decision in Scope ziehen - keine Designprinzipien aus `docs/reference/` ohne Principle-Gate oder Architecture Decision in Scope ziehen
- **keine Archetyp-Ifs** in Pages für Steuerungs-UI (`initiative.product === …`) — AP2.4 `steering_elements`
- **`methodUiDefaults.js` nicht für neue Archetypen erweitern** — Operating Context + Resolver
- **kein Full-`load()`** nach OM-Mutationen — `refreshOm([sliceKeys])`
--- ---
@ -115,6 +179,10 @@ Mitai/Shinkan-Prinzipien dürfen Kairo nicht überstimmen.
Bei notwendiger Abweichung erstelle ein Architecture Decision Proposal. Bei notwendiger Abweichung erstelle ein Architecture Decision Proposal.
## 8. Aktueller erster Auftrag ---
AP0.1 Projektgrundlage (Backend, Frontend minimal, Tests, Deploy). ## 8. Nächste Entwicklungsschritte
Siehe `docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md` und `Sprint1_AP1_2c_IA_Skeleton_Assignment_v0.1.md`.
Foundation AP0.1AP2.4 Plugin-Architektur geliefert. **Produkt:** Specs Welle 1 → AP2.2b/c/e + AP1.9d → AP2.1 Validation. Scope Lock: `ADP_AP1_0_Steering_Foundation_Scope_Lock_v0.1.md`; Plugin: ADP AP2.3/AP2.4.

226
README.md
View File

@ -8,6 +8,26 @@ Kairo steuert Vorhaben, Programme, Projekte, Meilensteine, Maßnahmen, Backlogs,
> Welcher nächste Schritt bringt ein Vorhaben aktuell am wirkungsvollsten voran? > Welcher nächste Schritt bringt ein Vorhaben aktuell am wirkungsvollsten voran?
## Product Direction Reset
Kairo wurde nach AP0.7 produktstrategisch neu ausgerichtet.
Die führenden Dokumente sind:
- `docs/product/Kairo_Product_Definition_and_MVP_Reset_v0.1.md`
- `docs/product/Kairo_Canonical_Operating_Model_v0.1.md`
- `docs/product/Kairo_Corrected_MVP_Roadmap_v0.1.md`
Kairo ist nicht als einfache Aufgabenliste zu verstehen, sondern als operativer Program Director.
Der aktuelle technische Slice `Initiative → Action` bleibt gültig, ist aber nicht das Zielmodell.
Siehe auch `CLAUDE.md` für die verbindliche Dokumentenreihenfolge.
Abschlussbericht: `docs/sprints/Sprint0_AP0_R1_Completion_Report_v0.1.md`
**Nächster geplanter Auftrag (Entwurf):** `docs/sprints/Sprint0_AP0_8_Assignment_v0.1.md`
## Startzustand ## Startzustand
Dieses Repository startet mit einem reinen Spezifikations- und Sprint-0-Handover-Paket. Dieses Repository startet mit einem reinen Spezifikations- und Sprint-0-Handover-Paket.
@ -22,14 +42,22 @@ Noch nicht enthalten:
## Verbindliche Dokumente für Sprint 0 ## Verbindliche Dokumente für Sprint 0
1. `docs/product/Jinkendo_Kairo_Product_Spec_v0.2.md` **Product Reset (führend ab AP0.R1):**
2. `docs/architecture/Jinkendo_Foundation_Minimum_Viable_Foundation_v0.2.md`
3. `docs/architecture/Kairo_Sprint0_Principle_Gate_v0.1.md` 1. `docs/product/Kairo_Product_Definition_and_MVP_Reset_v0.1.md`
4. `docs/sprints/Jinkendo_Kairo_04_Sprint0_Foundation_v0.3.md` 2. `docs/product/Kairo_Canonical_Operating_Model_v0.1.md`
5. `docs/sprints/Sprint0_Vibe_Coder_Handover_v0.1.md` 3. `docs/product/Kairo_Corrected_MVP_Roadmap_v0.1.md`
6. `docs/sprints/Sprint0_AP0_1_Project_Setup_Assignment_v0.1.md`
7. `CLAUDE.md` **Foundation & Steuerung:**
8. `.cursor/rules/kairo-architecture.mdc`
4. `docs/product/Jinkendo_Kairo_Product_Spec_v0.2.md`
5. `docs/architecture/Kairo_Current_State_Gap_Analysis_AP0.1_AP0.6b_v0.1.md`
6. `docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
7. `docs/architecture/Kairo_Sprint0_Principle_Gate_v0.1.md`
8. `docs/sprints/Jinkendo_Kairo_04_Sprint0_Foundation_v0.3.md`
9. `docs/sprints/Sprint0_Vibe_Coder_Handover_v0.1.md`
10. `CLAUDE.md`
11. `.cursor/rules/kairo-architecture.mdc`
## Arbeitsmodus ## Arbeitsmodus
@ -132,6 +160,178 @@ Migrationen & idempotente Data-Seeds: [docs/MIGRATIONS.md](docs/MIGRATIONS.md)
| `/api/me/admin/demo` | GET | `X-Auth-Token` + `kairo.admin.access` | Beispiel-Endpoint mit `require_capability` | | `/api/me/admin/demo` | GET | `X-Auth-Token` + `kairo.admin.access` | Beispiel-Endpoint mit `require_capability` |
| `/api/me/tenant` | POST | `X-Auth-Token` | Aktiven Tenant wechseln (nur Memberships) | | `/api/me/tenant` | POST | `X-Auth-Token` | Aktiven Tenant wechseln (nur Memberships) |
Vorhaben und Maßnahmen (AP0.5 minimaler Slice):
| Endpoint | Methode | Capability | Beschreibung |
|----------|---------|------------|--------------|
| `/api/initiatives` | GET | `kairo.initiative.read` | Vorhaben im aktiven Tenant |
| `/api/initiatives` | POST | `kairo.initiative.manage` | Vorhaben anlegen |
| `/api/initiatives/{id}` | GET | `kairo.initiative.read` | Einzelnes Vorhaben |
| `/api/initiatives/{id}` | PATCH | `kairo.initiative.manage` | Vorhaben bearbeiten |
| `/api/initiatives/{id}` | DELETE | `kairo.initiative.manage` | Vorhaben löschen |
| `/api/initiatives/{id}/actions` | GET | `kairo.action.read` | Maßnahmen eines Vorhabens |
| `/api/initiatives/{id}/actions` | POST | `kairo.action.manage` | Maßnahme anlegen |
| `/api/actions/{id}` | GET | `kairo.action.read` | Einzelne Maßnahme |
| `/api/actions/{id}` | PATCH | `kairo.action.manage` | Maßnahme bearbeiten / Status |
| `/api/actions/{id}/assignments` | PUT | `kairo.action.manage` | Actors zuweisen (ersetzt Liste) |
| `/api/actions/{id}` | DELETE | `kairo.action.manage` | Maßnahme löschen |
| `/api/actions/me/open` | GET | `kairo.action.read` | Offene Maßnahmen des aktuellen Actors (via Data Layer) |
Actor Directory und Workspace Data Layer (AP0.7):
| Endpoint | Methode | Capability | Beschreibung |
|----------|---------|------------|--------------|
| `/api/actors` | GET | `kairo.actor.read` | Actors des aktiven Tenants (Filter: `actor_type`, `q`, `include_inactive`) |
| `/api/actors/{id}` | GET | `kairo.actor.read` | Einzelner Actor (404 cross-tenant) |
| `/api/workspace/summary` | GET | `kairo.workspace.read` | Workspace-Kennzahlen |
| `/api/workspace/actions/open` | GET | `kairo.workspace.read` | Meine offenen Maßnahmen (open/in_progress) |
| `/api/workspace/actions/blocked` | GET | `kairo.workspace.read` | Meine blockierten Maßnahmen |
| `/api/workspace/initiatives/active` | GET | `kairo.workspace.read` | Aktive/pausierte Vorhaben (`?limit=`) |
| `/api/workspace/actors/workload` | GET | `kairo.workspace.read` | Actor-Workload (Basic) |
Operating Model Extension I (AP0.8, Schema `007`):
| Endpoint | Methode | Capability | Beschreibung |
|----------|---------|------------|--------------|
| `/api/workspace/attention` | GET | `kairo.attention.read` | Regelbasierte Attention-Items |
| `/api/workspace/next-actions` | GET | `kairo.attention.read` | NextActionCandidates (`?limit=`) |
| `/api/initiatives/{id}/blockers` | GET/POST | `kairo.blocker.read/manage` | Blocker eines Vorhabens |
| `/api/blockers/{id}` | GET/PATCH/DELETE | `kairo.blocker.read/manage` | Einzelner Blocker |
| `/api/initiatives/{id}/backlog` | GET/POST | `kairo.backlog.read/manage` | Backlog-Items |
| `/api/backlog/{id}` | GET/PATCH/DELETE | `kairo.backlog.read/manage` | Einzelnes Backlog-Item |
| `/api/backlog/{id}/convert-to-action` | POST | `kairo.backlog.manage` | Backlog → Maßnahme |
| `/api/initiatives/{id}/milestones` | GET/POST | `kairo.milestone.read/manage` | Meilensteine |
| `/api/milestones/{id}` | GET/PATCH/DELETE | `kairo.milestone.read/manage` | Einzelner Meilenstein |
**Status Blocker:** `open`, `in_progress`, `resolved`, `accepted_risk`, `dismissed`
**Status Backlog:** `new`, `triaged`, `accepted`, `rejected`, `converted`
**Status Meilenstein:** `planned`, `active`, `at_risk`, `reached`, `moved`, `discarded`
Capabilities gesamt nach AP0.8: **25** (inkl. `kairo.attention.read` und Blocker/Backlog/Milestone read/manage).
Operating Model Extension II (AP0.9, Schema `008`):
| Endpoint | Methode | Capability | Beschreibung |
|----------|---------|------------|--------------|
| `/api/initiatives/{id}/evidence` | GET/POST | `kairo.evidence.read/manage` | Evidence eines Vorhabens |
| `/api/evidence/{id}` | GET/PATCH/DELETE | `kairo.evidence.read/manage` | Einzelnes Evidence |
| `/api/initiatives/{id}/decisions` | GET/POST | `kairo.decision.read/manage` | Entscheidungen |
| `/api/decisions/{id}` | GET/PATCH/DELETE | `kairo.decision.read/manage` | Einzelne Entscheidung |
| `/api/initiatives/{id}/reviews` | GET/POST | `kairo.review.read/manage` | Reviews |
| `/api/reviews/{id}` | GET/PATCH/DELETE | `kairo.review.read/manage` | Einzelnes Review |
| `/api/initiatives/{id}/recurring` | GET/POST | `kairo.recurring.read/manage` | Recurring-Elemente |
| `/api/recurring/{id}` | GET/PATCH/DELETE | `kairo.recurring.read/manage` | Einzelnes Recurring-Element |
**Action-Status (erweitert):** `open`, `ready`, `in_progress`, `blocked`, `review_required`, `done`, `discarded` — optional `due_at`
**Attention-Regeln 79:** `overdue_action`, `review_due`, `recurring_due`
Capabilities gesamt nach AP0.9: **33**.
Operating Model Integration (AP0.10, Schema weiter `008`):
| Endpoint | Methode | Capability | Beschreibung |
|----------|---------|------------|--------------|
| `/api/initiatives/{id}/steering-snapshot` | GET | `kairo.initiative.read` | Verknüpfter Graph + Lifecycle + Operating Phase (deprecated) |
| `/api/initiatives/{id}/steering-context` | GET | `kairo.initiative.read` | SteeringContext (Lifecycle, Methode) |
**Steering Core (AP1.0, Schema `009`):** `backend/steering/` — Standard Lifecycle, Hook/Method Registry, Signal Engine. Scope Lock: siehe `docs/architecture/ADP_AP1_0_Steering_Foundation_Scope_Lock_v0.1.md`.
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
Version nach AP0.10: **`0.10.0-ap0.10`**. Brücke zum Steering Core: `docs/architecture/Kairo_Operating_Model_Steering_Bridge_v0.1.md`
Operating Model Integration UI (AP0.10b, Schema weiter `008`):
| Endpoint | Methode | Capability | Beschreibung |
|----------|---------|------------|--------------|
| `/api/workspace/actions/today` | GET | `kairo.workspace.read` | Meine Maßnahmen heute (überfällig zuerst) |
**Workspace-Widgets:** Nächste sinnvolle Schritte, Heute, Aufmerksamkeit (erweitert)
**Steuerungszustand v2:** Meilenstein-Horizont, Next Actions pro Vorhaben, Phase-Klartext
Version nach AP0.10b: **`0.10.1-ap0.10b`**
Operating Model Integration UI (AP0.10b, Schema weiter `008`):
Tenant-Invarianten: `docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
`docs/architecture/Kairo_Tenant_Invariants_v0.1.md`
**Status Vorhaben:** `active`, `paused`, `completed`, `archived`
**Status Maßnahme:** `open`, `in_progress`, `blocked`, `done`, `discarded`
**Priorität:** `low`, `normal`, `high`
```bash
# Vorhaben anlegen
curl -s -X POST http://localhost:8097/api/initiatives \
-H "X-Auth-Token: TOKEN" -H "Content-Type: application/json" \
-d '{"title":"Erstes Vorhaben","goal":"MVP validieren","priority":"high"}'
# Maßnahme mit Zuweisung
curl -s -X POST http://localhost:8097/api/initiatives/INITIATIVE_ID/actions \
-H "X-Auth-Token: TOKEN" -H "Content-Type: application/json" \
-d '{"title":"API testen","assigned_actor_ids":["ACTOR_ID"]}'
# Meine offenen Maßnahmen
curl -s http://localhost:8097/api/actions/me/open -H "X-Auth-Token: TOKEN"
```
### Workspace UI (AP0.6 / AP0.6b)
Nach Login leitet Kairo auf den **Workspace** weiter.
| Route | Beschreibung |
|-------|--------------|
| `/workspace` | Karten: Kontext, Überblick, **Aufmerksamkeit**, offene/blockierte Maßnahmen, aktive Vorhaben |
| `/initiatives` | Vorhabenliste mit offenen Maßnahmen-Zähler |
| `/initiatives/:id` | Vorhaben-Detail: Maßnahmen, **Meilensteine**, **Backlog**, **Blocker** |
| `/my-actions` | Alle offenen Maßnahmen des aktuellen Actors |
**App Shell (AP0.6b):** Desktop-Sidebar (≥1024px), Mobile-Header + Bottom-Navigation (<1024px), Tenant-/Actor-Kontext, Jinkendo-Family-Design-Tokens (`--jk-*`).
**Frontend-Struktur:** `frontend/src/api/`, `registry/`, `widgets/`, `pages/`, `components/`, `styles/`, `config/appNav.js`
**Widget Registry (frontend-only):** Widgets in `registry/widgetRegistry.js` — Capability-Filter, keine Backend-Persistenz.
**PWA:** `frontend/public/manifest.webmanifest`, Icon unter `public/icons/`. Kein Service Worker (bewusst — Installierbarkeit über Manifest; kein Offline-Caching in Sprint 0).
**Tests (Frontend):**
```bash
cd frontend && npm install && npm run test && npm run build
```
**Manueller Smoke-Test (Desktop + Mobile):**
1. Login → Workspace lädt mit Karten
2. Navigation: Workspace / Vorhaben / Meine Maßnahmen
3. Vorhaben anlegen → Detail → Maßnahme anlegen → Status *Erledigt*
4. Prüfen: Maßnahme verschwindet aus „Meine offenen Maßnahmen“
5. Responsive: DevTools Viewports **1440px**, **1024px**, **390px** — keine horizontale Scrollbar, Bottom-Nav sichtbar unter 1024px
6. PWA: Manifest unter `/manifest.webmanifest` erreichbar; „App installieren“ im Browser prüfbar
7. AP0.7: ActorSelect lädt `/api/actors`; Maßnahme einem Agent zuweisen; Workspace-Überblick sichtbar
8. AP0.8: Attention-Widget auf Workspace; Vorhaben-Detail mit Blocker/Backlog/Meilenstein; Backlog → Maßnahme konvertieren
Abschlussberichte: `docs/sprints/Sprint0_AP0_6_Completion_Report_v0.1.md`, `docs/sprints/Sprint0_AP0_6b_Completion_Report_v0.2.md`, `docs/sprints/Sprint0_AP0_7_Completion_Report_v0.2.md`, `docs/sprints/Sprint0_AP0_8_Completion_Report_v0.1.md`
### Registries (AP0.4) ### Registries (AP0.4)
Feature-, Prompt-, Placeholder- und Config-Registry analog zur Rights Registry: Code-Registrierung → Startup-Sync → DB. Feature-, Prompt-, Placeholder- und Config-Registry analog zur Rights Registry: Code-Registrierung → Startup-Sync → DB.
@ -147,6 +347,8 @@ Feature-, Prompt-, Placeholder- und Config-Registry analog zur Rights Registry:
Startup: `sync_prompt_feature_config.py` nach Rights-Sync (`SKIP_REGISTRY_SYNC=1` zum Überspringen). Startup: `sync_prompt_feature_config.py` nach Rights-Sync (`SKIP_REGISTRY_SYNC=1` zum Überspringen).
Abschlussbericht: `docs/sprints/Sprint0_AP0_4_Completion_Report_v0.2.md`
**Prompt-Modell:** `PromptDefinition``PromptVersion` → optional `PromptStep` (workflow-/pipeline-fähig; AP0.4 führt nur `execution_mode=single` aus). **Prompt-Modell:** `PromptDefinition``PromptVersion` → optional `PromptStep` (workflow-/pipeline-fähig; AP0.4 führt nur `execution_mode=single` aus).
**Beispiel-Prompts:** `kairo.system.health_summary`, `kairo.context.debug_summary` **Beispiel-Prompts:** `kairo.system.health_summary`, `kairo.context.debug_summary`
@ -182,7 +384,13 @@ Registry-first: Capabilities werden in `backend/rights_registrations/` registrie
|------------|-------|------------------| |------------|-------|------------------|
| `kairo.admin.access` | platform | Portal-Administration | | `kairo.admin.access` | platform | Portal-Administration |
| `kairo.tenant.manage` | tenant | Tenant-Verwaltung | | `kairo.tenant.manage` | tenant | Tenant-Verwaltung |
| `kairo.actor.manage` | tenant | Actor-Verwaltung | | `kairo.actor.read` | tenant | Actors im Tenant lesen (Directory) |
| `kairo.actor.manage` | tenant | Actors verwalten |
| `kairo.workspace.read` | workspace | Workspace Read-Models / Data Layer |
| `kairo.initiative.read` | initiative | Vorhaben lesen |
| `kairo.initiative.manage` | initiative | Vorhaben verwalten |
| `kairo.action.read` | action | Maßnahmen lesen |
| `kairo.action.manage` | action | Maßnahmen verwalten / zuweisen |
| `kairo.context.read` | platform | TenantContext lesen | | `kairo.context.read` | platform | TenantContext lesen |
| `kairo.entitlements.read` | platform | Entitlements lesen | | `kairo.entitlements.read` | platform | Entitlements lesen |

View File

@ -0,0 +1 @@
"""Kairo Data Layer — read-oriented aggregation for workspace and dashboards."""

View File

@ -0,0 +1,119 @@
"""Action read-models for workspace — tenant-scoped, actor-assignment based."""
from __future__ import annotations
from typing import Any
from psycopg2.extras import RealDictCursor
from db import get_connection
from services.actions import OPEN_ACTION_STATUSES, _attach_assignments, _serialize_row
from tenant_context import TenantContext
def _require_actor(ctx: TenantContext) -> str:
if not ctx.actor_id:
raise ValueError("Kein Actor im TenantContext")
return ctx.actor_id
def _list_assigned_actions(
*,
tenant_id: str,
actor_id: str,
statuses: list[str],
order_today: bool = False,
) -> list[dict[str, Any]]:
conn = get_connection()
order_clause = """
ORDER BY
CASE WHEN a.due_at IS NOT NULL AND a.due_at < NOW() THEN 0 ELSE 1 END,
CASE a.status WHEN 'blocked' THEN 0 WHEN 'review_required' THEN 1 ELSE 2 END,
CASE a.priority
WHEN 'high' THEN 0
WHEN 'normal' THEN 1
WHEN 'low' THEN 2
END,
a.due_at ASC NULLS LAST,
a.updated_at DESC
""" if order_today else """
ORDER BY
CASE a.priority
WHEN 'high' THEN 0
WHEN 'normal' THEN 1
WHEN 'low' THEN 2
END,
a.updated_at DESC
"""
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
SELECT a.id, a.tenant_id, a.initiative_id, a.title, a.description,
a.status, a.priority, a.due_at, a.created_at, a.updated_at,
i.title AS initiative_title,
(a.due_at IS NOT NULL AND a.due_at < NOW()) AS is_overdue
FROM actions a
JOIN action_assignments aa
ON aa.action_id = a.id AND aa.tenant_id = a.tenant_id
JOIN initiatives i
ON i.id = a.initiative_id AND i.tenant_id = a.tenant_id
WHERE a.tenant_id = %s
AND aa.actor_id = %s
AND a.status = ANY(%s)
{order_clause}
""",
(tenant_id, actor_id, statuses),
)
actions = [_serialize_row(dict(row)) for row in cur.fetchall()]
for action in actions:
action["is_overdue"] = bool(action.pop("is_overdue", False))
finally:
conn.close()
return _attach_assignments(actions, tenant_id=tenant_id)
def get_my_open_actions(ctx: TenantContext) -> list[dict[str, Any]]:
"""Open + in_progress actions assigned to current actor (excludes blocked)."""
actor_id = _require_actor(ctx)
return _list_assigned_actions(
tenant_id=ctx.tenant_id,
actor_id=actor_id,
statuses=["open", "in_progress"],
)
def get_my_blocked_actions(ctx: TenantContext) -> list[dict[str, Any]]:
"""Blocked actions assigned to current actor."""
actor_id = _require_actor(ctx)
return _list_assigned_actions(
tenant_id=ctx.tenant_id,
actor_id=actor_id,
statuses=["blocked"],
)
def get_all_my_open_actions(ctx: TenantContext) -> list[dict[str, Any]]:
"""All non-terminal open actions (open, in_progress, blocked) — legacy compat."""
actor_id = _require_actor(ctx)
return _list_assigned_actions(
tenant_id=ctx.tenant_id,
actor_id=actor_id,
statuses=list(OPEN_ACTION_STATUSES),
)
def get_my_today_actions(ctx: TenantContext, *, limit: int = 15) -> list[dict[str, Any]]:
"""Maßnahmen für heute — überfällig zuerst, dann blockiert/review, dann Priorität."""
if limit < 1:
limit = 1
if limit > 50:
limit = 50
actor_id = _require_actor(ctx)
actions = _list_assigned_actions(
tenant_id=ctx.tenant_id,
actor_id=actor_id,
statuses=["open", "ready", "in_progress", "blocked", "review_required"],
order_today=True,
)
return actions[:limit]

View File

@ -0,0 +1,61 @@
"""Actor workload read-models — tenant-scoped aggregation."""
from __future__ import annotations
from typing import Any
from psycopg2.extras import RealDictCursor
from db import get_connection
from tenant_context import TenantContext
def get_actor_workload(ctx: TenantContext) -> list[dict[str, Any]]:
"""Open/blocked/in_progress action counts per active actor in tenant."""
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT
a.id AS actor_id,
a.name AS display_name,
a.actor_type,
COUNT(*) FILTER (WHERE act.status = 'open') AS open_actions,
COUNT(*) FILTER (WHERE act.status = 'blocked') AS blocked_actions,
COUNT(*) FILTER (WHERE act.status = 'in_progress') AS in_progress_actions
FROM actors a
LEFT JOIN action_assignments aa
ON aa.actor_id = a.id AND aa.tenant_id = a.tenant_id
LEFT JOIN actions act
ON act.id = aa.action_id
AND act.tenant_id = aa.tenant_id
AND act.status IN ('open', 'in_progress', 'blocked')
WHERE a.tenant_id = %s AND a.is_active = TRUE
GROUP BY a.id, a.name, a.actor_type
ORDER BY
CASE a.actor_type
WHEN 'human' THEN 0
WHEN 'working_group' THEN 1
WHEN 'agent' THEN 2
ELSE 3
END,
a.name
""",
(ctx.tenant_id,),
)
result = []
for row in cur.fetchall():
result.append(
{
"actor_id": str(row["actor_id"]),
"display_name": row["display_name"],
"actor_type": row["actor_type"],
"open_actions": int(row["open_actions"] or 0),
"blocked_actions": int(row["blocked_actions"] or 0),
"in_progress_actions": int(row["in_progress_actions"] or 0),
}
)
return result
finally:
conn.close()

View File

@ -0,0 +1,25 @@
"""Attention and NextAction — thin Data Layer wrapper (AP1.0)."""
from __future__ import annotations
from steering.signals.engine import evaluate
from tenant_context import TenantContext
def get_attention_items(ctx: TenantContext):
return evaluate(ctx, kind="attention")
def get_next_action_candidates(ctx: TenantContext, *, limit: int = 10):
return evaluate(ctx, kind="next_action", limit=limit)
def get_next_action_candidates_for_initiative(
ctx: TenantContext, *, initiative_id: str, limit: int = 5
):
return evaluate(
ctx,
kind="next_action",
limit=limit,
initiative_id=initiative_id,
)

View File

@ -0,0 +1,420 @@
"""Initiative steering snapshot — verknüpftes Read-Model (AP0.10a).
Baut aus flachen OM-Tabellen einen erklärbaren Graph für UI und spätere Steering Core.
"""
from __future__ import annotations
from typing import Any, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from entity_archetypes.registry import archetype_label
from method_profiles.registry import get_method_profile
from steering.context import get_steering_context_dto
from steering.kernel import evaluate_steering
from steering.signals.snapshot_signals import derive_initiative_signals
from services.initiatives import get_initiative
from services.progression_graph import compute_progression_state
from tenant_context import TenantContext
OPEN_BLOCKER = ("open", "in_progress")
OPEN_ACTION = ("open", "ready", "in_progress", "blocked", "review_required")
ACTIVE_INITIATIVE = ("active", "paused")
_REASON_LABELS = {
"open_action_priority": "Offenes Arbeitspaket",
"milestone_at_risk_or_active": "Plan-Element aktiv oder gefährdet",
"blocked_action": "Blockiertes Arbeitspaket",
"backlog_triage": "Eingang triagieren",
}
def _steering_guidance(
*,
next_actions: list[dict[str, Any]],
signals: list[str],
method_key: str,
) -> str:
if next_actions:
item = next_actions[0]
title = item.get("title") or "Nächster Schritt"
hint = (
item.get("recommended_action")
or item.get("summary")
or _REASON_LABELS.get(item.get("reason_code", ""), "")
)
if hint:
return f"Als Nächstes: {title}{hint}"
return f"Als Nächstes: {title}"
if signals:
return "Attention: Struktur oder Ist-Arbeit ergänzen, um Next Actions zu erzeugen."
return (
f"Methode {method_key}: Plan oder Eingang befüllen, dann committete Arbeit starten."
)
def _attention_items(signals: list[str]) -> list[dict[str, str]]:
return [{"code": code, "label": code.replace("_", " ")} for code in signals]
def _sid(value: Any) -> Optional[str]:
return str(value) if value else None
def _iso(value: Any) -> Optional[str]:
return value.isoformat() if value else None
def get_initiative_steering_snapshot(
ctx: TenantContext, *, initiative_id: str
) -> Optional[dict[str, Any]]:
"""Tenant-scoped Graph-Snapshot eines Vorhabens."""
initiative = get_initiative(tenant_id=ctx.tenant_id, initiative_id=initiative_id)
if not initiative:
return None
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, title, description, status, priority, due_at,
created_at, updated_at
FROM actions
WHERE tenant_id = %s AND initiative_id = %s
ORDER BY updated_at DESC
""",
(ctx.tenant_id, initiative_id),
)
actions_raw = [dict(r) for r in cur.fetchall()]
cur.execute(
"""
SELECT id, action_id, title, status, created_at, updated_at
FROM blockers
WHERE tenant_id = %s AND initiative_id = %s
ORDER BY updated_at DESC
""",
(ctx.tenant_id, initiative_id),
)
blockers = [dict(r) for r in cur.fetchall()]
cur.execute(
"""
SELECT id, action_id, milestone_id, title, status, created_at, updated_at
FROM evidence
WHERE tenant_id = %s AND initiative_id = %s
ORDER BY updated_at DESC
""",
(ctx.tenant_id, initiative_id),
)
evidence = [dict(r) for r in cur.fetchall()]
cur.execute(
"""
SELECT id, action_id, milestone_id, title, status, due_at,
created_at, updated_at
FROM reviews
WHERE tenant_id = %s AND initiative_id = %s
ORDER BY updated_at DESC
""",
(ctx.tenant_id, initiative_id),
)
reviews = [dict(r) for r in cur.fetchall()]
cur.execute(
"""
SELECT id, title, status, priority, converted_action_id, item_kind,
parent_backlog_id, created_at, updated_at
FROM backlog_items
WHERE tenant_id = %s AND initiative_id = %s
ORDER BY updated_at DESC
""",
(ctx.tenant_id, initiative_id),
)
backlog = [dict(r) for r in cur.fetchall()]
cur.execute(
"""
SELECT ri.id, ri.title, ri.status, ri.target_date, ri.item_type,
ri.sequencing_mode, ri.sort_order, ri.created_at, ri.updated_at
FROM roadmap_items ri
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
WHERE ri.tenant_id = %s AND r.initiative_id = %s
ORDER BY ri.sort_order ASC, ri.updated_at DESC
""",
(ctx.tenant_id, initiative_id),
)
roadmap_items_raw = [dict(r) for r in cur.fetchall()]
cur.execute(
"""
SELECT id, title, status, outcome, created_at, updated_at
FROM decisions
WHERE tenant_id = %s AND initiative_id = %s
ORDER BY updated_at DESC
""",
(ctx.tenant_id, initiative_id),
)
decisions = [dict(r) for r in cur.fetchall()]
cur.execute(
"""
SELECT id, title, status, next_due_at, created_at, updated_at
FROM recurring_elements
WHERE tenant_id = %s AND initiative_id = %s
ORDER BY updated_at DESC
""",
(ctx.tenant_id, initiative_id),
)
recurring = [dict(r) for r in cur.fetchall()]
finally:
conn.close()
blockers_by_action: dict[str, list[dict]] = {}
unlinked_blockers: list[dict] = []
for b in blockers:
item = {
"id": _sid(b["id"]),
"title": b["title"],
"status": b["status"],
"action_id": _sid(b.get("action_id")),
}
aid = item["action_id"]
if aid:
blockers_by_action.setdefault(aid, []).append(item)
else:
unlinked_blockers.append(item)
evidence_by_action: dict[str, list[dict]] = {}
unlinked_evidence: list[dict] = []
for e in evidence:
item = {
"id": _sid(e["id"]),
"title": e["title"],
"status": e["status"],
"action_id": _sid(e.get("action_id")),
"milestone_id": _sid(e.get("milestone_id")),
}
aid = item["action_id"]
if aid:
evidence_by_action.setdefault(aid, []).append(item)
else:
unlinked_evidence.append(item)
reviews_by_action: dict[str, list[dict]] = {}
reviews_by_milestone: dict[str, list[dict]] = {}
unlinked_reviews: list[dict] = []
planned_reviews_due = 0
for r in reviews:
item = {
"id": _sid(r["id"]),
"title": r["title"],
"status": r["status"],
"due_at": _iso(r.get("due_at")),
"action_id": _sid(r.get("action_id")),
"milestone_id": _sid(r.get("milestone_id")),
}
if r["status"] == "planned" and r.get("due_at"):
planned_reviews_due += 1
aid, mid = item["action_id"], item["milestone_id"]
if aid:
reviews_by_action.setdefault(aid, []).append(item)
elif mid:
reviews_by_milestone.setdefault(mid, []).append(item)
else:
unlinked_reviews.append(item)
actions: list[dict[str, Any]] = []
open_actions = blocked_actions = review_required_actions = 0
for a in actions_raw:
aid = _sid(a["id"])
status = a["status"]
if status in OPEN_ACTION:
open_actions += 1
if status == "blocked":
blocked_actions += 1
if status == "review_required":
review_required_actions += 1
action_blockers = blockers_by_action.get(aid, [])
open_blocker_count = sum(1 for b in action_blockers if b["status"] in OPEN_BLOCKER)
actions.append(
{
"id": aid,
"title": a["title"],
"status": status,
"priority": a["priority"],
"due_at": _iso(a.get("due_at")),
"blockers": action_blockers,
"evidence": evidence_by_action.get(aid, []),
"reviews": reviews_by_action.get(aid, []),
"open_blocker_count": open_blocker_count,
"has_open_blocker": open_blocker_count > 0,
}
)
roadmap_items: list[dict[str, Any]] = []
for m in roadmap_items_raw:
mid = _sid(m["id"])
roadmap_items.append(
{
"id": mid,
"title": m["title"],
"status": m["status"],
"item_type": m["item_type"],
"sequencing_mode": m["sequencing_mode"],
"sort_order": m.get("sort_order", 0),
"target_date": m["target_date"].isoformat() if m.get("target_date") else None,
"reviews": reviews_by_milestone.get(mid, []),
}
)
open_blockers = sum(
1 for b in blockers if b["status"] in OPEN_BLOCKER
)
backlog_new = sum(1 for bi in backlog if bi["status"] in ("new", "triaged", "accepted"))
signals = derive_initiative_signals(
initiative_status=initiative["status"],
open_actions=open_actions,
blocked_actions=blocked_actions,
review_required_actions=review_required_actions,
open_blockers=open_blockers,
backlog_new=backlog_new,
planned_reviews_due=planned_reviews_due,
)
upcoming_milestones = sorted(
[
m
for m in roadmap_items
if m["status"] in ("planned", "active", "at_risk")
],
key=lambda m: (m["target_date"] is None, m["target_date"] or ""),
)[:5]
evaluation = evaluate_steering(ctx, initiative_id=initiative_id, next_work_limit=5)
next_actions = evaluation.next_work
kernel_attention = evaluation.attention
epic_rollup = evaluation.read_models.get("epic_rollup") or []
sprint_commit_proposals = evaluation.proposals.get("sprint_commit") or []
steering = get_steering_context_dto(ctx, initiative_id=initiative_id)
metadata = steering.get("lifecycle_metadata") or {}
if isinstance(metadata, str):
metadata = {}
method_profile_key = metadata.get("method_profile_key")
method_profile_label = None
if method_profile_key:
profile = get_method_profile(method_profile_key)
if profile:
method_profile_label = profile.get("label")
initiative_archetype = initiative.get("archetype_key") or "initiative.generic"
from services.work_cycle import get_active_work_cycle
active_work_cycle = get_active_work_cycle(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
return {
"initiative_id": initiative_id,
"initiative_title": initiative["title"],
"initiative_status": initiative["status"],
"archetype_key": initiative_archetype,
"archetype_label": archetype_label(
entity_type="initiative", archetype_key=initiative_archetype
),
"method_profile_key": method_profile_key,
"method_profile_label": method_profile_label,
"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"],
"steering_guidance": _steering_guidance(
next_actions=next_actions,
signals=signals,
method_key=steering["method_key"],
),
"attention_items": _attention_items(signals)
+ [
{
"code": item.get("reason_code") or item.get("kind", "attention"),
"label": item.get("summary") or item.get("title") or "Attention",
}
for item in kernel_attention[:5]
],
"signals": signals,
"steering_kernel": {
"horizon": evaluation.horizon.to_dict(),
"lifecycle": evaluation.lifecycle.to_dict(),
"primary_method_key": evaluation.binding.primary_method_key,
"composition_modifier": evaluation.binding.composition_modifier,
"data_source": evaluation.data_source,
"read_models": evaluation.read_models,
"proposals": evaluation.proposals,
"agent_slots": evaluation.agent_slots,
},
"upcoming_milestones": upcoming_milestones,
"upcoming_roadmap_items": upcoming_milestones,
"next_actions": next_actions,
"active_work_cycle": active_work_cycle,
"epic_rollup": epic_rollup,
"sprint_commit_proposals": sprint_commit_proposals,
"gate_next_actions_proposals": evaluation.proposals.get("gate_next_actions") or [],
"intake_triage_proposals": evaluation.proposals.get("intake_triage") or [],
"agent_slots": evaluation.agent_slots,
"tech_debt_summary": evaluation.read_models.get("tech_debt_summary") or {},
"counts": {
"actions_open": open_actions,
"actions_blocked": blocked_actions,
"actions_review_required": review_required_actions,
"blockers_open": open_blockers,
"backlog_open": backlog_new,
"decisions": len(decisions),
"reviews_planned_due": planned_reviews_due,
"recurring_active": sum(1 for r in recurring if r["status"] == "active"),
"unlinked_blockers": len(unlinked_blockers),
"unlinked_evidence": len(unlinked_evidence),
"unlinked_reviews": len(unlinked_reviews),
},
"actions": actions,
"roadmap_items": roadmap_items,
"milestones": roadmap_items,
"progression_state": compute_progression_state(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
),
"initiative_level": {
"blockers": unlinked_blockers,
"evidence": unlinked_evidence,
"reviews": unlinked_reviews,
"decisions": [
{"id": _sid(d["id"]), "title": d["title"], "status": d["status"]}
for d in decisions
],
"backlog": [
{
"id": _sid(b["id"]),
"title": b["title"],
"status": b["status"],
"item_kind": b.get("item_kind"),
"parent_backlog_id": _sid(b.get("parent_backlog_id")),
"converted_action_id": _sid(b.get("converted_action_id")),
}
for b in backlog
],
"recurring": [
{
"id": _sid(r["id"]),
"title": r["title"],
"status": r["status"],
"next_due_at": _iso(r.get("next_due_at")),
}
for r in recurring
],
},
"data_source": "initiative_steering_snapshot",
}

View File

@ -0,0 +1,37 @@
"""Initiative read-models for workspace."""
from __future__ import annotations
from typing import Any, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from services.initiatives import _serialize_row
from tenant_context import TenantContext
def get_active_initiatives(
ctx: TenantContext,
*,
limit: Optional[int] = None,
) -> list[dict[str, Any]]:
"""Active and paused initiatives in the current tenant."""
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
sql = """
SELECT id, tenant_id, title, goal, vision, target_state_summary, archetype_key,
status, priority, portfolio_rank, owner_actor_id, created_at, updated_at
FROM initiatives
WHERE tenant_id = %s AND status IN ('active', 'paused')
ORDER BY portfolio_rank ASC NULLS LAST, updated_at DESC, title
"""
params: list[Any] = [ctx.tenant_id]
if limit is not None:
sql += " LIMIT %s"
params.append(limit)
cur.execute(sql, params)
return [_serialize_row(dict(row)) for row in cur.fetchall()]
finally:
conn.close()

View File

@ -0,0 +1,23 @@
"""Portfolio steering read model — K-Ext-5."""
from __future__ import annotations
from typing import Any
from steering.portfolio.aggregate import aggregate_portfolio_steering
from tenant_context import TenantContext
def get_portfolio_steering(
ctx: TenantContext,
*,
max_initiatives: int = 50,
total_attention: int = 30,
total_next_work: int = 15,
) -> dict[str, Any]:
return aggregate_portfolio_steering(
ctx,
max_initiatives=max_initiatives,
total_attention=total_attention,
total_next_work=total_next_work,
)

View File

@ -0,0 +1,70 @@
"""Workspace summary read-model — tenant-scoped KPIs for current actor."""
from __future__ import annotations
from typing import Any
from psycopg2.extras import RealDictCursor
from db import get_connection
from tenant_context import TenantContext
def get_workspace_summary(ctx: TenantContext) -> dict[str, Any]:
"""
Summary for workspace dashboard.
Personal counts (current actor): open, blocked, recently done.
Tenant-wide: active initiatives count.
"""
if not ctx.actor_id:
return {
"open_actions_count": 0,
"blocked_actions_count": 0,
"active_initiatives_count": 0,
"done_actions_recent_count": 0,
}
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT
COUNT(*) FILTER (
WHERE a.status IN ('open', 'in_progress')
) AS open_actions_count,
COUNT(*) FILTER (
WHERE a.status = 'blocked'
) AS blocked_actions_count,
COUNT(*) FILTER (
WHERE a.status = 'done'
AND a.updated_at >= NOW() - INTERVAL '7 days'
) AS done_actions_recent_count
FROM actions a
JOIN action_assignments aa
ON aa.action_id = a.id AND aa.tenant_id = a.tenant_id
WHERE a.tenant_id = %s AND aa.actor_id = %s
""",
(ctx.tenant_id, ctx.actor_id),
)
row = dict(cur.fetchone())
cur.execute(
"""
SELECT COUNT(*) AS active_initiatives_count
FROM initiatives
WHERE tenant_id = %s AND status IN ('active', 'paused')
""",
(ctx.tenant_id,),
)
initiatives_row = dict(cur.fetchone())
finally:
conn.close()
return {
"open_actions_count": int(row["open_actions_count"] or 0),
"blocked_actions_count": int(row["blocked_actions_count"] or 0),
"active_initiatives_count": int(initiatives_row["active_initiatives_count"] or 0),
"done_actions_recent_count": int(row["done_actions_recent_count"] or 0),
}

View File

@ -0,0 +1,21 @@
from entity_archetypes.registry import (
ENTITY_ARCHETYPES,
INITIATIVE_ARCHETYPES,
PROJECT_ARCHETYPES,
archetype_label,
list_archetypes,
mirror_project_archetype_key,
resolve_default_method_key,
validate_archetype_key,
)
__all__ = [
"ENTITY_ARCHETYPES",
"INITIATIVE_ARCHETYPES",
"PROJECT_ARCHETYPES",
"archetype_label",
"list_archetypes",
"mirror_project_archetype_key",
"resolve_default_method_key",
"validate_archetype_key",
]

View File

@ -0,0 +1,211 @@
"""System-Archetypen (AP1.10 + AP2.0) — Seeds, Bindings, Spiegel Project."""
from __future__ import annotations
from typing import Any, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from entity_archetypes.ui_profiles import INITIATIVE_UI_PROFILES, resolve_ui_profile
# initiative suffix → default method (shared by initiative + project mirror)
_METHOD_BY_SUFFIX: dict[str, str] = {
"generic": "generic_operating",
"maturity_journey": "maturity_progression",
"linear_project": "sequential_dependency",
"recurring_program": "recurring_control",
"support_queue": "queue_pull",
"program": "program_delivery",
"product": "continuous_product",
"dispute_case": "dispute_procedure",
"content_project": "chapter_based_progression",
}
_INITIATIVE_SPEC_META: tuple[tuple[str, str, str], ...] = (
("generic", "Allgemeines Vorhaben", "Fallback ohne spezielle Steuerungsvorlage."),
(
"maturity_journey",
"Reifegrad-Entwicklung",
"Mehrstufige Entwicklung, Routinen, Graph-Voraussetzungen.",
),
(
"linear_project",
"Lineares Vorhaben",
"Einmalprojekte mit Pfaden, Gates und Graph.",
),
(
"recurring_program",
"Dauerprogramm / Rhythmus",
"Haushalt, Rhythmen, Abweichungs-Steuerung.",
),
(
"support_queue",
"Inbox / Queue",
"Ad-hoc-Eingang, triage, thematische Zuordnung.",
),
(
"program",
"Programm (begrenzt)",
"Phasen, Release-Programm mit Abschluss.",
),
(
"product",
"Produkt (kontinuierlich)",
"Dauerbetrieb, Wartung, Issues, Weiterentwicklung.",
),
(
"dispute_case",
"Verfahren / Konflikt",
"Reaktive Steuerung, Fristen, Entscheidungen.",
),
(
"content_project",
"Inhalt / Kapitel",
"Bücher, Konzepte, Kapitelstruktur.",
),
)
def _build_specs() -> tuple[dict[str, Any], ...]:
specs: list[dict[str, Any]] = []
for suffix, label, description in _INITIATIVE_SPEC_META:
method = _METHOD_BY_SUFFIX[suffix]
initiative_key = f"initiative.{suffix}"
ui_profile = INITIATIVE_UI_PROFILES.get(initiative_key, {})
specs.append(
{
"key": initiative_key,
"entity_type": "initiative",
"label": label,
"description": description,
"is_system": True,
"default_method_key": method,
"default_method_profile_key": None,
"ui_profile_json": ui_profile,
}
)
specs.append(
{
"key": f"project.{suffix}",
"entity_type": "project",
"label": label,
"description": f"Project-Spiegel: {description}",
"is_system": True,
"default_method_key": method,
"default_method_profile_key": None,
"ui_profile_json": {},
}
)
return tuple(specs)
ENTITY_ARCHETYPES: tuple[dict[str, Any], ...] = _build_specs()
INITIATIVE_ARCHETYPES: tuple[dict[str, Any], ...] = tuple(
item for item in ENTITY_ARCHETYPES if item["entity_type"] == "initiative"
)
PROJECT_ARCHETYPES: tuple[dict[str, Any], ...] = tuple(
item for item in ENTITY_ARCHETYPES if item["entity_type"] == "project"
)
_ARCHETYPE_INDEX: dict[tuple[str, str], dict[str, Any]] = {
(item["entity_type"], item["key"]): item for item in ENTITY_ARCHETYPES
}
_KEY_INDEX: dict[str, dict[str, Any]] = {item["key"]: item for item in ENTITY_ARCHETYPES}
def _list_archetypes_from_memory(*, entity_type: Optional[str] = None) -> list[dict[str, Any]]:
items = ENTITY_ARCHETYPES
if entity_type is not None:
items = tuple(i for i in items if i["entity_type"] == entity_type)
return [_public_archetype(item) for item in items]
def _public_archetype(item: dict[str, Any]) -> dict[str, Any]:
return {
"key": item["key"],
"entity_type": item["entity_type"],
"label": item["label"],
"description": item["description"],
"is_system": item["is_system"],
"default_method_key": item["default_method_key"],
"default_method_profile_key": item.get("default_method_profile_key"),
}
def _enrich_row(row: dict[str, Any]) -> dict[str, Any]:
base = dict(row)
spec = _KEY_INDEX.get(base.get("key", ""))
if spec:
base.setdefault("default_method_key", spec["default_method_key"])
base.setdefault("default_method_profile_key", spec.get("default_method_profile_key"))
return base
def list_archetypes(*, entity_type: Optional[str] = None) -> list[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
if entity_type is None:
cur.execute(
"""
SELECT archetype_key AS key, entity_type, label, description, is_system
FROM entity_archetypes
ORDER BY entity_type ASC, archetype_key ASC
"""
)
else:
cur.execute(
"""
SELECT archetype_key AS key, entity_type, label, description, is_system
FROM entity_archetypes
WHERE entity_type = %s
ORDER BY archetype_key ASC
""",
(entity_type,),
)
rows = cur.fetchall()
if rows:
return [_enrich_row(dict(row)) for row in rows]
except Exception:
pass
finally:
conn.close()
return _list_archetypes_from_memory(entity_type=entity_type)
def validate_archetype_key(*, entity_type: str, archetype_key: str) -> None:
if (entity_type, archetype_key) not in _ARCHETYPE_INDEX:
raise ValueError(f"Unbekannter Archetyp: {archetype_key}")
def archetype_label(*, entity_type: str, archetype_key: str) -> Optional[str]:
item = _ARCHETYPE_INDEX.get((entity_type, archetype_key))
return item["label"] if item else None
def resolve_default_method_key(archetype_key: str) -> str:
spec = _KEY_INDEX.get(archetype_key)
if not spec:
return "generic_operating"
return spec.get("default_method_key", "generic_operating")
def get_ui_profile_json(archetype_key: str) -> dict[str, Any]:
"""UI-Profil aus Registry-Seed (DB-Sync-Quelle)."""
spec = _KEY_INDEX.get(archetype_key)
if spec and spec.get("ui_profile_json"):
return dict(spec["ui_profile_json"])
return resolve_ui_profile(archetype_key)
def mirror_project_archetype_key(initiative_archetype_key: str) -> str:
if initiative_archetype_key.startswith("initiative."):
suffix = initiative_archetype_key[len("initiative.") :]
candidate = f"project.{suffix}"
if ( "project", candidate) in _ARCHETYPE_INDEX:
return candidate
return "project.generic"

View File

@ -0,0 +1,238 @@
"""UI-Profile für Initiative-Archetypen — AP2.3a / AP2.3f."""
from __future__ import annotations
from typing import Any
_PRODUCT_PROCESS = (
{"key": "inbox", "to": "/plan/inbox", "label": "Eingang", "mode": "plan"},
{"key": "sprint-plan", "to": "/plan/sprint", "label": "Sprint planen", "mode": "plan"},
{"key": "sprint-work", "to": "/work/sprint", "label": "Ausführen", "mode": "work"},
{"key": "control", "to": "/control/status", "label": "Kontrolle", "mode": "control"},
)
_LINEAR_PROCESS = (
{"key": "gates", "to": "/plan/gates", "label": "Zielzustände", "mode": "plan"},
{"key": "work", "to": "/work/today", "label": "Ausführen", "mode": "work"},
{"key": "control", "to": "/control/status", "label": "Kontrolle", "mode": "control"},
)
_MATURITY_PROCESS = (
{"key": "gates", "to": "/plan/gates", "label": "Stufen", "mode": "plan"},
{"key": "journey", "to": "/control/journey", "label": "Rhythmen", "mode": "control"},
{"key": "work", "to": "/work/today", "label": "Ausführen", "mode": "work"},
{"key": "control", "to": "/control/status", "label": "Kontrolle", "mode": "control"},
)
_PROGRAM_PROCESS = (
{"key": "gates", "to": "/plan/gates", "label": "Phasen", "mode": "plan"},
{"key": "inbox", "to": "/plan/inbox", "label": "Eingang", "mode": "plan"},
{"key": "work", "to": "/work/today", "label": "Ausführen", "mode": "work"},
{"key": "control", "to": "/control/status", "label": "Kontrolle", "mode": "control"},
)
_SUPPORT_QUEUE_PROCESS = (
{"key": "inbox", "to": "/plan/inbox", "label": "Eingang", "mode": "plan"},
{"key": "work", "to": "/work/today", "label": "Ausführen", "mode": "work"},
{"key": "control", "to": "/control/status", "label": "Kontrolle", "mode": "control"},
)
_CONTENT_PROCESS = (
{"key": "gates", "to": "/plan/gates", "label": "Kapitel", "mode": "plan"},
{"key": "work", "to": "/work/today", "label": "Ausführen", "mode": "work"},
{"key": "control", "to": "/control/status", "label": "Kontrolle", "mode": "control"},
)
_DISPUTE_PROCESS = (
{"key": "work", "to": "/work/today", "label": "Ausführen", "mode": "work"},
{"key": "control", "to": "/control/status", "label": "Kontrolle", "mode": "control"},
)
_COMMON_OM_SLICES = (
"actions",
"blockers",
"evidence",
"decisions",
"reviews",
"steering_methods",
)
GENERIC_UI_PROFILE: dict[str, Any] = {
"processSteps": [],
"planDefaultRoute": "/plan/profile",
"workDefaultRoute": "/work/today",
"controlDefaultRoute": "/control/status",
"planOutlineKeys": None,
"workNavKeys": None,
"dataSlices": [
"actions",
"backlog",
"roadmap",
"projects",
"blockers",
*_COMMON_OM_SLICES[1:],
],
"enabledModes": ["plan", "work", "control"],
}
INITIATIVE_UI_PROFILES: dict[str, dict[str, Any]] = {
"initiative.generic": dict(GENERIC_UI_PROFILE),
"initiative.product": {
"processSteps": list(_PRODUCT_PROCESS),
"planDefaultRoute": "/plan/inbox",
"workDefaultRoute": "/work/sprint",
"workDefaultRouteWithoutActiveWorkCycle": "/work/today",
"controlDefaultRoute": "/control/status",
"planOutlineKeys": ["profile", "inbox", "work", "sprint", "gates"],
"workNavKeys": ["sprint", "today", "mine"],
"dataSlices": [
"backlog",
"actions",
"roadmap",
"work_cycles",
"projects",
"blockers",
"evidence",
"decisions",
"reviews",
"steering_methods",
],
"enabledModes": ["plan", "work", "control"],
},
"initiative.linear_project": {
"processSteps": list(_LINEAR_PROCESS),
"planDefaultRoute": "/plan/gates",
"workDefaultRoute": "/work/today",
"controlDefaultRoute": "/control/status",
"planOutlineKeys": ["profile", "structure", "gates", "inbox", "work"],
"workNavKeys": ["today", "mine", "sprint"],
"dataSlices": [
"backlog",
"actions",
"roadmap",
"projects",
"blockers",
"evidence",
"decisions",
"reviews",
"steering_methods",
],
"enabledModes": ["plan", "work", "control"],
},
"initiative.maturity_journey": {
"processSteps": list(_MATURITY_PROCESS),
"planDefaultRoute": "/plan/gates",
"workDefaultRoute": "/work/today",
"controlDefaultRoute": "/control/status",
"planOutlineKeys": ["profile", "gates", "work"],
"workNavKeys": ["today", "mine", "sprint"],
"dataSlices": [
"actions",
"roadmap",
"blockers",
"evidence",
"decisions",
"reviews",
"recurring",
"steering_methods",
],
"enabledModes": ["plan", "work", "control"],
},
"initiative.program": {
"processSteps": list(_PROGRAM_PROCESS),
"planDefaultRoute": "/plan/gates",
"workDefaultRoute": "/work/today",
"controlDefaultRoute": "/control/status",
"planOutlineKeys": ["profile", "structure", "gates", "inbox", "sprint", "work"],
"workNavKeys": ["today", "mine", "sprint"],
"dataSlices": [
"backlog",
"actions",
"roadmap",
"projects",
"work_cycles",
"blockers",
"evidence",
"decisions",
"reviews",
"steering_methods",
],
"enabledModes": ["plan", "work", "control"],
},
"initiative.support_queue": {
"processSteps": list(_SUPPORT_QUEUE_PROCESS),
"planDefaultRoute": "/plan/inbox",
"workDefaultRoute": "/work/today",
"controlDefaultRoute": "/control/status",
"planOutlineKeys": ["profile", "inbox", "work"],
"workNavKeys": ["today", "mine"],
"dataSlices": [
"backlog",
"actions",
"blockers",
"steering_methods",
],
"enabledModes": ["plan", "work", "control"],
},
"initiative.recurring_program": {
"processSteps": list(_MATURITY_PROCESS),
"planDefaultRoute": "/plan/gates",
"workDefaultRoute": "/work/today",
"controlDefaultRoute": "/control/status",
"planOutlineKeys": ["profile", "gates", "inbox", "work"],
"workNavKeys": ["today", "mine"],
"dataSlices": [
"actions",
"roadmap",
"recurring",
"blockers",
"steering_methods",
],
"enabledModes": ["plan", "work", "control"],
},
"initiative.content_project": {
"processSteps": list(_CONTENT_PROCESS),
"planDefaultRoute": "/plan/gates",
"workDefaultRoute": "/work/today",
"controlDefaultRoute": "/control/status",
"planOutlineKeys": ["profile", "gates", "inbox", "work"],
"workNavKeys": ["today", "mine"],
"dataSlices": [
"actions",
"roadmap",
"blockers",
"evidence",
"decisions",
"reviews",
"steering_methods",
],
"enabledModes": ["plan", "work", "control"],
},
"initiative.dispute_case": {
"processSteps": list(_DISPUTE_PROCESS),
"planDefaultRoute": "/plan/profile",
"workDefaultRoute": "/work/today",
"controlDefaultRoute": "/control/status",
"planOutlineKeys": ["profile", "work"],
"workNavKeys": ["today", "mine"],
"dataSlices": [
"actions",
"blockers",
"evidence",
"decisions",
"reviews",
"steering_methods",
],
"enabledModes": ["plan", "work", "control"],
},
}
def resolve_ui_profile(archetype_key: str) -> dict[str, Any]:
"""UI-Profil aus Code-Seed; unbekannte Archetypen → Generic-Fallback."""
profile = INITIATIVE_UI_PROFILES.get(archetype_key)
resolved = dict(profile) if profile else dict(GENERIC_UI_PROFILE)
slices = resolved.get("dataSlices") or []
resolved["omCapabilities"] = list(resolved.get("omCapabilities") or slices)
resolved.pop("uiFeatures", None)
return resolved

View File

@ -0,0 +1,20 @@
from entity_fields.definitions import FIELD_DEFINITIONS
from entity_fields.service import (
get_entity_field_values,
get_initiative_archetype_key,
list_field_definitions,
patch_entity_field_values,
)
from entity_fields.sync import sync_entity_field_registry_to_db
from entity_fields.validation import normalize_field_value, validate_required_fields
__all__ = [
"FIELD_DEFINITIONS",
"get_entity_field_values",
"get_initiative_archetype_key",
"list_field_definitions",
"normalize_field_value",
"patch_entity_field_values",
"sync_entity_field_registry_to_db",
"validate_required_fields",
]

View File

@ -0,0 +1,88 @@
"""System-Felddefinitionen (Seed) — AP1.10b / AP2.0b."""
from __future__ import annotations
from typing import Any
FIELD_DEFINITIONS: tuple[dict[str, Any], ...] = (
{
"archetype_key": "initiative.program",
"field_key": "stakeholder_map",
"field_type": "longtext",
"label": "Stakeholder-Übersicht",
"required": False,
"searchable": True,
"sort_order": 10,
"validation_json": {},
},
{
"archetype_key": "initiative.program",
"field_key": "success_criteria",
"field_type": "longtext",
"label": "Erfolgskriterien",
"required": False,
"searchable": True,
"sort_order": 20,
"validation_json": {},
},
{
"archetype_key": "initiative.product",
"field_key": "release_theme",
"field_type": "text",
"label": "Release-Thema",
"required": False,
"searchable": True,
"sort_order": 10,
"validation_json": {},
},
{
"archetype_key": "initiative.product",
"field_key": "metrics",
"field_type": "longtext",
"label": "Metriken / KPIs",
"required": False,
"searchable": True,
"sort_order": 20,
"validation_json": {},
},
{
"archetype_key": "initiative.maturity_journey",
"field_key": "current_focus",
"field_type": "text",
"label": "Aktueller Fokus",
"required": False,
"searchable": True,
"sort_order": 10,
"validation_json": {},
},
{
"archetype_key": "initiative.maturity_journey",
"field_key": "training_frequency",
"field_type": "enum",
"label": "Trainingsfrequenz",
"required": False,
"searchable": False,
"sort_order": 20,
"validation_json": {"options": ["daily", "3x_week", "weekly", "flexible"]},
},
{
"archetype_key": "initiative.content_project",
"field_key": "manuscript_status",
"field_type": "text",
"label": "Manuskript-Status",
"required": False,
"searchable": True,
"sort_order": 10,
"validation_json": {},
},
{
"archetype_key": "initiative.content_project",
"field_key": "word_count_goal",
"field_type": "number",
"label": "Wortziel",
"required": False,
"searchable": False,
"sort_order": 20,
"validation_json": {},
},
)

View File

@ -0,0 +1,206 @@
"""Entity Field System — Lesen/Schreiben dynamischer Feldwerte — AP1.10b/10c."""
from __future__ import annotations
from typing import Any, Optional
from psycopg2.extras import Json, RealDictCursor
from db import get_connection
from entity_fields.validation import normalize_field_value, validate_required_fields
def _serialize_definition(row: dict[str, Any]) -> dict[str, Any]:
result = dict(row)
if result.get("id"):
result["id"] = str(result["id"])
validation = result.get("validation_json")
if validation is None:
result["validation_json"] = {}
return result
def _api_value_from_row(row: dict[str, Any], field_type: str) -> Any:
if field_type == "number":
if row.get("value_number") is not None:
return float(row["value_number"])
return None
if field_type == "boolean":
if row.get("value_boolean") is not None:
return bool(row["value_boolean"])
return False
if field_type == "date":
value_date = row.get("value_date")
if value_date is not None:
return value_date.isoformat()
return None
text = row.get("value_text") or ""
return text
def list_field_definitions(
*,
archetype_key: str,
) -> list[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, archetype_key, field_key, field_type, label,
required, searchable, sort_order, validation_json
FROM field_definitions
WHERE archetype_key = %s
ORDER BY sort_order ASC, field_key ASC
""",
(archetype_key,),
)
return [_serialize_definition(dict(row)) for row in cur.fetchall()]
finally:
conn.close()
def get_entity_field_values(
*,
tenant_id: str,
entity_type: str,
entity_id: str,
definitions: list[dict[str, Any]],
) -> dict[str, Any]:
if not definitions:
return {}
field_keys = [item["field_key"] for item in definitions]
type_by_key = {item["field_key"]: item["field_type"] for item in definitions}
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT field_key, value_text, value_number, value_boolean, value_date
FROM field_values
WHERE tenant_id = %s
AND entity_type = %s
AND entity_id = %s
AND field_key = ANY(%s)
""",
(tenant_id, entity_type, entity_id, field_keys),
)
rows = {row["field_key"]: dict(row) for row in cur.fetchall()}
finally:
conn.close()
result: dict[str, Any] = {}
for field_key in field_keys:
row = rows.get(field_key)
if row is None:
field_type = type_by_key[field_key]
if field_type == "boolean":
result[field_key] = False
else:
result[field_key] = None if field_type in {"number", "date"} else ""
else:
result[field_key] = _api_value_from_row(row, type_by_key[field_key])
return result
def patch_entity_field_values(
*,
tenant_id: str,
entity_type: str,
entity_id: str,
definitions: list[dict[str, Any]],
values: dict[str, Any],
) -> dict[str, Any]:
if not values:
return get_entity_field_values(
tenant_id=tenant_id,
entity_type=entity_type,
entity_id=entity_id,
definitions=definitions,
)
allowed_keys = {item["field_key"] for item in definitions}
unknown = sorted(set(values.keys()) - allowed_keys)
if unknown:
raise ValueError(f"Unbekannte Felder: {', '.join(unknown)}")
definition_by_key = {item["field_key"]: item for item in definitions}
merged = get_entity_field_values(
tenant_id=tenant_id,
entity_type=entity_type,
entity_id=entity_id,
definitions=definitions,
)
merged.update(values)
validate_required_fields(definitions=definitions, values=merged)
conn = get_connection()
try:
with conn.cursor() as cur:
for field_key, raw_value in values.items():
definition = definition_by_key[field_key]
normalized = normalize_field_value(
field_type=definition["field_type"],
raw_value=raw_value,
validation_json=definition.get("validation_json"),
)
cur.execute(
"""
INSERT INTO field_values (
tenant_id, entity_type, entity_id, field_key,
value_json, value_text, value_number, value_boolean, value_date
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (tenant_id, entity_type, entity_id, field_key) DO UPDATE SET
value_json = EXCLUDED.value_json,
value_text = EXCLUDED.value_text,
value_number = EXCLUDED.value_number,
value_boolean = EXCLUDED.value_boolean,
value_date = EXCLUDED.value_date,
updated_at = NOW()
""",
(
tenant_id,
entity_type,
entity_id,
field_key,
Json(normalized["value_json"]) if normalized["value_json"] is not None else None,
normalized["value_text"],
normalized["value_number"],
normalized["value_boolean"],
normalized["value_date"],
),
)
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
return get_entity_field_values(
tenant_id=tenant_id,
entity_type=entity_type,
entity_id=entity_id,
definitions=definitions,
)
def get_initiative_archetype_key(*, tenant_id: str, initiative_id: str) -> Optional[str]:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT archetype_key
FROM initiatives
WHERE tenant_id = %s AND id = %s
""",
(tenant_id, initiative_id),
)
row = cur.fetchone()
return row[0] if row else None
finally:
conn.close()

View File

@ -0,0 +1,82 @@
"""Sync Entity-Archetypen und Felddefinitionen in PostgreSQL — AP1.10b / AP2.0b."""
from __future__ import annotations
import json
from db import get_connection
from entity_archetypes.registry import ENTITY_ARCHETYPES
from entity_fields.definitions import FIELD_DEFINITIONS
def sync_entity_field_registry_to_db() -> int:
conn = get_connection()
try:
with conn.cursor() as cur:
for archetype in ENTITY_ARCHETYPES:
ui_profile = archetype.get("ui_profile_json") or {}
cur.execute(
"""
INSERT INTO entity_archetypes (
archetype_key, entity_type, label, description, is_system,
ui_profile_json
)
VALUES (%s, %s, %s, %s, %s, %s::jsonb)
ON CONFLICT (archetype_key) DO UPDATE SET
entity_type = EXCLUDED.entity_type,
label = EXCLUDED.label,
description = EXCLUDED.description,
is_system = EXCLUDED.is_system,
ui_profile_json = EXCLUDED.ui_profile_json,
updated_at = NOW()
""",
(
archetype["key"],
archetype["entity_type"],
archetype["label"],
archetype["description"],
archetype["is_system"],
json.dumps(ui_profile),
),
)
for field_def in FIELD_DEFINITIONS:
validation_json = field_def.get("validation_json") or {}
cur.execute(
"""
INSERT INTO field_definitions (
archetype_key, field_key, field_type, label,
required, searchable, sort_order, validation_json
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb)
ON CONFLICT (archetype_key, field_key) DO UPDATE SET
field_type = EXCLUDED.field_type,
label = EXCLUDED.label,
required = EXCLUDED.required,
searchable = EXCLUDED.searchable,
sort_order = EXCLUDED.sort_order,
validation_json = EXCLUDED.validation_json,
updated_at = NOW()
""",
(
field_def["archetype_key"],
field_def["field_key"],
field_def["field_type"],
field_def["label"],
field_def["required"],
field_def["searchable"],
field_def["sort_order"],
json.dumps(validation_json),
),
)
conn.commit()
print(
"[entity_fields] Sync OK — "
f"{len(ENTITY_ARCHETYPES)} Archetyp(en), "
f"{len(FIELD_DEFINITIONS)} Felddefinition(en)"
)
return 0
except Exception as exc:
conn.rollback()
print(f"[entity_fields] Sync FAIL: {exc}")
return 1

View File

@ -0,0 +1,153 @@
"""Validierung dynamischer Feldwerte gegen Definitionen — AP1.10b/10c."""
from __future__ import annotations
import re
from datetime import date, datetime
from typing import Any, Optional
from urllib.parse import urlparse
VALID_FIELD_TYPES = frozenset(
{"text", "longtext", "number", "boolean", "date", "enum", "actor_ref", "url"}
)
_URL_SCHEME_RE = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.-]*:")
def _parse_date(value: str) -> date:
try:
return date.fromisoformat(value)
except ValueError as exc:
raise ValueError(f"Ungültiges Datum: {value}") from exc
def _validate_url(value: str) -> None:
if not value.strip():
return
parsed = urlparse(value.strip())
if parsed.scheme and parsed.netloc:
return
if _URL_SCHEME_RE.match(value.strip()):
return
raise ValueError(f"Ungültige URL: {value}")
def normalize_field_value(
*,
field_type: str,
raw_value: Any,
validation_json: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
"""Wert normalisieren und in DB-Spalten aufteilen."""
validation_json = validation_json or {}
if field_type in {"text", "longtext", "url", "actor_ref", "enum"}:
if raw_value is None:
text = ""
elif not isinstance(raw_value, str):
raise ValueError(f"Feld erwartet Text, erhalten: {type(raw_value).__name__}")
else:
text = raw_value.strip()
if field_type == "url" and text:
_validate_url(text)
if field_type == "enum" and text:
options = validation_json.get("options") or []
if options and text not in options:
raise ValueError(f"Ungültiger Enum-Wert: {text}")
return {
"value_json": None,
"value_text": text,
"value_number": None,
"value_boolean": None,
"value_date": None,
"api_value": text,
}
if field_type == "number":
if raw_value is None or raw_value == "":
return {
"value_json": None,
"value_text": "",
"value_number": None,
"value_boolean": None,
"value_date": None,
"api_value": None,
}
try:
number = float(raw_value)
except (TypeError, ValueError) as exc:
raise ValueError(f"Ungültige Zahl: {raw_value}") from exc
return {
"value_json": None,
"value_text": str(number),
"value_number": number,
"value_boolean": None,
"value_date": None,
"api_value": number,
}
if field_type == "boolean":
if raw_value is None:
bool_val = False
elif isinstance(raw_value, bool):
bool_val = raw_value
elif isinstance(raw_value, str):
lowered = raw_value.strip().lower()
if lowered in {"true", "1", "yes", "on"}:
bool_val = True
elif lowered in {"false", "0", "no", "off", ""}:
bool_val = False
else:
raise ValueError(f"Ungültiger Boolean-Wert: {raw_value}")
else:
bool_val = bool(raw_value)
return {
"value_json": None,
"value_text": "true" if bool_val else "false",
"value_number": None,
"value_boolean": bool_val,
"value_date": None,
"api_value": bool_val,
}
if field_type == "date":
if raw_value is None or raw_value == "":
return {
"value_json": None,
"value_text": "",
"value_number": None,
"value_boolean": None,
"value_date": None,
"api_value": None,
}
if isinstance(raw_value, date) and not isinstance(raw_value, datetime):
parsed = raw_value
elif isinstance(raw_value, str):
parsed = _parse_date(raw_value)
else:
raise ValueError(f"Ungültiges Datum: {raw_value}")
iso = parsed.isoformat()
return {
"value_json": None,
"value_text": iso,
"value_number": None,
"value_boolean": None,
"value_date": parsed,
"api_value": iso,
}
raise ValueError(f"Unbekannter Feldtyp: {field_type}")
def validate_required_fields(
*,
definitions: list[dict[str, Any]],
values: dict[str, Any],
) -> None:
for definition in definitions:
if not definition.get("required"):
continue
key = definition["field_key"]
raw = values.get(key)
if raw is None or (isinstance(raw, str) and not raw.strip()):
raise ValueError(f"Pflichtfeld fehlt: {definition.get('label') or key}")

View File

@ -23,8 +23,9 @@ fi
if [ "${SKIP_REGISTRY_SYNC}" != "1" ] && [ "${SKIP_REGISTRY_SYNC}" != "true" ] && [ "${SKIP_REGISTRY_SYNC}" != "yes" ]; then if [ "${SKIP_REGISTRY_SYNC}" != "1" ] && [ "${SKIP_REGISTRY_SYNC}" != "true" ] && [ "${SKIP_REGISTRY_SYNC}" != "yes" ]; then
python sync_prompt_feature_config.py python sync_prompt_feature_config.py
python sync_entity_field_registry.py
else else
echo "[SKIP_REGISTRY_SYNC] Feature/Prompt/Config-Sync übersprungen" echo "[SKIP_REGISTRY_SYNC] Feature/Prompt/Config/EFS-Sync übersprungen"
fi fi
export KAIRO_DB_READY=1 export KAIRO_DB_READY=1

View File

@ -11,6 +11,8 @@ from fastapi.middleware.cors import CORSMiddleware
from db import check_db from db import check_db
from version import APP_NAME, APP_VERSION, DB_SCHEMA_VERSION from version import APP_NAME, APP_VERSION, DB_SCHEMA_VERSION
import steering # noqa: F401 — bootstrap hooks & methods
if os.getenv("KAIRO_DB_READY") != "1": if os.getenv("KAIRO_DB_READY") != "1":
if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() not in ("1", "true", "yes"): if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() not in ("1", "true", "yes"):
import run_migrations import run_migrations
@ -53,13 +55,64 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
from routers import auth, config, features, me, prompts # noqa: E402 from routers import ( # noqa: E402
actions,
actors,
auth,
backlog,
blockers,
config,
decisions,
entity_archetypes,
evidence,
execution_plan,
features,
initiatives,
journey,
me,
milestones,
projects,
roadmap,
prompts,
recurring,
reviews,
steering,
tasks,
workspace,
operational,
work_cycles,
)
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(me.router) app.include_router(me.router)
app.include_router(features.router) app.include_router(features.router)
app.include_router(prompts.router) app.include_router(prompts.router)
app.include_router(config.router) app.include_router(config.router)
app.include_router(initiatives.router)
app.include_router(entity_archetypes.router)
app.include_router(journey.initiative_router)
app.include_router(journey.roadmap_router)
app.include_router(actions.router)
app.include_router(projects.initiative_router)
app.include_router(projects.items_router)
app.include_router(tasks.router)
app.include_router(blockers.router)
app.include_router(backlog.router)
app.include_router(milestones.router)
app.include_router(roadmap.initiative_router)
app.include_router(roadmap.items_router)
app.include_router(roadmap.deps_router)
app.include_router(roadmap.criteria_router)
app.include_router(execution_plan.initiative_router)
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)
app.include_router(operational.router)
app.include_router(work_cycles.initiative_router)
@app.get("/api/health") @app.get("/api/health")

View File

@ -0,0 +1 @@
"""Package marker for method profile seeds."""

View File

@ -0,0 +1,73 @@
"""Referenz-Ausprägungen (Method Profiles) — Code-Seeds AP2.0b."""
from __future__ import annotations
from typing import Any, Optional
METHOD_PROFILES: tuple[dict[str, Any], ...] = (
{
"key": "maturity.karate_kumite",
"label": "Karate Kumite",
"description": "Mehrere Fähigkeiten mit Stufen, Routinen und Voraussetzungs-Graph.",
"initiative_archetype_key": "initiative.maturity_journey",
"method_key": "maturity_progression",
"seed_hints": {
"skill_count": 8,
"stages_per_skill": "5-7",
"graph_prerequisites": True,
},
},
{
"key": "content.book_writing",
"label": "Buch schreiben",
"description": "Kapitelstruktur, Schreib-APs, Review-Gates.",
"initiative_archetype_key": "initiative.content_project",
"method_key": "chapter_based_progression",
"seed_hints": {
"roadmap_item_type": "chapter",
"review_gates": True,
},
},
{
"key": "product.kairo_dev",
"label": "Kairo Entwicklung",
"description": "Kontinuierliches Produkt mit Gate-Orientierung und optionaler Sprint-Zeitbox.",
"initiative_archetype_key": "initiative.product",
"method_key": "continuous_product",
"seed_hints": {
"agile_iteration_profile": True,
"default_actor_setup": "po_plus_vibe_coder",
},
},
{
"key": "project.agile_iteration",
"label": "Projekt mit Sprint",
"description": "Lineares Projekt (A2) mit optionaler Agile-Iteration.",
"initiative_archetype_key": "initiative.linear_project",
"method_key": "sequential_dependency",
"seed_hints": {
"agile_iteration_profile": True,
},
},
)
_PROFILE_INDEX = {item["key"]: item for item in METHOD_PROFILES}
def get_method_profile(key: str) -> Optional[dict[str, Any]]:
item = _PROFILE_INDEX.get(key)
return dict(item) if item else None
def list_method_profiles(*, initiative_archetype_key: Optional[str] = None) -> list[dict[str, Any]]:
items = METHOD_PROFILES
if initiative_archetype_key:
items = tuple(
i for i in items if i["initiative_archetype_key"] == initiative_archetype_key
)
return [dict(item) for item in items]
def resolve_method_for_profile(profile_key: str) -> Optional[str]:
profile = get_method_profile(profile_key)
return profile["method_key"] if profile else None

View File

@ -0,0 +1,49 @@
-- AP0.5: Minimal initiatives (Vorhaben), actions (Maßnahmen), action_assignments
CREATE TABLE initiatives (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
goal TEXT NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'paused', 'completed', 'archived')),
priority VARCHAR(16) NOT NULL DEFAULT 'normal'
CHECK (priority IN ('low', 'normal', 'high')),
owner_actor_id UUID NOT NULL REFERENCES actors(id) ON DELETE RESTRICT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_initiatives_tenant ON initiatives(tenant_id);
CREATE INDEX idx_initiatives_owner ON initiatives(owner_actor_id);
CREATE TABLE actions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'in_progress', 'blocked', 'done', 'discarded')),
priority VARCHAR(16) NOT NULL DEFAULT 'normal'
CHECK (priority IN ('low', 'normal', 'high')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_actions_tenant ON actions(tenant_id);
CREATE INDEX idx_actions_initiative ON actions(initiative_id);
CREATE INDEX idx_actions_status ON actions(tenant_id, status);
CREATE TABLE action_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
action_id UUID NOT NULL REFERENCES actions(id) ON DELETE CASCADE,
actor_id UUID NOT NULL REFERENCES actors(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (action_id, actor_id)
);
CREATE INDEX idx_action_assignments_tenant ON action_assignments(tenant_id);
CREATE INDEX idx_action_assignments_actor ON action_assignments(actor_id);
CREATE INDEX idx_action_assignments_action ON action_assignments(action_id);

View File

@ -0,0 +1,52 @@
-- AP0.8: Operating Model Extension I — blockers, backlog_items, milestones
CREATE TABLE blockers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
action_id UUID NULL REFERENCES actions(id) ON DELETE SET NULL,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'in_progress', 'resolved', 'accepted_risk', 'dismissed')),
reported_by_actor_id UUID NULL REFERENCES actors(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_blockers_tenant_initiative ON blockers(tenant_id, initiative_id);
CREATE INDEX idx_blockers_tenant_status ON blockers(tenant_id, status);
CREATE TABLE backlog_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'new'
CHECK (status IN ('new', 'triaged', 'accepted', 'rejected', 'converted')),
priority VARCHAR(16) NOT NULL DEFAULT 'normal'
CHECK (priority IN ('low', 'normal', 'high')),
converted_action_id UUID NULL REFERENCES actions(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_backlog_items_tenant_initiative ON backlog_items(tenant_id, initiative_id);
CREATE INDEX idx_backlog_items_tenant_status ON backlog_items(tenant_id, status);
CREATE TABLE milestones (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
goal_description TEXT NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'planned'
CHECK (status IN ('planned', 'active', 'at_risk', 'reached', 'moved', 'discarded')),
target_date DATE NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_milestones_tenant_initiative ON milestones(tenant_id, initiative_id);
CREATE INDEX idx_milestones_tenant_status ON milestones(tenant_id, status);

View File

@ -0,0 +1,82 @@
-- AP0.9: Operating Model Extension II — evidence, decisions, reviews, recurring_elements, action due_at
ALTER TABLE actions ADD COLUMN due_at TIMESTAMPTZ NULL;
ALTER TABLE actions DROP CONSTRAINT actions_status_check;
ALTER TABLE actions ADD CONSTRAINT actions_status_check
CHECK (status IN (
'open', 'ready', 'in_progress', 'blocked', 'review_required', 'done', 'discarded'
));
CREATE INDEX idx_actions_due_at ON actions(tenant_id, due_at) WHERE due_at IS NOT NULL;
CREATE TABLE evidence (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
action_id UUID NULL REFERENCES actions(id) ON DELETE SET NULL,
milestone_id UUID NULL REFERENCES milestones(id) ON DELETE SET NULL,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'submitted'
CHECK (status IN ('submitted', 'accepted', 'rejected')),
submitted_by_actor_id UUID NULL REFERENCES actors(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_evidence_tenant_initiative ON evidence(tenant_id, initiative_id);
CREATE TABLE decisions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'proposed'
CHECK (status IN ('proposed', 'decided', 'superseded')),
outcome TEXT NOT NULL DEFAULT '',
decided_by_actor_id UUID NULL REFERENCES actors(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_decisions_tenant_initiative ON decisions(tenant_id, initiative_id);
CREATE TABLE reviews (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
action_id UUID NULL REFERENCES actions(id) ON DELETE SET NULL,
milestone_id UUID NULL REFERENCES milestones(id) ON DELETE SET NULL,
title VARCHAR(255) NOT NULL,
summary TEXT NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'planned'
CHECK (status IN ('planned', 'completed', 'skipped')),
due_at TIMESTAMPTZ NULL,
reviewed_by_actor_id UUID NULL REFERENCES actors(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_reviews_tenant_initiative ON reviews(tenant_id, initiative_id);
CREATE INDEX idx_reviews_due_at ON reviews(tenant_id, due_at) WHERE due_at IS NOT NULL;
CREATE TABLE recurring_elements (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'paused', 'ended')),
interval_days INT NULL CHECK (interval_days IS NULL OR interval_days > 0),
next_due_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_recurring_elements_tenant_initiative ON recurring_elements(tenant_id, initiative_id);
CREATE INDEX idx_recurring_elements_next_due ON recurring_elements(tenant_id, next_due_at)
WHERE next_due_at IS NOT NULL;

View File

@ -0,0 +1,41 @@
-- AP1.0: Steering Foundation — steering_contexts
CREATE TABLE steering_contexts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
method_key VARCHAR(64) NOT NULL DEFAULT 'generic_operating',
method_version VARCHAR(32) NOT NULL DEFAULT '0.1.0',
lifecycle_state VARCHAR(64) NOT NULL DEFAULT 'intake',
lifecycle_metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, initiative_id)
);
CREATE INDEX idx_steering_contexts_tenant ON steering_contexts(tenant_id);
CREATE INDEX idx_steering_contexts_initiative ON steering_contexts(tenant_id, initiative_id);
INSERT INTO steering_contexts (tenant_id, initiative_id, lifecycle_state)
SELECT
i.tenant_id,
i.id,
CASE
WHEN i.status IN ('completed', 'archived') THEN 'closure'
WHEN EXISTS (
SELECT 1 FROM actions a
WHERE a.initiative_id = i.id
AND a.tenant_id = i.tenant_id
AND a.status IN (
'open', 'ready', 'in_progress', 'blocked', 'review_required'
)
) THEN 'action_selection'
WHEN EXISTS (
SELECT 1 FROM backlog_items bi
WHERE bi.initiative_id = i.id
AND bi.tenant_id = i.tenant_id
AND bi.status IN ('new', 'triaged', 'accepted')
) THEN 'structure_setup'
ELSE 'planning'
END
FROM initiatives i;

View File

@ -0,0 +1,86 @@
-- AP1.4: Roadmap + RoadmapItem + Dependencies (methodenneutral)
CREATE TABLE roadmaps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL DEFAULT 'Plan',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, initiative_id)
);
CREATE INDEX idx_roadmaps_tenant_initiative ON roadmaps(tenant_id, initiative_id);
CREATE TABLE roadmap_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
roadmap_id UUID NOT NULL REFERENCES roadmaps(id) ON DELETE CASCADE,
item_type VARCHAR(32) NOT NULL DEFAULT 'milestone'
CHECK (item_type IN ('milestone', 'review_gate', 'maturity_stage')),
title VARCHAR(255) NOT NULL,
goal_description TEXT NOT NULL DEFAULT '',
definition_of_done JSONB NOT NULL DEFAULT '[]'::jsonb,
status VARCHAR(32) NOT NULL DEFAULT 'planned'
CHECK (status IN ('planned', 'active', 'at_risk', 'reached', 'moved', 'discarded')),
sequencing_mode VARCHAR(32) NOT NULL DEFAULT 'sequential'
CHECK (sequencing_mode IN ('sequential', 'parallel', 'optional')),
target_date DATE NULL,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_roadmap_items_tenant_roadmap ON roadmap_items(tenant_id, roadmap_id);
CREATE INDEX idx_roadmap_items_tenant_status ON roadmap_items(tenant_id, status);
CREATE INDEX idx_roadmap_items_tenant_type ON roadmap_items(tenant_id, item_type);
CREATE TABLE roadmap_item_dependencies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
from_item_id UUID NOT NULL REFERENCES roadmap_items(id) ON DELETE CASCADE,
to_item_id UUID NOT NULL REFERENCES roadmap_items(id) ON DELETE CASCADE,
dependency_type VARCHAR(32) NOT NULL DEFAULT 'requires'
CHECK (dependency_type IN ('requires', 'blocks', 'related')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, from_item_id, to_item_id, dependency_type)
);
CREATE INDEX idx_roadmap_deps_from ON roadmap_item_dependencies(tenant_id, from_item_id);
CREATE INDEX idx_roadmap_deps_to ON roadmap_item_dependencies(tenant_id, to_item_id);
ALTER TABLE backlog_items
ADD COLUMN roadmap_item_id UUID NULL REFERENCES roadmap_items(id) ON DELETE SET NULL;
ALTER TABLE actions
ADD COLUMN roadmap_item_id UUID NULL REFERENCES roadmap_items(id) ON DELETE SET NULL;
CREATE INDEX idx_backlog_roadmap_item ON backlog_items(tenant_id, roadmap_item_id)
WHERE roadmap_item_id IS NOT NULL;
CREATE INDEX idx_actions_roadmap_item ON actions(tenant_id, roadmap_item_id)
WHERE roadmap_item_id IS NOT NULL;
-- Ein Plan pro Initiative
INSERT INTO roadmaps (tenant_id, initiative_id, title)
SELECT tenant_id, id, 'Plan' FROM initiatives;
-- Bestehende Meilensteine → RoadmapItems (IDs beibehalten für Evidence/Review-FKs)
INSERT INTO roadmap_items (
id, tenant_id, roadmap_id, item_type, title, goal_description, status,
sequencing_mode, target_date, sort_order, created_at, updated_at
)
SELECT
m.id,
m.tenant_id,
r.id,
'milestone',
m.title,
m.goal_description,
m.status,
'sequential',
m.target_date,
0,
m.created_at,
m.updated_at
FROM milestones m
JOIN roadmaps r ON r.initiative_id = m.initiative_id AND r.tenant_id = m.tenant_id;

View File

@ -0,0 +1,29 @@
-- AP1.4b: Plan-Ist-Verknüpfung für Evidence und Reviews
ALTER TABLE evidence
ADD COLUMN roadmap_item_id UUID NULL REFERENCES roadmap_items(id) ON DELETE SET NULL;
ALTER TABLE reviews
ADD COLUMN roadmap_item_id UUID NULL REFERENCES roadmap_items(id) ON DELETE SET NULL;
CREATE INDEX idx_evidence_roadmap_item ON evidence(tenant_id, roadmap_item_id)
WHERE roadmap_item_id IS NOT NULL;
CREATE INDEX idx_reviews_roadmap_item ON reviews(tenant_id, roadmap_item_id)
WHERE roadmap_item_id IS NOT NULL;
-- Bestehende milestone_id-Verknüpfungen spiegeln (RoadmapItem behält oft dieselbe UUID)
UPDATE evidence e
SET roadmap_item_id = e.milestone_id
WHERE e.milestone_id IS NOT NULL
AND EXISTS (
SELECT 1 FROM roadmap_items ri
WHERE ri.id = e.milestone_id AND ri.tenant_id = e.tenant_id
);
UPDATE reviews r
SET roadmap_item_id = r.milestone_id
WHERE r.milestone_id IS NOT NULL
AND EXISTS (
SELECT 1 FROM roadmap_items ri
WHERE ri.id = r.milestone_id AND ri.tenant_id = r.tenant_id
);

View File

@ -0,0 +1,76 @@
-- AP1.4b: Gate-Checkliste (Prüfplan pro RoadmapItem)
CREATE TABLE roadmap_item_criteria (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
roadmap_item_id UUID NOT NULL REFERENCES roadmap_items(id) ON DELETE CASCADE,
sort_order INT NOT NULL DEFAULT 0,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
criterion_kind VARCHAR(32) NOT NULL DEFAULT 'evidence_required'
CHECK (criterion_kind IN ('manual', 'evidence_required', 'review_required', 'metric')),
verification_hint TEXT NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'satisfied', 'waived', 'deferred', 'failed')),
waived_or_deferred_decision_id UUID NULL REFERENCES decisions(id) ON DELETE SET NULL,
satisfied_by_evidence_id UUID NULL REFERENCES evidence(id) ON DELETE SET NULL,
satisfied_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_roadmap_criteria_item ON roadmap_item_criteria(tenant_id, roadmap_item_id);
CREATE INDEX idx_roadmap_criteria_status ON roadmap_item_criteria(tenant_id, status);
-- Compat: Default-Kriterium pro bestehendem Plan-Element
INSERT INTO roadmap_item_criteria (
tenant_id, roadmap_item_id, sort_order, title, description, criterion_kind, status
)
SELECT
ri.tenant_id,
ri.id,
0,
'Gate allgemein',
COALESCE(NULLIF(ri.goal_description, ''), 'Akzeptierter Nachweis oder abgeschlossenes Review am Plan-Element.'),
'evidence_required',
'open'
FROM roadmap_items ri;
-- Bereits vorhandene accepted Evidence → Default-Kriterium satisfied
UPDATE roadmap_item_criteria ric
SET
status = 'satisfied',
satisfied_at = NOW(),
satisfied_by_evidence_id = sub.evidence_id
FROM (
SELECT DISTINCT ON (ric2.id)
ric2.id AS criterion_id,
e.id AS evidence_id
FROM roadmap_item_criteria ric2
JOIN evidence e
ON e.tenant_id = ric2.tenant_id
AND e.roadmap_item_id = ric2.roadmap_item_id
AND e.status = 'accepted'
WHERE ric2.title = 'Gate allgemein'
ORDER BY ric2.id, e.updated_at DESC
) sub
WHERE ric.id = sub.criterion_id;
-- Bereits reached Gates ohne Evidence: Review completed
UPDATE roadmap_item_criteria ric
SET status = 'satisfied', satisfied_at = NOW()
FROM roadmap_items ri
WHERE ric.roadmap_item_id = ri.id
AND ric.tenant_id = ri.tenant_id
AND ric.title = 'Gate allgemein'
AND ric.status = 'open'
AND ri.status = 'reached'
AND EXISTS (
SELECT 1 FROM reviews rev
WHERE rev.tenant_id = ri.tenant_id
AND rev.initiative_id IN (
SELECT r.initiative_id FROM roadmaps r WHERE r.id = ri.roadmap_id
)
AND rev.status = 'completed'
AND (rev.roadmap_item_id = ri.id OR rev.milestone_id = ri.id)
);

View File

@ -0,0 +1,45 @@
-- AP1.5: Project, Task, Action.project_id — operative Ist-Hierarchie
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'paused', 'completed', 'archived')),
roadmap_item_id UUID NULL REFERENCES roadmap_items(id) ON DELETE SET NULL,
sort_order INT NOT NULL DEFAULT 0,
target_date DATE NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_projects_tenant_initiative ON projects(tenant_id, initiative_id);
CREATE INDEX idx_projects_tenant_status ON projects(tenant_id, status);
CREATE INDEX idx_projects_roadmap_item ON projects(tenant_id, roadmap_item_id)
WHERE roadmap_item_id IS NOT NULL;
ALTER TABLE actions
ADD COLUMN project_id UUID NULL REFERENCES projects(id) ON DELETE SET NULL;
CREATE INDEX idx_actions_project ON actions(tenant_id, project_id)
WHERE project_id IS NOT NULL;
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
action_id UUID NOT NULL REFERENCES actions(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL DEFAULT '',
status VARCHAR(32) NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'in_progress', 'done', 'discarded')),
roadmap_item_id UUID NULL REFERENCES roadmap_items(id) ON DELETE SET NULL,
sort_order INT NOT NULL DEFAULT 0,
due_at TIMESTAMPTZ NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_tasks_tenant_action ON tasks(tenant_id, action_id);
CREATE INDEX idx_tasks_tenant_status ON tasks(tenant_id, status);

View File

@ -0,0 +1,12 @@
-- AP1.5c: Rekursive Projects (parent_project_id, container_kind)
ALTER TABLE projects
ADD COLUMN parent_project_id UUID NULL REFERENCES projects(id) ON DELETE SET NULL,
ADD COLUMN container_kind VARCHAR(32) NULL
CHECK (
container_kind IS NULL
OR container_kind IN ('project', 'stream', 'phase', 'release')
);
CREATE INDEX idx_projects_parent ON projects(tenant_id, parent_project_id)
WHERE parent_project_id IS NOT NULL;

View File

@ -0,0 +1,21 @@
-- AP1.12c: Backlog sort_order für Reorder im Plan-Modus
ALTER TABLE backlog_items
ADD COLUMN IF NOT EXISTS sort_order INT NOT NULL DEFAULT 0;
WITH ranked AS (
SELECT
id,
(ROW_NUMBER() OVER (
PARTITION BY tenant_id, initiative_id
ORDER BY created_at ASC, id ASC
) - 1) * 10 AS next_order
FROM backlog_items
)
UPDATE backlog_items bi
SET sort_order = ranked.next_order
FROM ranked
WHERE bi.id = ranked.id;
CREATE INDEX IF NOT EXISTS idx_backlog_items_sort
ON backlog_items (tenant_id, initiative_id, sort_order);

View File

@ -0,0 +1,16 @@
-- AP1.10a: Initiative Standardfelder + Archetyp-Schlüssel
ALTER TABLE initiatives
ADD COLUMN IF NOT EXISTS vision TEXT NOT NULL DEFAULT '';
ALTER TABLE initiatives
ADD COLUMN IF NOT EXISTS target_state_summary TEXT NOT NULL DEFAULT '';
ALTER TABLE initiatives
ADD COLUMN IF NOT EXISTS archetype_key VARCHAR(64) NOT NULL DEFAULT 'initiative.generic';
CREATE INDEX IF NOT EXISTS idx_initiatives_archetype
ON initiatives (tenant_id, archetype_key);
CREATE INDEX IF NOT EXISTS idx_initiatives_status_tenant
ON initiatives (tenant_id, status);

View File

@ -0,0 +1,59 @@
-- AP1.10b: Entity Field System (EFS) — Archetypen, Felddefinitionen, Werte
CREATE TABLE IF NOT EXISTS entity_archetypes (
archetype_key VARCHAR(64) PRIMARY KEY,
entity_type VARCHAR(64) NOT NULL,
label VARCHAR(255) NOT NULL,
description TEXT NOT NULL DEFAULT '',
is_system BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_entity_archetypes_entity_type
ON entity_archetypes (entity_type);
CREATE TABLE IF NOT EXISTS field_definitions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
archetype_key VARCHAR(64) NOT NULL
REFERENCES entity_archetypes (archetype_key) ON DELETE CASCADE,
field_key VARCHAR(64) NOT NULL,
field_type VARCHAR(32) NOT NULL
CHECK (field_type IN (
'text', 'longtext', 'number', 'boolean', 'date', 'enum', 'actor_ref', 'url'
)),
label VARCHAR(255) NOT NULL,
required BOOLEAN NOT NULL DEFAULT FALSE,
searchable BOOLEAN NOT NULL DEFAULT FALSE,
sort_order INTEGER NOT NULL DEFAULT 0,
validation_json JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (archetype_key, field_key)
);
CREATE INDEX IF NOT EXISTS idx_field_definitions_archetype
ON field_definitions (archetype_key, sort_order);
CREATE TABLE IF NOT EXISTS field_values (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants (id) ON DELETE CASCADE,
entity_type VARCHAR(64) NOT NULL,
entity_id UUID NOT NULL,
field_key VARCHAR(64) NOT NULL,
value_json JSONB,
value_text TEXT NOT NULL DEFAULT '',
value_number NUMERIC,
value_boolean BOOLEAN,
value_date DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, entity_type, entity_id, field_key)
);
CREATE INDEX IF NOT EXISTS idx_field_values_entity
ON field_values (tenant_id, entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_field_values_search
ON field_values (tenant_id, entity_type, field_key)
WHERE value_text <> '';

View File

@ -0,0 +1,12 @@
-- AP1.5d: Rekursive Tasks unter Action
ALTER TABLE tasks
ADD COLUMN IF NOT EXISTS parent_task_id UUID NULL
REFERENCES tasks (id) ON DELETE CASCADE;
CREATE INDEX IF NOT EXISTS idx_tasks_parent
ON tasks (tenant_id, parent_task_id)
WHERE parent_task_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_tasks_action_parent
ON tasks (tenant_id, action_id, parent_task_id);

View File

@ -0,0 +1,27 @@
-- AP2.0b: Project archetype_key (Spiegel der Initiative)
ALTER TABLE projects
ADD COLUMN IF NOT EXISTS archetype_key VARCHAR(64) NULL;
CREATE INDEX IF NOT EXISTS idx_projects_archetype
ON projects (tenant_id, archetype_key)
WHERE archetype_key IS NOT NULL;
-- Bestehende Projects: Mirror aus Initiative (einmalig)
UPDATE projects p
SET archetype_key = CASE i.archetype_key
WHEN 'initiative.generic' THEN 'project.generic'
WHEN 'initiative.maturity_journey' THEN 'project.maturity_journey'
WHEN 'initiative.linear_project' THEN 'project.linear_project'
WHEN 'initiative.recurring_program' THEN 'project.recurring_program'
WHEN 'initiative.support_queue' THEN 'project.support_queue'
WHEN 'initiative.program' THEN 'project.program'
WHEN 'initiative.product' THEN 'project.product'
WHEN 'initiative.dispute_case' THEN 'project.dispute_case'
WHEN 'initiative.content_project' THEN 'project.content_project'
ELSE 'project.generic'
END
FROM initiatives i
WHERE p.initiative_id = i.id
AND p.tenant_id = i.tenant_id
AND p.archetype_key IS NULL;

View File

@ -0,0 +1,17 @@
-- AP1.4d: Graph Engine — erweiterte Kantenarten + Parallelgruppe
ALTER TABLE roadmap_item_dependencies
DROP CONSTRAINT IF EXISTS roadmap_item_dependencies_dependency_type_check;
ALTER TABLE roadmap_item_dependencies
ADD CONSTRAINT roadmap_item_dependencies_dependency_type_check
CHECK (dependency_type IN (
'requires', 'blocks', 'related', 'parallel_group', 'optional_branch'
));
ALTER TABLE roadmap_item_dependencies
ADD COLUMN IF NOT EXISTS group_key VARCHAR(128) NULL;
CREATE INDEX idx_roadmap_deps_group_key
ON roadmap_item_dependencies(tenant_id, group_key)
WHERE group_key IS NOT NULL;

View File

@ -0,0 +1,17 @@
-- AP1.14: Plan-Snapshots (Plan-Revision) — Soll-Zustand getrennt von Ist
CREATE TABLE roadmap_plan_snapshots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
roadmap_id UUID NOT NULL REFERENCES roadmaps(id) ON DELETE CASCADE,
revision_number INT NOT NULL,
reason TEXT NOT NULL DEFAULT '',
plan_payload JSONB NOT NULL,
created_by_user_id UUID NULL REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, initiative_id, revision_number)
);
CREATE INDEX idx_roadmap_plan_snapshots_initiative
ON roadmap_plan_snapshots(tenant_id, initiative_id, created_at DESC);

View File

@ -0,0 +1,24 @@
-- AP1.8a: Portfolio-Priorität (relatives Ranking im Tenant)
ALTER TABLE initiatives
ADD COLUMN IF NOT EXISTS portfolio_rank INT;
WITH ranked AS (
SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY tenant_id
ORDER BY updated_at DESC, title
) - 1 AS rn
FROM initiatives
)
UPDATE initiatives AS i
SET portfolio_rank = ranked.rn
FROM ranked
WHERE i.id = ranked.id AND i.portfolio_rank IS NULL;
CREATE INDEX IF NOT EXISTS idx_initiatives_tenant_portfolio_rank
ON initiatives (tenant_id, portfolio_rank);
COMMENT ON COLUMN initiatives.portfolio_rank IS
'Relatives Portfolio-Ranking (0 = höchste Aufmerksamkeit). AP1.8a';

View File

@ -0,0 +1,32 @@
-- AP1.16a: Durchführungsplan — Action-Reihenfolge, kind, Abhängigkeiten
ALTER TABLE actions
ADD COLUMN IF NOT EXISTS sort_order INT NOT NULL DEFAULT 0;
ALTER TABLE actions
ADD COLUMN IF NOT EXISTS action_kind VARCHAR(32) NOT NULL DEFAULT 'delivery'
CHECK (action_kind IN ('delivery', 'planning', 'review'));
CREATE INDEX idx_actions_initiative_sort
ON actions(tenant_id, initiative_id, sort_order);
CREATE TABLE action_dependencies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
initiative_id UUID NOT NULL REFERENCES initiatives(id) ON DELETE CASCADE,
predecessor_action_id UUID NOT NULL REFERENCES actions(id) ON DELETE CASCADE,
successor_action_id UUID NOT NULL REFERENCES actions(id) ON DELETE CASCADE,
dependency_kind VARCHAR(32) NOT NULL DEFAULT 'requires'
CHECK (dependency_kind IN ('requires', 'blocks', 'relates')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (tenant_id, predecessor_action_id, successor_action_id, dependency_kind)
);
CREATE INDEX idx_action_deps_predecessor
ON action_dependencies(tenant_id, predecessor_action_id);
CREATE INDEX idx_action_deps_successor
ON action_dependencies(tenant_id, successor_action_id);
CREATE INDEX idx_action_deps_initiative
ON action_dependencies(tenant_id, initiative_id);

View File

@ -0,0 +1,22 @@
-- AP1.7c — Actor Service Tokens für Operational API (Vibe-Coder / Agenten)
CREATE TABLE actor_service_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
actor_id UUID NOT NULL REFERENCES actors(id) ON DELETE CASCADE,
label VARCHAR(255) NOT NULL,
token_prefix VARCHAR(16) NOT NULL,
token_hash VARCHAR(64) NOT NULL,
capabilities JSONB NOT NULL DEFAULT '[]'::jsonb,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
expires_at TIMESTAMPTZ NULL,
last_used_at TIMESTAMPTZ NULL,
created_by_user_id UUID NULL REFERENCES users(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
revoked_at TIMESTAMPTZ NULL,
UNIQUE (token_prefix)
);
CREATE INDEX idx_actor_service_tokens_tenant ON actor_service_tokens(tenant_id);
CREATE INDEX idx_actor_service_tokens_actor ON actor_service_tokens(actor_id);
CREATE INDEX idx_actor_service_tokens_hash ON actor_service_tokens(token_hash);

View File

@ -0,0 +1,11 @@
-- AP2.0f — work_cycle (Zeitbox/Sprint) + actions.work_cycle_id
ALTER TABLE roadmap_items DROP CONSTRAINT IF EXISTS roadmap_items_item_type_check;
ALTER TABLE roadmap_items ADD CONSTRAINT roadmap_items_item_type_check
CHECK (item_type IN ('milestone', 'review_gate', 'maturity_stage', 'work_cycle'));
ALTER TABLE actions
ADD COLUMN work_cycle_id UUID NULL REFERENCES roadmap_items(id) ON DELETE SET NULL;
CREATE INDEX idx_actions_work_cycle ON actions(tenant_id, work_cycle_id)
WHERE work_cycle_id IS NOT NULL;

View File

@ -0,0 +1,26 @@
-- AP2.2e: Sprint-Planung — Item-Arten, Feature-Referenz, Unplan-Support
ALTER TABLE actions DROP CONSTRAINT IF EXISTS actions_action_kind_check;
ALTER TABLE actions
ADD CONSTRAINT actions_action_kind_check
CHECK (action_kind IN ('delivery', 'planning', 'review', 'bug', 'issue'));
ALTER TABLE actions
ADD COLUMN IF NOT EXISTS parent_action_id UUID NULL
REFERENCES actions (id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_actions_parent
ON actions (tenant_id, initiative_id, parent_action_id)
WHERE parent_action_id IS NOT NULL;
ALTER TABLE backlog_items
ADD COLUMN IF NOT EXISTS item_kind VARCHAR(32) NOT NULL DEFAULT 'story'
CHECK (item_kind IN ('story', 'bug', 'issue'));
ALTER TABLE backlog_items
ADD COLUMN IF NOT EXISTS parent_action_id UUID NULL
REFERENCES actions (id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_backlog_parent_action
ON backlog_items (tenant_id, initiative_id, parent_action_id)
WHERE parent_action_id IS NOT NULL;

View File

@ -0,0 +1,4 @@
-- AP2.3a: UI-Profil pro Archetyp (Registry-Sync → Frontend Operating Context)
ALTER TABLE entity_archetypes
ADD COLUMN IF NOT EXISTS ui_profile_json JSONB NOT NULL DEFAULT '{}'::jsonb;

View File

@ -0,0 +1,14 @@
-- AP2.2f / ADP Backlog Epic-Hierarchie — parent_backlog_id + item_kind epic
ALTER TABLE backlog_items
ADD COLUMN IF NOT EXISTS parent_backlog_id UUID NULL
REFERENCES backlog_items (id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_backlog_parent_backlog
ON backlog_items (tenant_id, initiative_id, parent_backlog_id)
WHERE parent_backlog_id IS NOT NULL;
ALTER TABLE backlog_items DROP CONSTRAINT IF EXISTS backlog_items_item_kind_check;
ALTER TABLE backlog_items
ADD CONSTRAINT backlog_items_item_kind_check
CHECK (item_kind IN ('story', 'bug', 'issue', 'epic'));

View File

@ -0,0 +1,11 @@
-- P7: Technische Schuld — item_kind / action_kind tech_debt
ALTER TABLE actions DROP CONSTRAINT IF EXISTS actions_action_kind_check;
ALTER TABLE actions
ADD CONSTRAINT actions_action_kind_check
CHECK (action_kind IN ('delivery', 'planning', 'review', 'bug', 'issue', 'tech_debt'));
ALTER TABLE backlog_items DROP CONSTRAINT IF EXISTS backlog_items_item_kind_check;
ALTER TABLE backlog_items
ADD CONSTRAINT backlog_items_item_kind_check
CHECK (item_kind IN ('story', 'bug', 'issue', 'epic', 'tech_debt'));

View File

@ -0,0 +1,25 @@
-- AP A1.1 — CadenceInstance (minimal): eine offene Instanz pro Recurring, Tages-Tracking
CREATE TABLE cadence_instances (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
recurring_element_id UUID NOT NULL REFERENCES recurring_elements(id) ON DELETE CASCADE,
status VARCHAR(32) NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'completed')),
due_at TIMESTAMPTZ NOT NULL,
completed_at TIMESTAMPTZ NULL,
measurement_note TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_cadence_instances_tenant_recurring
ON cadence_instances(tenant_id, recurring_element_id);
CREATE INDEX idx_cadence_instances_open_due
ON cadence_instances(tenant_id, due_at)
WHERE status = 'open';
CREATE UNIQUE INDEX idx_cadence_instances_one_open_per_recurring
ON cadence_instances(recurring_element_id)
WHERE status = 'open';

View File

@ -0,0 +1,9 @@
-- AP A1.2 — RecurringElement an Reifegrad-Stufe binden (Activity Set)
ALTER TABLE recurring_elements
ADD COLUMN roadmap_item_id UUID NULL
REFERENCES roadmap_items(id) ON DELETE SET NULL;
CREATE INDEX idx_recurring_elements_stage
ON recurring_elements(tenant_id, roadmap_item_id)
WHERE roadmap_item_id IS NOT NULL;

View File

@ -0,0 +1,26 @@
-- AP-A1-PM-2: Progression graph — join_gate, strand binding, recurring strand
ALTER TABLE roadmap_items
DROP CONSTRAINT IF EXISTS roadmap_items_item_type_check;
ALTER TABLE roadmap_items
ADD CONSTRAINT roadmap_items_item_type_check
CHECK (item_type IN (
'milestone', 'review_gate', 'maturity_stage', 'work_cycle', 'join_gate'
));
ALTER TABLE roadmap_items
ADD COLUMN IF NOT EXISTS strand_project_id UUID NULL
REFERENCES projects(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_roadmap_items_strand
ON roadmap_items(tenant_id, strand_project_id)
WHERE strand_project_id IS NOT NULL;
ALTER TABLE recurring_elements
ADD COLUMN IF NOT EXISTS project_id UUID NULL
REFERENCES projects(id) ON DELETE SET NULL;
CREATE INDEX IF NOT EXISTS idx_recurring_elements_project
ON recurring_elements(tenant_id, project_id)
WHERE project_id IS NOT NULL;

View File

@ -0,0 +1,51 @@
-- Unified Schedule & Cadence Layer (ADP v0.1)
CREATE TABLE schedules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
schedule_kind VARCHAR(16) NOT NULL DEFAULT 'interval'
CHECK (schedule_kind IN ('interval', 'weekdays')),
interval_days INT NULL CHECK (interval_days IS NULL OR interval_days > 0),
weekday_mask INT NOT NULL DEFAULT 0,
pause_until DATE NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_schedules_tenant ON schedules(tenant_id);
ALTER TABLE recurring_elements
ADD COLUMN IF NOT EXISTS schedule_id UUID NULL
REFERENCES schedules(id) ON DELETE SET NULL;
ALTER TABLE recurring_elements
ADD COLUMN IF NOT EXISTS action_id UUID NULL
REFERENCES actions(id) ON DELETE CASCADE;
CREATE UNIQUE INDEX idx_recurring_elements_action
ON recurring_elements(action_id)
WHERE action_id IS NOT NULL;
ALTER TABLE actions
ADD COLUMN IF NOT EXISTS schedule_id UUID NULL
REFERENCES schedules(id) ON DELETE SET NULL;
-- Backfill: one schedule per recurring without schedule_id
DO $$
DECLARE
r RECORD;
sid UUID;
idays INT;
BEGIN
FOR r IN
SELECT id, tenant_id, interval_days
FROM recurring_elements
WHERE schedule_id IS NULL
LOOP
idays := GREATEST(COALESCE(r.interval_days, 1), 1);
INSERT INTO schedules (tenant_id, schedule_kind, interval_days, weekday_mask)
VALUES (r.tenant_id, 'interval', idays, 0)
RETURNING id INTO sid;
UPDATE recurring_elements SET schedule_id = sid WHERE id = r.id;
END LOOP;
END $$;

View File

@ -0,0 +1,92 @@
"""Operational API auth — Session or Actor Service Token (AP1.7c)."""
from __future__ import annotations
from typing import Callable, Optional
from fastapi import Depends, Header, HTTPException
from auth import AUTH_HEADER, get_session
from capabilities import _enforce_capability
from services.actor_service_tokens import validate_service_token
from tenant_context import TenantContext, resolve_tenant_context
ACTOR_TOKEN_HEADER = "X-Actor-Token"
def _extract_actor_token(
x_actor_token: Optional[str],
authorization: Optional[str],
) -> Optional[str]:
if x_actor_token and x_actor_token.strip():
return x_actor_token.strip()
if authorization:
scheme, _, value = authorization.partition(" ")
if scheme.lower() == "bearer" and value.strip():
return value.strip()
return None
def resolve_service_token_context(raw_token: str) -> TenantContext:
resolved = validate_service_token(raw_token)
if not resolved:
raise HTTPException(status_code=401, detail="Ungültiger oder abgelaufener Actor-Token")
return TenantContext(
user_id="",
email="",
display_name=resolved["actor_name"],
portal_role="",
tenant_id=resolved["tenant_id"],
tenant_slug=resolved["tenant_slug"],
tenant_name=resolved["tenant_name"],
tenant_role=None,
actor_id=resolved["actor_id"],
actor_type=resolved["actor_type"],
session_token="",
capabilities=resolved["capabilities"],
auth_source="service_token",
service_token_id=resolved["token_id"],
)
def get_operational_context(
x_actor_token: Optional[str] = Header(default=None, alias=ACTOR_TOKEN_HEADER),
authorization: Optional[str] = Header(default=None),
x_auth_token: Optional[str] = Header(default=None, alias=AUTH_HEADER),
) -> TenantContext:
actor_token = _extract_actor_token(x_actor_token, authorization)
if actor_token:
return resolve_service_token_context(actor_token)
session_token = (x_auth_token or "").strip()
if not session_token:
raise HTTPException(
status_code=401,
detail="Authentifizierung erforderlich (X-Actor-Token oder X-Auth-Token)",
)
session = get_session(session_token)
if not session:
raise HTTPException(status_code=401, detail="Ungültige Session")
ctx = resolve_tenant_context(session)
if not ctx.tenant_id:
raise HTTPException(status_code=403, detail="Kein aktiver Tenant — Tenant wählen")
return ctx
def require_operational_capability(capability_key: str) -> Callable[..., TenantContext]:
"""Service tokens: always enforce. Sessions: probe/enforce per CAPABILITY_ENFORCE."""
def _dependency(
ctx: TenantContext = Depends(get_operational_context),
) -> TenantContext:
if ctx.auth_source == "service_token":
if capability_key not in ctx.capabilities:
raise HTTPException(
status_code=403,
detail=f"Capability fehlt auf Service Token: {capability_key}",
)
return ctx
return _enforce_capability(ctx, capability_key)
return _dependency

View File

@ -1,5 +1,19 @@
"""Import all module registrations — side effect registers capabilities.""" """Import all module registrations — side effect registers capabilities."""
from . import platform, registry_ops, tenant_ops # noqa: F401 from . import ( # noqa: F401
initiative_ops,
operating_model_ops,
platform,
registry_ops,
tenant_ops,
workspace_ops,
)
__all__ = ["platform", "registry_ops", "tenant_ops"] __all__ = [
"initiative_ops",
"operating_model_ops",
"platform",
"registry_ops",
"tenant_ops",
"workspace_ops",
]

View File

@ -0,0 +1,63 @@
"""Initiative and action capabilities (AP0.5)."""
from __future__ import annotations
from rights_registry import CapabilityRegistration, register_capability
register_capability(
CapabilityRegistration(
key="kairo.initiative.read",
module="initiative",
description="Vorhaben im aktiven Tenant lesen",
default_grants=(
("portal", "admin"),
("portal", "user"),
("tenant", "owner"),
("tenant", "admin"),
("tenant", "member"),
),
)
)
register_capability(
CapabilityRegistration(
key="kairo.initiative.manage",
module="initiative",
description="Vorhaben im aktiven Tenant anlegen und bearbeiten",
default_grants=(
("portal", "admin"),
("tenant", "owner"),
("tenant", "admin"),
("tenant", "member"),
),
)
)
register_capability(
CapabilityRegistration(
key="kairo.action.read",
module="action",
description="Maßnahmen im aktiven Tenant lesen",
default_grants=(
("portal", "admin"),
("portal", "user"),
("tenant", "owner"),
("tenant", "admin"),
("tenant", "member"),
),
)
)
register_capability(
CapabilityRegistration(
key="kairo.action.manage",
module="action",
description="Maßnahmen anlegen, bearbeiten, zuweisen und Status ändern",
default_grants=(
("portal", "admin"),
("tenant", "owner"),
("tenant", "admin"),
("tenant", "member"),
),
)
)

View File

@ -0,0 +1,191 @@
"""Operating model capabilities (AP0.8)."""
from __future__ import annotations
from rights_registry import CapabilityRegistration, register_capability
_MEMBER_READ = (
("portal", "admin"),
("portal", "user"),
("tenant", "owner"),
("tenant", "admin"),
("tenant", "member"),
)
_MEMBER_MANAGE = (
("portal", "admin"),
("tenant", "owner"),
("tenant", "admin"),
("tenant", "member"),
)
register_capability(
CapabilityRegistration(
key="kairo.attention.read",
module="attention",
description="Attention- und NextAction-Sichten lesen",
default_grants=_MEMBER_READ,
)
)
register_capability(
CapabilityRegistration(
key="kairo.blocker.read",
module="blocker",
description="Blocker im aktiven Tenant lesen",
default_grants=_MEMBER_READ,
)
)
register_capability(
CapabilityRegistration(
key="kairo.blocker.manage",
module="blocker",
description="Blocker anlegen und bearbeiten",
default_grants=_MEMBER_MANAGE,
)
)
register_capability(
CapabilityRegistration(
key="kairo.backlog.read",
module="backlog",
description="Backlog-Items im aktiven Tenant lesen",
default_grants=_MEMBER_READ,
)
)
register_capability(
CapabilityRegistration(
key="kairo.backlog.manage",
module="backlog",
description="Backlog-Items anlegen, bearbeiten und konvertieren",
default_grants=_MEMBER_MANAGE,
)
)
register_capability(
CapabilityRegistration(
key="kairo.milestone.read",
module="milestone",
description="Meilensteine im aktiven Tenant lesen",
default_grants=_MEMBER_READ,
)
)
register_capability(
CapabilityRegistration(
key="kairo.milestone.manage",
module="milestone",
description="Meilensteine anlegen und bearbeiten",
default_grants=_MEMBER_MANAGE,
)
)
register_capability(
CapabilityRegistration(
key="kairo.evidence.read",
module="evidence",
description="Evidence im aktiven Tenant lesen",
default_grants=_MEMBER_READ,
)
)
register_capability(
CapabilityRegistration(
key="kairo.evidence.manage",
module="evidence",
description="Evidence anlegen und bearbeiten",
default_grants=_MEMBER_MANAGE,
)
)
register_capability(
CapabilityRegistration(
key="kairo.decision.read",
module="decision",
description="Entscheidungen im aktiven Tenant lesen",
default_grants=_MEMBER_READ,
)
)
register_capability(
CapabilityRegistration(
key="kairo.decision.manage",
module="decision",
description="Entscheidungen anlegen und bearbeiten",
default_grants=_MEMBER_MANAGE,
)
)
register_capability(
CapabilityRegistration(
key="kairo.review.read",
module="review",
description="Reviews im aktiven Tenant lesen",
default_grants=_MEMBER_READ,
)
)
register_capability(
CapabilityRegistration(
key="kairo.review.manage",
module="review",
description="Reviews anlegen und bearbeiten",
default_grants=_MEMBER_MANAGE,
)
)
register_capability(
CapabilityRegistration(
key="kairo.recurring.read",
module="recurring",
description="Recurring-Elemente im aktiven Tenant lesen",
default_grants=_MEMBER_READ,
)
)
register_capability(
CapabilityRegistration(
key="kairo.recurring.manage",
module="recurring",
description="Recurring-Elemente anlegen und bearbeiten",
default_grants=_MEMBER_MANAGE,
)
)
register_capability(
CapabilityRegistration(
key="kairo.roadmap.reopen",
module="roadmap",
description="Erreichte Plan-Elemente wieder öffnen (Reopen)",
default_grants=_MEMBER_MANAGE,
)
)
register_capability(
CapabilityRegistration(
key="kairo.roadmap.reopen.emergency",
module="roadmap",
description="Plan-Reopen als Portal-Admin (Notfall)",
default_grants=(("portal", "admin"),),
)
)
register_capability(
CapabilityRegistration(
key="kairo.project.read",
module="project",
description="Projekte im aktiven Tenant lesen",
default_grants=_MEMBER_READ,
)
)
register_capability(
CapabilityRegistration(
key="kairo.project.manage",
module="project",
description="Projekte anlegen und bearbeiten",
default_grants=_MEMBER_MANAGE,
)
)

View File

@ -0,0 +1,35 @@
"""Workspace read capabilities (AP0.7)."""
from __future__ import annotations
from rights_registry import CapabilityRegistration, register_capability
register_capability(
CapabilityRegistration(
key="kairo.actor.read",
module="tenant",
description="Actors im aktiven Tenant lesen (Directory)",
default_grants=(
("portal", "admin"),
("portal", "user"),
("tenant", "owner"),
("tenant", "admin"),
("tenant", "member"),
),
)
)
register_capability(
CapabilityRegistration(
key="kairo.workspace.read",
module="workspace",
description="Workspace-Übersichten und aggregierte Read-Models lesen",
default_grants=(
("portal", "admin"),
("portal", "user"),
("tenant", "owner"),
("tenant", "admin"),
("tenant", "member"),
),
)
)

251
backend/routers/actions.py Normal file
View File

@ -0,0 +1,251 @@
"""Action (Maßnahme) API — AP0.5 / AP0.7."""
from __future__ import annotations
from datetime import datetime
from typing import Literal, Optional
from capabilities import require_capability
from data_layer import actions as dl_actions
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from schemas.schedule_payload import SchedulePayload
from services import actions as action_service
from services import scheduled_work as scheduled_work_service
from services import tasks as task_service
from tenant_context import TenantContext
router = APIRouter(prefix="/api/actions", tags=["actions"])
class ActionUpdateRequest(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
description: Optional[str] = None
status: Optional[
Literal["open", "ready", "in_progress", "blocked", "review_required", "done", "discarded"]
] = None
priority: Optional[Literal["low", "normal", "high"]] = None
due_at: Optional[str] = None
clear_due_at: bool = False
project_id: Optional[str] = None
clear_project: bool = False
roadmap_item_id: Optional[str] = None
clear_roadmap_item: bool = False
sort_order: Optional[int] = None
action_kind: Optional[Literal["delivery", "planning", "review", "bug", "issue"]] = None
work_cycle_id: Optional[str] = None
clear_work_cycle: bool = False
parent_action_id: Optional[str] = None
clear_parent_action: bool = False
class TaskCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
description: str = ""
status: Literal["open", "in_progress", "done", "discarded"] = "open"
parent_task_id: Optional[str] = None
roadmap_item_id: Optional[str] = None
sort_order: int = 0
due_at: Optional[str] = None
class ActionAssignmentsRequest(BaseModel):
actor_ids: list[str] = Field(default_factory=list)
@router.get("/me/open")
def list_my_open_actions(
ctx: TenantContext = Depends(require_capability("kairo.action.read")),
):
try:
return dl_actions.get_all_my_open_actions(ctx)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/{action_id}/tasks")
def list_action_tasks(
action_id: str,
ctx: TenantContext = Depends(require_capability("kairo.action.read")),
):
try:
return task_service.list_tasks_for_action(
tenant_id=ctx.tenant_id, action_id=action_id
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/{action_id}/tasks", status_code=201)
def create_action_task(
action_id: str,
body: TaskCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
):
due_at = None
if body.due_at:
try:
due_at = datetime.fromisoformat(body.due_at.replace("Z", "+00:00"))
except ValueError as exc:
raise HTTPException(status_code=400, detail="Ungültiges due_at") from exc
try:
return task_service.create_task(
tenant_id=ctx.tenant_id,
action_id=action_id,
user_id=ctx.user_id,
title=body.title,
description=body.description,
status=body.status,
parent_task_id=body.parent_task_id,
roadmap_item_id=body.roadmap_item_id,
sort_order=body.sort_order,
due_at=due_at,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/{action_id}")
def get_action(
action_id: str,
ctx: TenantContext = Depends(require_capability("kairo.action.read")),
):
item = action_service.get_action(tenant_id=ctx.tenant_id, action_id=action_id)
if not item:
raise HTTPException(status_code=404, detail="Maßnahme nicht gefunden")
return item
@router.patch("/{action_id}")
def update_action(
action_id: str,
body: ActionUpdateRequest,
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
):
due_at = None
if body.due_at is not None:
try:
due_at = datetime.fromisoformat(body.due_at.replace("Z", "+00:00"))
except ValueError as exc:
raise HTTPException(status_code=400, detail="Ungültiges due_at") from exc
try:
item = action_service.update_action(
tenant_id=ctx.tenant_id,
action_id=action_id,
user_id=ctx.user_id,
title=body.title,
description=body.description,
status=body.status,
priority=body.priority,
due_at=due_at,
clear_due_at=body.clear_due_at,
project_id=body.project_id,
clear_project=body.clear_project,
roadmap_item_id=body.roadmap_item_id,
clear_roadmap_item=body.clear_roadmap_item,
work_cycle_id=body.work_cycle_id,
clear_work_cycle=body.clear_work_cycle,
sort_order=body.sort_order,
action_kind=body.action_kind,
parent_action_id=body.parent_action_id,
clear_parent_action=body.clear_parent_action,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="Maßnahme nicht gefunden")
return item
@router.put("/{action_id}/schedule")
def upsert_action_schedule(
action_id: str,
body: SchedulePayload,
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
):
try:
return scheduled_work_service.upsert_action_schedule(
tenant_id=ctx.tenant_id,
action_id=action_id,
user_id=ctx.user_id,
schedule_kind=body.schedule_kind,
interval_days=body.interval_days,
weekday_mask=body.weekday_mask,
pause_until=body.pause_until_date(),
)
except ValueError as exc:
detail = str(exc)
if detail == "Arbeitspaket nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc
@router.delete("/{action_id}/schedule", status_code=200)
def clear_action_schedule(
action_id: str,
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
):
try:
return scheduled_work_service.clear_action_schedule(
tenant_id=ctx.tenant_id,
action_id=action_id,
user_id=ctx.user_id,
)
except ValueError as exc:
detail = str(exc)
if detail == "Arbeitspaket nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc
@router.put("/{action_id}/assignments")
def set_action_assignments(
action_id: str,
body: ActionAssignmentsRequest,
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
):
try:
item = action_service.set_action_assignments(
tenant_id=ctx.tenant_id,
action_id=action_id,
actor_ids=body.actor_ids,
user_id=ctx.user_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="Maßnahme nicht gefunden")
return item
@router.post("/{action_id}/unplan-to-backlog", status_code=200)
def unplan_action_to_backlog(
action_id: str,
carryover: bool = False,
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
):
try:
return action_service.unplan_action_to_backlog(
tenant_id=ctx.tenant_id,
action_id=action_id,
user_id=ctx.user_id,
carryover=carryover,
)
except ValueError as exc:
detail = str(exc)
if detail == "Arbeitspaket nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc
@router.delete("/{action_id}", status_code=204)
def delete_action(
action_id: str,
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
):
if not action_service.delete_action(
tenant_id=ctx.tenant_id,
action_id=action_id,
user_id=ctx.user_id,
):
raise HTTPException(status_code=404, detail="Maßnahme nicht gefunden")

125
backend/routers/actors.py Normal file
View File

@ -0,0 +1,125 @@
"""Actor directory API — AP0.7 + AP1.7c Service Tokens."""
from __future__ import annotations
from typing import Literal, Optional
from capabilities import require_capability
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from services import actor_service_tokens
from services import actors as actor_service
from tenant_context import TenantContext
router = APIRouter(prefix="/api/actors", tags=["actors"])
class ServiceTokenCreateRequest(BaseModel):
label: str = Field(min_length=1, max_length=255)
capabilities: list[str] = Field(default_factory=list)
expires_in_days: Optional[int] = Field(default=365, ge=1, le=730)
class ActorCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=255)
actor_type: Literal["human", "agent", "working_group", "external_system"] = "agent"
@router.get("")
def list_actors(
actor_type: Optional[Literal["human", "agent", "working_group", "external_system"]] = None,
include_inactive: bool = Query(default=False),
q: Optional[str] = Query(default=None, max_length=100),
ctx: TenantContext = Depends(require_capability("kairo.actor.read")),
):
try:
return actor_service.list_actors(
tenant_id=ctx.tenant_id,
actor_type=actor_type,
include_inactive=include_inactive,
q=q,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("", status_code=201)
def create_actor(
body: ActorCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.actor.manage")),
):
if body.actor_type == "human":
raise HTTPException(
status_code=400,
detail="Human-Actors werden über User-Provisioning angelegt",
)
try:
return actor_service.create_actor(
tenant_id=ctx.tenant_id,
actor_type=body.actor_type,
name=body.name.strip(),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/{actor_id}")
def get_actor(
actor_id: str,
ctx: TenantContext = Depends(require_capability("kairo.actor.read")),
):
item = actor_service.get_actor(tenant_id=ctx.tenant_id, actor_id=actor_id)
if not item:
raise HTTPException(status_code=404, detail="Actor nicht gefunden")
return item
@router.get("/{actor_id}/service-tokens")
def list_actor_service_tokens(
actor_id: str,
include_revoked: bool = Query(default=False),
ctx: TenantContext = Depends(require_capability("kairo.actor.manage")),
):
actor = actor_service.get_actor(tenant_id=ctx.tenant_id, actor_id=actor_id)
if not actor:
raise HTTPException(status_code=404, detail="Actor nicht gefunden")
return actor_service_tokens.list_service_tokens_for_actor(
tenant_id=ctx.tenant_id,
actor_id=actor_id,
include_revoked=include_revoked,
)
@router.post("/{actor_id}/service-tokens", status_code=201)
def create_actor_service_token(
actor_id: str,
body: ServiceTokenCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.actor.manage")),
):
try:
return actor_service_tokens.create_service_token(
tenant_id=ctx.tenant_id,
actor_id=actor_id,
label=body.label,
created_by_user_id=ctx.user_id,
capabilities=body.capabilities,
expires_in_days=body.expires_in_days,
)
except ValueError as exc:
detail = str(exc)
if detail == "Actor nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc
@router.delete("/service-tokens/{token_id}", status_code=204)
def revoke_actor_service_token(
token_id: str,
ctx: TenantContext = Depends(require_capability("kairo.actor.manage")),
):
if not actor_service_tokens.revoke_service_token(
tenant_id=ctx.tenant_id,
token_id=token_id,
user_id=ctx.user_id,
):
raise HTTPException(status_code=404, detail="Token nicht gefunden")

132
backend/routers/backlog.py Normal file
View File

@ -0,0 +1,132 @@
"""BacklogItem API — AP0.8c."""
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 backlog as backlog_service
from tenant_context import TenantContext
router = APIRouter(prefix="/api/backlog", tags=["backlog"])
class BacklogCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
description: str = ""
status: Literal["new", "triaged", "accepted", "rejected"] = "new"
priority: Literal["low", "normal", "high"] = "normal"
roadmap_item_id: Optional[str] = None
sort_order: Optional[int] = None
item_kind: Literal["story", "bug", "issue", "epic", "tech_debt"] = "story"
parent_action_id: Optional[str] = None
parent_backlog_id: Optional[str] = None
class BacklogUpdateRequest(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
description: Optional[str] = None
status: Optional[Literal["new", "triaged", "accepted", "rejected"]] = None
priority: Optional[Literal["low", "normal", "high"]] = None
roadmap_item_id: Optional[str] = None
clear_roadmap_item: bool = False
sort_order: Optional[int] = None
item_kind: Optional[Literal["story", "bug", "issue", "epic", "tech_debt"]] = None
parent_action_id: Optional[str] = None
clear_parent_action: bool = False
parent_backlog_id: Optional[str] = None
clear_parent_backlog: bool = False
class BacklogConvertRequest(BaseModel):
assigned_actor_ids: list[str] = Field(default_factory=list)
work_cycle_id: Optional[str] = None
assign_active_sprint: bool = True
@router.get("/{backlog_item_id}")
def get_backlog_item(
backlog_item_id: str,
ctx: TenantContext = Depends(require_capability("kairo.backlog.read")),
):
item = backlog_service.get_backlog_item(
tenant_id=ctx.tenant_id, backlog_item_id=backlog_item_id
)
if not item:
raise HTTPException(status_code=404, detail="Backlog-Item nicht gefunden")
return item
@router.patch("/{backlog_item_id}")
def update_backlog_item(
backlog_item_id: str,
body: BacklogUpdateRequest,
ctx: TenantContext = Depends(require_capability("kairo.backlog.manage")),
):
try:
item = backlog_service.update_backlog_item(
tenant_id=ctx.tenant_id,
backlog_item_id=backlog_item_id,
user_id=ctx.user_id,
title=body.title,
description=body.description,
status=body.status,
priority=body.priority,
roadmap_item_id=body.roadmap_item_id,
clear_roadmap_item=body.clear_roadmap_item,
sort_order=body.sort_order,
item_kind=body.item_kind,
parent_action_id=body.parent_action_id,
clear_parent_action=body.clear_parent_action,
parent_backlog_id=body.parent_backlog_id,
clear_parent_backlog=body.clear_parent_backlog,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="Backlog-Item nicht gefunden")
return item
@router.delete("/{backlog_item_id}", status_code=204)
def delete_backlog_item(
backlog_item_id: str,
ctx: TenantContext = Depends(require_capability("kairo.backlog.manage")),
):
try:
deleted = backlog_service.delete_backlog_item(
tenant_id=ctx.tenant_id,
backlog_item_id=backlog_item_id,
user_id=ctx.user_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not deleted:
raise HTTPException(status_code=404, detail="Backlog-Item nicht gefunden")
@router.post("/{backlog_item_id}/convert-to-action", status_code=201)
def convert_backlog_to_action(
backlog_item_id: str,
body: BacklogConvertRequest,
ctx: TenantContext = Depends(require_capability("kairo.backlog.manage")),
):
assigned = body.assigned_actor_ids
if not assigned and ctx.actor_id:
assigned = [ctx.actor_id]
try:
return backlog_service.convert_backlog_to_action(
tenant_id=ctx.tenant_id,
backlog_item_id=backlog_item_id,
user_id=ctx.user_id,
assigned_actor_ids=assigned,
work_cycle_id=body.work_cycle_id,
assign_active_sprint=body.assign_active_sprint,
)
except ValueError as exc:
detail = str(exc)
if detail == "Backlog-Item nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc

View File

@ -0,0 +1,77 @@
"""Blocker API — AP0.8b."""
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 blockers as blocker_service
from tenant_context import TenantContext
router = APIRouter(prefix="/api/blockers", tags=["blockers"])
class BlockerCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
description: str = ""
status: Literal["open", "in_progress", "resolved", "accepted_risk", "dismissed"] = "open"
action_id: Optional[str] = None
set_action_blocked: bool = False
class BlockerUpdateRequest(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
description: Optional[str] = None
status: Optional[
Literal["open", "in_progress", "resolved", "accepted_risk", "dismissed"]
] = None
action_id: Optional[str] = None
clear_action_id: bool = False
@router.get("/{blocker_id}")
def get_blocker(
blocker_id: str,
ctx: TenantContext = Depends(require_capability("kairo.blocker.read")),
):
item = blocker_service.get_blocker(tenant_id=ctx.tenant_id, blocker_id=blocker_id)
if not item:
raise HTTPException(status_code=404, detail="Blocker nicht gefunden")
return item
@router.patch("/{blocker_id}")
def update_blocker(
blocker_id: str,
body: BlockerUpdateRequest,
ctx: TenantContext = Depends(require_capability("kairo.blocker.manage")),
):
try:
item = blocker_service.update_blocker(
tenant_id=ctx.tenant_id,
blocker_id=blocker_id,
user_id=ctx.user_id,
title=body.title,
description=body.description,
status=body.status,
action_id=body.action_id,
clear_action_id=body.clear_action_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="Blocker nicht gefunden")
return item
@router.delete("/{blocker_id}", status_code=204)
def delete_blocker(
blocker_id: str,
ctx: TenantContext = Depends(require_capability("kairo.blocker.manage")),
):
if not blocker_service.delete_blocker(
tenant_id=ctx.tenant_id, blocker_id=blocker_id, user_id=ctx.user_id
):
raise HTTPException(status_code=404, detail="Blocker nicht gefunden")

View File

@ -0,0 +1,67 @@
"""Decision API — AP0.9c."""
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 decisions as decision_service
from tenant_context import TenantContext
router = APIRouter(prefix="/api/decisions", tags=["decisions"])
class DecisionUpdateRequest(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
description: Optional[str] = None
status: Optional[Literal["proposed", "decided", "superseded"]] = None
outcome: Optional[str] = None
decided_by_actor_id: Optional[str] = None
@router.get("/{decision_id}")
def get_decision(
decision_id: str,
ctx: TenantContext = Depends(require_capability("kairo.decision.read")),
):
item = decision_service.get_decision(tenant_id=ctx.tenant_id, decision_id=decision_id)
if not item:
raise HTTPException(status_code=404, detail="Entscheidung nicht gefunden")
return item
@router.patch("/{decision_id}")
def update_decision(
decision_id: str,
body: DecisionUpdateRequest,
ctx: TenantContext = Depends(require_capability("kairo.decision.manage")),
):
try:
item = decision_service.update_decision(
tenant_id=ctx.tenant_id,
decision_id=decision_id,
user_id=ctx.user_id,
title=body.title,
description=body.description,
status=body.status,
outcome=body.outcome,
decided_by_actor_id=body.decided_by_actor_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="Entscheidung nicht gefunden")
return item
@router.delete("/{decision_id}", status_code=204)
def delete_decision(
decision_id: str,
ctx: TenantContext = Depends(require_capability("kairo.decision.manage")),
):
if not decision_service.delete_decision(
tenant_id=ctx.tenant_id, decision_id=decision_id, user_id=ctx.user_id
):
raise HTTPException(status_code=404, detail="Entscheidung nicht gefunden")

View File

@ -0,0 +1,53 @@
"""Entity-Archetypen API — AP1.10a/10b."""
from __future__ import annotations
from typing import Optional
from capabilities import require_capability
from entity_archetypes import list_archetypes, validate_archetype_key
from services.archetype_starter_kit import get_starter_preview
from entity_fields.service import list_field_definitions
from fastapi import APIRouter, Depends, HTTPException, Query
from tenant_context import TenantContext
router = APIRouter(prefix="/api/entity-archetypes", tags=["entity-archetypes"])
@router.get("")
def get_entity_archetypes(
entity_type: Optional[str] = Query(default=None),
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
):
_ = ctx
return list_archetypes(entity_type=entity_type)
@router.get("/field-definitions")
def get_archetype_field_definitions(
entity_type: str = Query(..., min_length=1),
archetype_key: str = Query(..., min_length=1),
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
):
_ = ctx
archetypes = list_archetypes(entity_type=entity_type)
if not any(item["key"] == archetype_key for item in archetypes):
raise HTTPException(status_code=404, detail="Archetyp nicht gefunden")
return list_field_definitions(archetype_key=archetype_key)
@router.get("/{archetype_key}/starter-preview")
def get_archetype_starter_preview(
archetype_key: str,
method_profile_key: Optional[str] = Query(default=None),
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
):
_ = ctx
try:
validate_archetype_key(entity_type="initiative", archetype_key=archetype_key)
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
return get_starter_preview(
archetype_key=archetype_key,
method_profile_key=method_profile_key,
)

View File

@ -0,0 +1,75 @@
"""Evidence API — AP0.9b."""
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 evidence as evidence_service
from tenant_context import TenantContext
router = APIRouter(prefix="/api/evidence", tags=["evidence"])
class EvidenceUpdateRequest(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
description: Optional[str] = None
status: Optional[Literal["submitted", "accepted", "rejected"]] = None
action_id: Optional[str] = None
milestone_id: Optional[str] = None
roadmap_item_id: Optional[str] = None
clear_action_id: bool = False
clear_milestone_id: bool = False
clear_roadmap_item_id: bool = False
@router.get("/{evidence_id}")
def get_evidence(
evidence_id: str,
ctx: TenantContext = Depends(require_capability("kairo.evidence.read")),
):
item = evidence_service.get_evidence(tenant_id=ctx.tenant_id, evidence_id=evidence_id)
if not item:
raise HTTPException(status_code=404, detail="Evidence nicht gefunden")
return item
@router.patch("/{evidence_id}")
def update_evidence(
evidence_id: str,
body: EvidenceUpdateRequest,
ctx: TenantContext = Depends(require_capability("kairo.evidence.manage")),
):
try:
item = evidence_service.update_evidence(
tenant_id=ctx.tenant_id,
evidence_id=evidence_id,
user_id=ctx.user_id,
title=body.title,
description=body.description,
status=body.status,
action_id=body.action_id,
milestone_id=body.milestone_id,
roadmap_item_id=body.roadmap_item_id,
clear_action_id=body.clear_action_id,
clear_milestone_id=body.clear_milestone_id,
clear_roadmap_item_id=body.clear_roadmap_item_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="Evidence nicht gefunden")
return item
@router.delete("/{evidence_id}", status_code=204)
def delete_evidence(
evidence_id: str,
ctx: TenantContext = Depends(require_capability("kairo.evidence.manage")),
):
if not evidence_service.delete_evidence(
tenant_id=ctx.tenant_id, evidence_id=evidence_id, user_id=ctx.user_id
):
raise HTTPException(status_code=404, detail="Evidence nicht gefunden")

View File

@ -0,0 +1,119 @@
"""Execution plan API — AP1.16b."""
from __future__ import annotations
from typing import Literal, Optional
from capabilities import require_capability
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from services import actions as action_service
from services import execution_plan as execution_plan_service
from steering.graph.execution_engine import load_initiative_execution_graph_state
from tenant_context import TenantContext
initiative_router = APIRouter(prefix="/api/initiatives", tags=["execution-plan"])
class ActionDependencyCreateRequest(BaseModel):
predecessor_action_id: str
successor_action_id: str
dependency_kind: Literal["requires", "blocks", "relates"] = "requires"
@initiative_router.get("/{initiative_id}/execution/graph-state")
def get_initiative_execution_graph_state(
initiative_id: str,
scope_roadmap_item_id: Optional[str] = Query(default=None),
ctx: TenantContext = Depends(require_capability("kairo.action.read")),
):
try:
state = load_initiative_execution_graph_state(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
scope_roadmap_item_id=scope_roadmap_item_id,
)
if scope_roadmap_item_id is None:
from services import actions as action_service
from services import roadmap as roadmap_service
all_actions = action_service.list_actions_for_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
roadmap_items = roadmap_service.list_roadmap_items_for_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
from steering.graph.execution_engine import compute_planning_debt
state["planning_debt"] = compute_planning_debt(
actions=all_actions,
roadmap_items=roadmap_items,
)
return state
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@initiative_router.get("/{initiative_id}/execution/dependencies")
def list_initiative_action_dependencies(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.action.read")),
):
try:
return execution_plan_service.list_dependencies_for_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@initiative_router.post("/{initiative_id}/execution/dependencies", status_code=201)
def create_initiative_action_dependency(
initiative_id: str,
body: ActionDependencyCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
):
try:
predecessor = action_service.get_action(
tenant_id=ctx.tenant_id, action_id=body.predecessor_action_id
)
successor = action_service.get_action(
tenant_id=ctx.tenant_id, action_id=body.successor_action_id
)
if not predecessor or not successor:
raise ValueError("Action nicht gefunden")
if (
predecessor["initiative_id"] != initiative_id
or successor["initiative_id"] != initiative_id
):
raise ValueError("Actions gehören nicht zum angegebenen Vorhaben")
return execution_plan_service.add_dependency(
tenant_id=ctx.tenant_id,
predecessor_action_id=body.predecessor_action_id,
successor_action_id=body.successor_action_id,
dependency_kind=body.dependency_kind,
user_id=ctx.user_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@initiative_router.delete(
"/{initiative_id}/execution/dependencies/{dependency_id}",
status_code=204,
)
def delete_initiative_action_dependency(
initiative_id: str,
dependency_id: str,
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
):
del initiative_id # scope validated via tenant + dependency row
try:
execution_plan_service.delete_dependency(
tenant_id=ctx.tenant_id,
dependency_id=dependency_id,
user_id=ctx.user_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc

View File

@ -0,0 +1,659 @@
"""Initiative (Vorhaben) API — AP0.5."""
from __future__ import annotations
from datetime import datetime
from typing import Literal, Optional
from capabilities import require_capability
from data_layer import initiative_snapshot as dl_initiative_snapshot
from entity_fields.service import (
get_entity_field_values,
list_field_definitions,
patch_entity_field_values,
)
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from steering.context import get_steering_context_dto
from services import actions as action_service
from services import backlog as backlog_service
from services import blockers as blocker_service
from services import decisions as decision_service
from services import evidence as evidence_service
from services import initiatives as initiative_service
from services import operating_context as operating_context_service
from services import milestones as milestone_service
from services import recurring as recurring_service
from services import reviews as review_service
from tenant_context import TenantContext
router = APIRouter(prefix="/api/initiatives", tags=["initiatives"])
class InitiativeCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
goal: str = ""
vision: str = ""
target_state_summary: str = ""
archetype_key: str = "initiative.generic"
status: Literal["active", "paused", "completed", "archived"] = "active"
priority: Literal["low", "normal", "high"] = "normal"
owner_actor_id: Optional[str] = None
method_profile_key: Optional[str] = Field(default=None, min_length=1)
apply_starter_kit: bool = True
class InitiativeUpdateRequest(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
goal: Optional[str] = None
vision: Optional[str] = None
target_state_summary: Optional[str] = None
archetype_key: Optional[str] = None
status: Optional[Literal["active", "paused", "completed", "archived"]] = None
priority: Optional[Literal["low", "normal", "high"]] = None
owner_actor_id: Optional[str] = None
class InitiativeFieldsPatchRequest(BaseModel):
fields: dict[str, object] = Field(default_factory=dict)
class ActionCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
description: str = ""
status: Literal[
"open", "ready", "in_progress", "blocked", "review_required", "done", "discarded"
] = "open"
priority: Literal["low", "normal", "high"] = "normal"
due_at: Optional[str] = None
project_id: Optional[str] = None
roadmap_item_id: Optional[str] = None
work_cycle_id: Optional[str] = None
assigned_actor_ids: list[str] = Field(default_factory=list)
sort_order: int = 0
action_kind: Literal["delivery", "planning", "review", "bug", "issue"] = "delivery"
parent_action_id: Optional[str] = None
class BlockerCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
description: str = ""
status: Literal["open", "in_progress", "resolved", "accepted_risk", "dismissed"] = "open"
action_id: Optional[str] = None
set_action_blocked: bool = False
class BacklogCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
description: str = ""
status: Literal["new", "triaged", "accepted", "rejected"] = "new"
priority: Literal["low", "normal", "high"] = "normal"
roadmap_item_id: Optional[str] = None
item_kind: Literal["story", "bug", "issue", "epic"] = "story"
parent_action_id: Optional[str] = None
parent_backlog_id: Optional[str] = None
class MilestoneCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
goal_description: str = ""
status: Literal["planned", "active", "at_risk", "reached", "moved", "discarded"] = "planned"
target_date: Optional[str] = None
class EvidenceCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
description: str = ""
status: Literal["submitted", "accepted", "rejected"] = "submitted"
action_id: Optional[str] = None
milestone_id: Optional[str] = None
roadmap_item_id: Optional[str] = None
class DecisionCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
description: str = ""
status: Literal["proposed", "decided", "superseded"] = "proposed"
outcome: str = ""
class ReviewCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
summary: str = ""
status: Literal["planned", "completed", "skipped"] = "planned"
due_at: Optional[str] = None
action_id: Optional[str] = None
milestone_id: Optional[str] = None
class RecurringCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
description: str = ""
status: Literal["active", "paused", "ended"] = "active"
interval_days: Optional[int] = Field(default=None, gt=0)
next_due_at: Optional[str] = None
roadmap_item_id: Optional[str] = None
def _parse_optional_datetime(value: Optional[str], field_name: str) -> Optional[datetime]:
if not value:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise HTTPException(status_code=400, detail=f"Ungültiges {field_name}") from exc
@router.get("")
def list_initiatives(
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
):
return initiative_service.list_initiatives(tenant_id=ctx.tenant_id)
@router.post("", status_code=201)
def create_initiative(
body: InitiativeCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.initiative.manage")),
):
owner_actor_id = body.owner_actor_id or ctx.actor_id
if not owner_actor_id:
raise HTTPException(status_code=400, detail="Kein Actor im TenantContext")
try:
return initiative_service.create_initiative(
tenant_id=ctx.tenant_id,
title=body.title,
goal=body.goal,
vision=body.vision,
target_state_summary=body.target_state_summary,
archetype_key=body.archetype_key,
status=body.status,
priority=body.priority,
owner_actor_id=owner_actor_id,
user_id=ctx.user_id,
method_profile_key=body.method_profile_key,
apply_starter_kit=body.apply_starter_kit,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/{initiative_id}")
def get_initiative(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
):
item = initiative_service.get_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
if not item:
raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden")
return item
@router.get("/{initiative_id}/steering-snapshot")
def get_initiative_steering_snapshot(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
):
snapshot = dl_initiative_snapshot.get_initiative_steering_snapshot(
ctx, initiative_id=initiative_id
)
if not snapshot:
raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden")
return snapshot
@router.get("/{initiative_id}/steering-context")
def get_initiative_steering_context(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
):
if not initiative_service.get_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
):
raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden")
return get_steering_context_dto(ctx, initiative_id=initiative_id)
@router.get("/{initiative_id}/operating-context")
def get_initiative_operating_context(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
):
context = operating_context_service.get_operating_context(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
if not context:
raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden")
return context
def _require_initiative(*, tenant_id: str, initiative_id: str) -> dict:
initiative = initiative_service.get_initiative(
tenant_id=tenant_id, initiative_id=initiative_id
)
if not initiative:
raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden")
return initiative
@router.get("/{initiative_id}/field-definitions")
def get_initiative_field_definitions(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
):
initiative = _require_initiative(tenant_id=ctx.tenant_id, initiative_id=initiative_id)
return list_field_definitions(archetype_key=initiative["archetype_key"])
@router.get("/{initiative_id}/fields")
def get_initiative_fields(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
):
initiative = _require_initiative(tenant_id=ctx.tenant_id, initiative_id=initiative_id)
definitions = list_field_definitions(archetype_key=initiative["archetype_key"])
return get_entity_field_values(
tenant_id=ctx.tenant_id,
entity_type="initiative",
entity_id=initiative_id,
definitions=definitions,
)
@router.patch("/{initiative_id}/fields")
def patch_initiative_fields(
initiative_id: str,
body: InitiativeFieldsPatchRequest,
ctx: TenantContext = Depends(require_capability("kairo.initiative.manage")),
):
initiative = _require_initiative(tenant_id=ctx.tenant_id, initiative_id=initiative_id)
definitions = list_field_definitions(archetype_key=initiative["archetype_key"])
try:
return patch_entity_field_values(
tenant_id=ctx.tenant_id,
entity_type="initiative",
entity_id=initiative_id,
definitions=definitions,
values=body.fields,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.patch("/{initiative_id}")
def update_initiative(
initiative_id: str,
body: InitiativeUpdateRequest,
ctx: TenantContext = Depends(require_capability("kairo.initiative.manage")),
):
try:
item = initiative_service.update_initiative(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
user_id=ctx.user_id,
title=body.title,
goal=body.goal,
vision=body.vision,
target_state_summary=body.target_state_summary,
archetype_key=body.archetype_key,
status=body.status,
priority=body.priority,
owner_actor_id=body.owner_actor_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden")
return item
@router.delete("/{initiative_id}", status_code=204)
def delete_initiative(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.initiative.manage")),
):
if not initiative_service.delete_initiative(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
user_id=ctx.user_id,
):
raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden")
@router.get("/{initiative_id}/actions")
def list_initiative_actions(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.action.read")),
):
if not initiative_service.get_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
):
raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden")
return action_service.list_actions_for_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
@router.post("/{initiative_id}/actions", status_code=201)
def create_initiative_action(
initiative_id: str,
body: ActionCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
):
try:
due_at = _parse_optional_datetime(body.due_at, "due_at")
return action_service.create_action(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
title=body.title,
description=body.description,
status=body.status,
priority=body.priority,
due_at=due_at,
project_id=body.project_id,
roadmap_item_id=body.roadmap_item_id,
work_cycle_id=body.work_cycle_id,
assigned_actor_ids=body.assigned_actor_ids,
sort_order=body.sort_order,
action_kind=body.action_kind,
parent_action_id=body.parent_action_id,
user_id=ctx.user_id,
)
except ValueError as exc:
detail = str(exc)
if detail == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc
@router.get("/{initiative_id}/blockers")
def list_initiative_blockers(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.blocker.read")),
):
try:
return blocker_service.list_blockers_for_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
except ValueError as exc:
if str(exc) == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=str(exc)) from exc
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/{initiative_id}/blockers", status_code=201)
def create_initiative_blocker(
initiative_id: str,
body: BlockerCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.blocker.manage")),
):
try:
return blocker_service.create_blocker(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
title=body.title,
description=body.description,
status=body.status,
action_id=body.action_id,
reported_by_actor_id=ctx.actor_id,
user_id=ctx.user_id,
set_action_blocked=body.set_action_blocked,
)
except ValueError as exc:
detail = str(exc)
if detail == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc
@router.get("/{initiative_id}/backlog")
def list_initiative_backlog(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.backlog.read")),
):
try:
return backlog_service.list_backlog_for_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
except ValueError as exc:
if str(exc) == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=str(exc)) from exc
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/{initiative_id}/backlog", status_code=201)
def create_initiative_backlog_item(
initiative_id: str,
body: BacklogCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.backlog.manage")),
):
try:
return backlog_service.create_backlog_item(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
title=body.title,
description=body.description,
status=body.status,
priority=body.priority,
roadmap_item_id=body.roadmap_item_id,
item_kind=body.item_kind,
parent_action_id=body.parent_action_id,
parent_backlog_id=body.parent_backlog_id,
user_id=ctx.user_id,
)
except ValueError as exc:
detail = str(exc)
if detail == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc
@router.get("/{initiative_id}/milestones")
def list_initiative_milestones(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
):
try:
return milestone_service.list_milestones_for_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
except ValueError as exc:
if str(exc) == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=str(exc)) from exc
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/{initiative_id}/milestones", status_code=201)
def create_initiative_milestone(
initiative_id: str,
body: MilestoneCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
from datetime import date as date_type
target_date = None
if body.target_date:
try:
target_date = date_type.fromisoformat(body.target_date)
except ValueError as exc:
raise HTTPException(status_code=400, detail="Ungültiges target_date") from exc
try:
return milestone_service.create_milestone(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
title=body.title,
goal_description=body.goal_description,
status=body.status,
target_date=target_date,
user_id=ctx.user_id,
)
except ValueError as exc:
detail = str(exc)
if detail == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc
@router.get("/{initiative_id}/evidence")
def list_initiative_evidence(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.evidence.read")),
):
try:
return evidence_service.list_evidence_for_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
except ValueError as exc:
if str(exc) == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=str(exc)) from exc
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/{initiative_id}/evidence", status_code=201)
def create_initiative_evidence(
initiative_id: str,
body: EvidenceCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.evidence.manage")),
):
try:
return evidence_service.create_evidence(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
title=body.title,
description=body.description,
status=body.status,
action_id=body.action_id,
milestone_id=body.milestone_id,
roadmap_item_id=body.roadmap_item_id,
submitted_by_actor_id=ctx.actor_id,
user_id=ctx.user_id,
)
except ValueError as exc:
detail = str(exc)
if detail == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc
@router.get("/{initiative_id}/decisions")
def list_initiative_decisions(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.decision.read")),
):
try:
return decision_service.list_decisions_for_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
except ValueError as exc:
if str(exc) == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=str(exc)) from exc
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/{initiative_id}/decisions", status_code=201)
def create_initiative_decision(
initiative_id: str,
body: DecisionCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.decision.manage")),
):
try:
return decision_service.create_decision(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
title=body.title,
description=body.description,
status=body.status,
outcome=body.outcome,
decided_by_actor_id=ctx.actor_id if body.status == "decided" else None,
user_id=ctx.user_id,
)
except ValueError as exc:
detail = str(exc)
if detail == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc
@router.get("/{initiative_id}/reviews")
def list_initiative_reviews(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.review.read")),
):
try:
return review_service.list_reviews_for_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
except ValueError as exc:
if str(exc) == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=str(exc)) from exc
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/{initiative_id}/reviews", status_code=201)
def create_initiative_review(
initiative_id: str,
body: ReviewCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.review.manage")),
):
due_at = _parse_optional_datetime(body.due_at, "due_at")
try:
return review_service.create_review(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
title=body.title,
summary=body.summary,
status=body.status,
due_at=due_at,
action_id=body.action_id,
milestone_id=body.milestone_id,
reviewed_by_actor_id=ctx.actor_id,
user_id=ctx.user_id,
)
except ValueError as exc:
detail = str(exc)
if detail == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc
@router.get("/{initiative_id}/recurring")
def list_initiative_recurring(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.recurring.read")),
):
try:
return recurring_service.list_recurring_for_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
except ValueError as exc:
if str(exc) == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=str(exc)) from exc
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/{initiative_id}/recurring", status_code=201)
def create_initiative_recurring(
initiative_id: str,
body: RecurringCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.recurring.manage")),
):
next_due_at = _parse_optional_datetime(body.next_due_at, "next_due_at")
try:
return recurring_service.create_recurring_element(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
title=body.title,
description=body.description,
status=body.status,
interval_days=body.interval_days,
next_due_at=next_due_at,
roadmap_item_id=body.roadmap_item_id,
user_id=ctx.user_id,
)
except ValueError as exc:
detail = str(exc)
if detail == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc

View File

@ -0,0 +1,44 @@
"""Plan/Ist & Journey API — AP1.6."""
from __future__ import annotations
from capabilities import require_capability
from fastapi import APIRouter, Depends, HTTPException
from services import plan_ist as plan_ist_service
from tenant_context import TenantContext
initiative_router = APIRouter(prefix="/api/initiatives", tags=["journey"])
roadmap_router = APIRouter(prefix="/api/roadmap-items", tags=["journey"])
@initiative_router.get("/{initiative_id}/journey")
def list_initiative_journey(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
):
try:
events = plan_ist_service.list_journey_events(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
except ValueError as exc:
detail = str(exc)
if detail == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc
return {"events": events}
@roadmap_router.get("/{roadmap_item_id}/contributions")
def list_gate_contributions(
roadmap_item_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
):
try:
return plan_ist_service.list_gate_contributions(
tenant_id=ctx.tenant_id, roadmap_item_id=roadmap_item_id
)
except ValueError as exc:
detail = str(exc)
if detail == "Gate nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc

View File

@ -0,0 +1,77 @@
"""Milestone API — AP0.8d."""
from __future__ import annotations
from datetime import date
from typing import Literal, Optional
from capabilities import require_capability
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from services import milestones as milestone_service
from tenant_context import TenantContext
router = APIRouter(prefix="/api/milestones", tags=["milestones"])
class MilestoneCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
goal_description: str = ""
status: Literal["planned", "active", "at_risk", "reached", "moved", "discarded"] = "planned"
target_date: Optional[date] = None
class MilestoneUpdateRequest(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
goal_description: Optional[str] = None
status: Optional[
Literal["planned", "active", "at_risk", "reached", "moved", "discarded"]
] = None
target_date: Optional[date] = None
clear_target_date: bool = False
@router.get("/{milestone_id}")
def get_milestone(
milestone_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
):
item = milestone_service.get_milestone(tenant_id=ctx.tenant_id, milestone_id=milestone_id)
if not item:
raise HTTPException(status_code=404, detail="Meilenstein nicht gefunden")
return item
@router.patch("/{milestone_id}")
def update_milestone(
milestone_id: str,
body: MilestoneUpdateRequest,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
try:
item = milestone_service.update_milestone(
tenant_id=ctx.tenant_id,
milestone_id=milestone_id,
user_id=ctx.user_id,
title=body.title,
goal_description=body.goal_description,
status=body.status,
target_date=body.target_date,
clear_target_date=body.clear_target_date,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="Meilenstein nicht gefunden")
return item
@router.delete("/{milestone_id}", status_code=204)
def delete_milestone(
milestone_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
if not milestone_service.delete_milestone(
tenant_id=ctx.tenant_id, milestone_id=milestone_id, user_id=ctx.user_id
):
raise HTTPException(status_code=404, detail="Meilenstein nicht gefunden")

View File

@ -0,0 +1,429 @@
"""Operational Actor API — AP1.7 (Vibe-Coder / Agenten)."""
from __future__ import annotations
from typing import Any, Literal, Optional
from data_layer.attention import get_next_action_candidates_for_initiative
from data_layer.initiative_snapshot import get_initiative_steering_snapshot
from fastapi import APIRouter, Depends, HTTPException, Query
from operational_auth import require_operational_capability
from pydantic import BaseModel, Field
from services import actions as action_service
from services import backlog as backlog_service
from services import blockers as blocker_service
from services import decisions as decision_service
from services import evidence as evidence_service
from services import recurring as recurring_service
from services.audit import log_audit
from tenant_context import TenantContext
router = APIRouter(prefix="/api/operational", tags=["operational"])
def _ok(ctx: TenantContext, data: Any) -> dict[str, Any]:
return {
"ok": True,
"data": data,
"actor_id": ctx.actor_id,
}
class ActionStatusPatch(BaseModel):
status: Literal[
"open", "ready", "in_progress", "blocked", "review_required", "done", "discarded"
]
note: Optional[str] = None
class ActionAssignmentsBody(BaseModel):
actor_ids: list[str] = Field(default_factory=list)
class BlockerCreateBody(BaseModel):
initiative_id: str
title: str = Field(min_length=1, max_length=255)
description: str = ""
action_id: Optional[str] = None
status: Literal["open", "in_progress"] = "open"
class BlockerPatchBody(BaseModel):
status: Literal["open", "in_progress", "resolved", "accepted_risk", "dismissed"]
class EvidenceCreateBody(BaseModel):
initiative_id: str
title: str = Field(min_length=1, max_length=255)
body: str = ""
action_id: Optional[str] = None
roadmap_item_id: Optional[str] = None
status: Literal["submitted", "accepted", "rejected"] = "submitted"
class DecisionProposalBody(BaseModel):
initiative_id: str
title: str = Field(min_length=1, max_length=255)
rationale: str = ""
proposed_status: Literal["proposed"] = "proposed"
class BacklogProposalBody(BaseModel):
initiative_id: str
title: str = Field(min_length=1, max_length=255)
description: str = ""
roadmap_item_id: Optional[str] = None
class BacklogConvertBody(BaseModel):
work_cycle_id: Optional[str] = None
assign_active_sprint: bool = True
assigned_actor_ids: list[str] = Field(default_factory=list)
class RecurringStatusPatch(BaseModel):
status: Literal["active", "paused", "ended"]
class ActionWorkCyclePatch(BaseModel):
work_cycle_id: Optional[str] = None
clear_work_cycle: bool = False
@router.get("/me")
def operational_me(ctx: TenantContext = Depends(require_operational_capability("kairo.action.read"))):
return _ok(
ctx,
{
"auth_source": ctx.auth_source,
"tenant_id": ctx.tenant_id,
"tenant_slug": ctx.tenant_slug,
"actor_id": ctx.actor_id,
"actor_type": ctx.actor_type,
"display_name": ctx.display_name,
"capabilities": sorted(ctx.capabilities),
},
)
@router.get("/initiatives/{initiative_id}/context")
def operational_context(
initiative_id: str,
ctx: TenantContext = Depends(require_operational_capability("kairo.initiative.read")),
):
snapshot = get_initiative_steering_snapshot(ctx, initiative_id=initiative_id)
if not snapshot:
raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden")
subset = {
"initiative_id": snapshot.get("initiative_id"),
"title": snapshot.get("initiative_title"),
"status": snapshot.get("initiative_status"),
"method_key": snapshot.get("method_key"),
"method_label": snapshot.get("method_label"),
"archetype_key": snapshot.get("archetype_key"),
"archetype_label": snapshot.get("archetype_label"),
"steering_guidance": snapshot.get("steering_guidance"),
"lifecycle_state": snapshot.get("lifecycle_state"),
"lifecycle_label": snapshot.get("lifecycle_label"),
"signals": snapshot.get("signals"),
"attention_items": snapshot.get("attention_items"),
"next_actions": snapshot.get("next_actions"),
"active_work_cycle": snapshot.get("active_work_cycle"),
}
return _ok(ctx, subset)
@router.get("/next-action")
def operational_next_action(
initiative_id: str = Query(...),
limit: int = Query(default=5, ge=1, le=20),
ctx: TenantContext = Depends(require_operational_capability("kairo.initiative.read")),
):
items = get_next_action_candidates_for_initiative(
ctx, initiative_id=initiative_id, limit=limit
)
return _ok(ctx, items)
@router.get("/actions/{action_id}")
def operational_get_action(
action_id: str,
ctx: TenantContext = Depends(require_operational_capability("kairo.action.read")),
):
item = action_service.get_action(tenant_id=ctx.tenant_id, action_id=action_id)
if not item:
raise HTTPException(status_code=404, detail="Arbeitspaket nicht gefunden")
return _ok(ctx, item)
@router.patch("/actions/{action_id}/status")
def operational_patch_action_status(
action_id: str,
body: ActionStatusPatch,
ctx: TenantContext = Depends(require_operational_capability("kairo.action.manage")),
):
description = None
if body.note:
description = body.note.strip()
try:
item = action_service.update_action(
tenant_id=ctx.tenant_id,
action_id=action_id,
user_id=ctx.user_id or None,
status=body.status,
description=description,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="Arbeitspaket nicht gefunden")
log_audit(
"operational.action.status",
user_id=ctx.user_id or None,
tenant_id=ctx.tenant_id,
details={
"action_id": action_id,
"status": body.status,
"actor_id": ctx.actor_id,
"auth_source": ctx.auth_source,
},
)
return _ok(ctx, item)
@router.post("/actions/{action_id}/assignments")
def operational_set_assignments(
action_id: str,
body: ActionAssignmentsBody,
ctx: TenantContext = Depends(require_operational_capability("kairo.action.manage")),
):
try:
item = action_service.set_action_assignments(
tenant_id=ctx.tenant_id,
action_id=action_id,
actor_ids=body.actor_ids,
user_id=ctx.user_id or None,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="Arbeitspaket nicht gefunden")
return _ok(ctx, item)
@router.post("/blockers", status_code=201)
def operational_create_blocker(
body: BlockerCreateBody,
ctx: TenantContext = Depends(require_operational_capability("kairo.blocker.manage")),
):
try:
item = blocker_service.create_blocker(
tenant_id=ctx.tenant_id,
initiative_id=body.initiative_id,
title=body.title,
description=body.description,
status=body.status,
action_id=body.action_id,
reported_by_actor_id=ctx.actor_id,
user_id=ctx.user_id or None,
)
except ValueError as exc:
detail = str(exc)
if detail == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc
return _ok(ctx, item)
@router.patch("/blockers/{blocker_id}")
def operational_patch_blocker(
blocker_id: str,
body: BlockerPatchBody,
ctx: TenantContext = Depends(require_operational_capability("kairo.blocker.manage")),
):
try:
item = blocker_service.update_blocker(
tenant_id=ctx.tenant_id,
blocker_id=blocker_id,
user_id=ctx.user_id or None,
status=body.status,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="Blocker nicht gefunden")
return _ok(ctx, item)
@router.post("/evidence", status_code=201)
def operational_create_evidence(
body: EvidenceCreateBody,
ctx: TenantContext = Depends(require_operational_capability("kairo.evidence.manage")),
):
try:
item = evidence_service.create_evidence(
tenant_id=ctx.tenant_id,
initiative_id=body.initiative_id,
title=body.title,
description=body.body,
status=body.status,
action_id=body.action_id,
roadmap_item_id=body.roadmap_item_id,
submitted_by_actor_id=ctx.actor_id,
user_id=ctx.user_id or None,
)
except ValueError as exc:
detail = str(exc)
if detail == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc
return _ok(ctx, item)
@router.post("/decisions/proposals", status_code=201)
def operational_decision_proposal(
body: DecisionProposalBody,
ctx: TenantContext = Depends(require_operational_capability("kairo.decision.manage")),
):
try:
item = decision_service.create_decision(
tenant_id=ctx.tenant_id,
initiative_id=body.initiative_id,
title=body.title,
description=body.rationale,
status=body.proposed_status,
decided_by_actor_id=ctx.actor_id,
user_id=ctx.user_id or None,
)
except ValueError as exc:
detail = str(exc)
if detail == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc
return _ok(ctx, item)
@router.post("/backlog/proposals", status_code=201)
def operational_backlog_proposal(
body: BacklogProposalBody,
ctx: TenantContext = Depends(require_operational_capability("kairo.backlog.manage")),
):
try:
item = backlog_service.create_backlog_item(
tenant_id=ctx.tenant_id,
initiative_id=body.initiative_id,
title=body.title,
description=body.description,
status="new",
roadmap_item_id=body.roadmap_item_id,
user_id=ctx.user_id or None,
)
except ValueError as exc:
detail = str(exc)
if detail == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc
return _ok(ctx, item)
@router.post("/backlog/{backlog_item_id}/convert-to-action", status_code=201)
def operational_convert_backlog(
backlog_item_id: str,
body: BacklogConvertBody,
ctx: TenantContext = Depends(require_operational_capability("kairo.backlog.manage")),
):
assigned = body.assigned_actor_ids
if not assigned and ctx.actor_id:
assigned = [ctx.actor_id]
try:
result = backlog_service.convert_backlog_to_action(
tenant_id=ctx.tenant_id,
backlog_item_id=backlog_item_id,
user_id=ctx.user_id or None,
assigned_actor_ids=assigned,
work_cycle_id=body.work_cycle_id,
assign_active_sprint=body.assign_active_sprint,
)
except ValueError as exc:
detail = str(exc)
if detail == "Backlog-Item nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc
log_audit(
"operational.backlog.convert",
user_id=ctx.user_id or None,
tenant_id=ctx.tenant_id,
details={
"backlog_item_id": backlog_item_id,
"action_id": result.get("action", {}).get("id"),
"work_cycle_id": body.work_cycle_id,
"actor_id": ctx.actor_id,
},
)
return _ok(ctx, result)
@router.patch("/recurring/{recurring_id}")
def operational_patch_recurring(
recurring_id: str,
body: RecurringStatusPatch,
ctx: TenantContext = Depends(require_operational_capability("kairo.recurring.manage")),
):
try:
item = recurring_service.update_recurring_element(
tenant_id=ctx.tenant_id,
recurring_id=recurring_id,
user_id=ctx.user_id or None,
status=body.status,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="Recurring-Element nicht gefunden")
log_audit(
"operational.recurring.status",
user_id=ctx.user_id or None,
tenant_id=ctx.tenant_id,
details={
"recurring_id": recurring_id,
"status": body.status,
"actor_id": ctx.actor_id,
},
)
return _ok(ctx, item)
@router.patch("/actions/{action_id}/work-cycle")
def operational_patch_action_work_cycle(
action_id: str,
body: ActionWorkCyclePatch,
ctx: TenantContext = Depends(require_operational_capability("kairo.action.manage")),
):
try:
item = action_service.update_action(
tenant_id=ctx.tenant_id,
action_id=action_id,
user_id=ctx.user_id or None,
work_cycle_id=body.work_cycle_id,
clear_work_cycle=body.clear_work_cycle,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="Arbeitspaket nicht gefunden")
log_audit(
"operational.action.work_cycle",
user_id=ctx.user_id or None,
tenant_id=ctx.tenant_id,
details={
"action_id": action_id,
"work_cycle_id": body.work_cycle_id,
"clear_work_cycle": body.clear_work_cycle,
"actor_id": ctx.actor_id,
},
)
return _ok(ctx, item)

135
backend/routers/projects.py Normal file
View File

@ -0,0 +1,135 @@
"""Project API — AP1.5."""
from __future__ import annotations
from datetime import date
from typing import Literal, Optional
from capabilities import require_capability
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from services import projects as project_service
from tenant_context import TenantContext
initiative_router = APIRouter(prefix="/api/initiatives", tags=["projects"])
items_router = APIRouter(prefix="/api/projects", tags=["projects"])
class ProjectCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
description: str = ""
status: Literal["active", "paused", "completed", "archived"] = "active"
parent_project_id: Optional[str] = None
container_kind: Optional[Literal["project", "stream", "phase", "release"]] = None
roadmap_item_id: Optional[str] = None
sort_order: int = 0
target_date: Optional[date] = None
class ProjectUpdateRequest(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
description: Optional[str] = None
status: Optional[Literal["active", "paused", "completed", "archived"]] = None
parent_project_id: Optional[str] = None
clear_parent_project: bool = False
container_kind: Optional[Literal["project", "stream", "phase", "release"]] = None
clear_container_kind: bool = False
roadmap_item_id: Optional[str] = None
clear_roadmap_item: bool = False
sort_order: Optional[int] = None
target_date: Optional[date] = None
clear_target_date: bool = False
@initiative_router.get("/{initiative_id}/projects")
def list_initiative_projects(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.project.read")),
):
try:
return project_service.list_projects_for_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@initiative_router.post("/{initiative_id}/projects", status_code=201)
def create_initiative_project(
initiative_id: str,
body: ProjectCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.project.manage")),
):
try:
return project_service.create_project(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
user_id=ctx.user_id,
title=body.title,
description=body.description,
status=body.status,
parent_project_id=body.parent_project_id,
container_kind=body.container_kind,
roadmap_item_id=body.roadmap_item_id,
sort_order=body.sort_order,
target_date=body.target_date,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@items_router.get("/{project_id}")
def get_project(
project_id: str,
ctx: TenantContext = Depends(require_capability("kairo.project.read")),
):
item = project_service.get_project(tenant_id=ctx.tenant_id, project_id=project_id)
if not item:
raise HTTPException(status_code=404, detail="Projekt nicht gefunden")
return item
@items_router.patch("/{project_id}")
def update_project(
project_id: str,
body: ProjectUpdateRequest,
ctx: TenantContext = Depends(require_capability("kairo.project.manage")),
):
try:
item = project_service.update_project(
tenant_id=ctx.tenant_id,
project_id=project_id,
user_id=ctx.user_id,
title=body.title,
description=body.description,
status=body.status,
parent_project_id=body.parent_project_id,
clear_parent_project=body.clear_parent_project,
container_kind=body.container_kind,
clear_container_kind=body.clear_container_kind,
roadmap_item_id=body.roadmap_item_id,
clear_roadmap_item=body.clear_roadmap_item,
sort_order=body.sort_order,
target_date=body.target_date,
clear_target_date=body.clear_target_date,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="Projekt nicht gefunden")
return item
@items_router.delete("/{project_id}", status_code=204)
def delete_project(
project_id: str,
ctx: TenantContext = Depends(require_capability("kairo.project.manage")),
):
try:
deleted = project_service.delete_project(
tenant_id=ctx.tenant_id, project_id=project_id, user_id=ctx.user_id
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not deleted:
raise HTTPException(status_code=404, detail="Projekt nicht gefunden")

View File

@ -0,0 +1,122 @@
"""RecurringElement API — AP0.9e."""
from __future__ import annotations
from datetime import datetime
from typing import Literal, Optional
from capabilities import require_capability
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from schemas.schedule_payload import SchedulePayload
from services import recurring as recurring_service
from tenant_context import TenantContext
router = APIRouter(prefix="/api/recurring", tags=["recurring"])
class RecurringUpdateRequest(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
description: Optional[str] = None
status: Optional[Literal["active", "paused", "ended"]] = None
interval_days: Optional[int] = Field(default=None, gt=0)
clear_interval_days: bool = False
next_due_at: Optional[str] = None
clear_next_due_at: bool = False
roadmap_item_id: Optional[str] = None
clear_roadmap_item_id: bool = False
schedule: Optional[SchedulePayload] = None
class RecurringCompleteRequest(BaseModel):
measurement_note: Optional[str] = Field(default=None, max_length=2000)
def _parse_datetime(value: Optional[str]) -> Optional[datetime]:
if value is None:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise HTTPException(status_code=400, detail="Ungültiges next_due_at") from exc
@router.get("/{recurring_id}")
def get_recurring_element(
recurring_id: str,
ctx: TenantContext = Depends(require_capability("kairo.recurring.read")),
):
item = recurring_service.get_recurring_element(
tenant_id=ctx.tenant_id, recurring_id=recurring_id
)
if not item:
raise HTTPException(status_code=404, detail="Recurring-Element nicht gefunden")
return item
@router.patch("/{recurring_id}")
def update_recurring_element(
recurring_id: str,
body: RecurringUpdateRequest,
ctx: TenantContext = Depends(require_capability("kairo.recurring.manage")),
):
next_due_at = _parse_datetime(body.next_due_at) if body.next_due_at is not None else None
sched = body.schedule
try:
item = recurring_service.update_recurring_element(
tenant_id=ctx.tenant_id,
recurring_id=recurring_id,
user_id=ctx.user_id,
title=body.title,
description=body.description,
status=body.status,
interval_days=body.interval_days if sched is None else sched.interval_days,
clear_interval_days=body.clear_interval_days,
next_due_at=next_due_at,
clear_next_due_at=body.clear_next_due_at,
roadmap_item_id=body.roadmap_item_id,
clear_roadmap_item_id=body.clear_roadmap_item_id,
schedule_kind=sched.schedule_kind if sched else None,
weekday_mask=sched.weekday_mask if sched else None,
pause_until=sched.pause_until_date() if sched else None,
clear_pause_until=sched is not None and sched.pause_until is None,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="Recurring-Element nicht gefunden")
return item
@router.post("/{recurring_id}/complete")
def complete_recurring_practice(
recurring_id: str,
body: RecurringCompleteRequest,
ctx: TenantContext = Depends(require_capability("kairo.recurring.manage")),
):
from services.cadence_instance import complete_open_instance
try:
result = complete_open_instance(
tenant_id=ctx.tenant_id,
recurring_element_id=recurring_id,
user_id=ctx.user_id or None,
measurement_note=body.measurement_note or "",
)
except ValueError as exc:
detail = str(exc)
if detail == "Recurring-Element nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc
return result
@router.delete("/{recurring_id}", status_code=204)
def delete_recurring_element(
recurring_id: str,
ctx: TenantContext = Depends(require_capability("kairo.recurring.manage")),
):
if not recurring_service.delete_recurring_element(
tenant_id=ctx.tenant_id, recurring_id=recurring_id, user_id=ctx.user_id
):
raise HTTPException(status_code=404, detail="Recurring-Element nicht gefunden")

View File

@ -0,0 +1,86 @@
"""Review API — AP0.9d."""
from __future__ import annotations
from datetime import datetime
from typing import Literal, Optional
from capabilities import require_capability
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from services import reviews as review_service
from tenant_context import TenantContext
router = APIRouter(prefix="/api/reviews", tags=["reviews"])
class ReviewUpdateRequest(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
summary: Optional[str] = None
status: Optional[Literal["planned", "completed", "skipped"]] = None
due_at: Optional[str] = None
clear_due_at: bool = False
action_id: Optional[str] = None
milestone_id: Optional[str] = None
clear_action_id: bool = False
clear_milestone_id: bool = False
def _parse_due_at(value: Optional[str]) -> Optional[datetime]:
if value is None:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as exc:
raise HTTPException(status_code=400, detail="Ungültiges due_at") from exc
@router.get("/{review_id}")
def get_review(
review_id: str,
ctx: TenantContext = Depends(require_capability("kairo.review.read")),
):
item = review_service.get_review(tenant_id=ctx.tenant_id, review_id=review_id)
if not item:
raise HTTPException(status_code=404, detail="Review nicht gefunden")
return item
@router.patch("/{review_id}")
def update_review(
review_id: str,
body: ReviewUpdateRequest,
ctx: TenantContext = Depends(require_capability("kairo.review.manage")),
):
due_at = _parse_due_at(body.due_at) if body.due_at is not None else None
try:
item = review_service.update_review(
tenant_id=ctx.tenant_id,
review_id=review_id,
user_id=ctx.user_id,
title=body.title,
summary=body.summary,
status=body.status,
due_at=due_at,
clear_due_at=body.clear_due_at,
action_id=body.action_id,
milestone_id=body.milestone_id,
clear_action_id=body.clear_action_id,
clear_milestone_id=body.clear_milestone_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="Review nicht gefunden")
return item
@router.delete("/{review_id}", status_code=204)
def delete_review(
review_id: str,
ctx: TenantContext = Depends(require_capability("kairo.review.manage")),
):
if not review_service.delete_review(
tenant_id=ctx.tenant_id, review_id=review_id, user_id=ctx.user_id
):
raise HTTPException(status_code=404, detail="Review nicht gefunden")

593
backend/routers/roadmap.py Normal file
View File

@ -0,0 +1,593 @@
"""Roadmap API — AP1.4."""
from __future__ import annotations
from datetime import date
from typing import Any, Literal, Optional
from capabilities import require_capability
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from schemas.schedule_payload import SchedulePayload
from services import roadmap as roadmap_service
from services import roadmap_criteria as criteria_service
from services import roadmap_plan_snapshot as snapshot_service
from tenant_context import TenantContext
router = APIRouter(tags=["roadmap"])
class RoadmapItemCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
item_type: Literal["milestone", "review_gate", "maturity_stage", "work_cycle", "join_gate"] = "milestone"
goal_description: str = ""
definition_of_done: list[Any] = Field(default_factory=list)
status: Literal[
"planned", "active", "at_risk", "reached", "moved", "discarded"
] = "planned"
sequencing_mode: Literal["sequential", "parallel", "optional"] = "sequential"
target_date: Optional[date] = None
sort_order: int = 0
strand_project_id: Optional[str] = None
initial_criterion_title: Optional[str] = Field(default=None, max_length=500)
class RoadmapItemUpdateRequest(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
item_type: Optional[Literal["milestone", "review_gate", "maturity_stage", "work_cycle", "join_gate"]] = None
goal_description: Optional[str] = None
definition_of_done: Optional[list[Any]] = None
status: Optional[
Literal["planned", "active", "at_risk", "reached", "moved", "discarded"]
] = None
sequencing_mode: Optional[Literal["sequential", "parallel", "optional"]] = None
target_date: Optional[date] = None
clear_target_date: bool = False
sort_order: Optional[int] = None
strand_project_id: Optional[str] = None
clear_strand_project: bool = False
class DependencyCreateRequest(BaseModel):
to_item_id: str
dependency_type: Literal[
"requires", "blocks", "related", "parallel_group", "optional_branch"
] = "requires"
group_key: Optional[str] = Field(default=None, max_length=128)
class CriterionCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=500)
description: str = ""
criterion_kind: Literal["manual", "evidence_required", "review_required", "metric"] = "manual"
verification_hint: str = ""
sort_order: int = 0
class CriterionUpdateRequest(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=500)
description: Optional[str] = None
criterion_kind: Optional[
Literal["manual", "evidence_required", "review_required", "metric"]
] = None
verification_hint: Optional[str] = None
sort_order: Optional[int] = None
change_reason: str = ""
class CriterionWaiveDeferRequest(BaseModel):
decision_title: str = Field(min_length=1, max_length=255)
decision_description: str = ""
class CriterionSatisfyRequest(BaseModel):
evidence_id: Optional[str] = None
class ReopenRequest(BaseModel):
reason: str = ""
class GatePracticeCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
description: str = ""
status: Literal["active", "paused", "ended"] = "active"
interval_days: int = Field(default=1, gt=0)
schedule: Optional[SchedulePayload] = None
class PlanSnapshotCreateRequest(BaseModel):
reason: str = Field(default="", max_length=2000)
initiative_router = APIRouter(prefix="/api/initiatives", tags=["roadmap"])
items_router = APIRouter(prefix="/api/roadmap-items", tags=["roadmap"])
deps_router = APIRouter(prefix="/api/roadmap-dependencies", tags=["roadmap"])
@initiative_router.get("/{initiative_id}/roadmap")
def get_initiative_roadmap(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
):
try:
roadmap = roadmap_service.get_roadmap_for_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not roadmap:
return {"initiative_id": initiative_id, "items": []}
items = roadmap_service.list_roadmap_items_for_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
return {**roadmap, "items": items}
@initiative_router.get("/{initiative_id}/roadmap/items")
def list_initiative_roadmap_items(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
):
try:
return roadmap_service.list_roadmap_items_for_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@initiative_router.get("/{initiative_id}/roadmap/dependencies")
def list_initiative_roadmap_dependencies(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
):
try:
return roadmap_service.list_dependencies_for_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@initiative_router.get("/{initiative_id}/roadmap/criteria-progress")
def list_initiative_roadmap_criteria_progress(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
):
try:
return criteria_service.criteria_progress_for_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@initiative_router.get("/{initiative_id}/roadmap/graph-state")
def get_initiative_roadmap_graph_state(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
):
try:
from steering.graph.roadmap_engine import load_initiative_graph_state
return load_initiative_graph_state(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@initiative_router.get("/{initiative_id}/roadmap/progression-state")
def get_initiative_progression_state(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
):
try:
from services.progression_graph import compute_progression_state
return compute_progression_state(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@initiative_router.get("/{initiative_id}/roadmap/plan-ist-view")
def get_initiative_plan_ist_view(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
):
try:
from steering.graph.plan_ist import load_plan_ist_view
return load_plan_ist_view(tenant_id=ctx.tenant_id, initiative_id=initiative_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@initiative_router.get("/{initiative_id}/roadmap/plan-snapshots")
def list_initiative_plan_snapshots(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
):
try:
return snapshot_service.list_plan_snapshots(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@initiative_router.post("/{initiative_id}/roadmap/plan-snapshots", status_code=201)
def create_initiative_plan_snapshot(
initiative_id: str,
body: PlanSnapshotCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
try:
return snapshot_service.create_plan_snapshot(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
reason=body.reason,
user_id=ctx.user_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@initiative_router.post("/{initiative_id}/roadmap/items", status_code=201)
def create_initiative_roadmap_item(
initiative_id: str,
body: RoadmapItemCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
try:
return roadmap_service.create_roadmap_item(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
user_id=ctx.user_id,
title=body.title,
item_type=body.item_type,
goal_description=body.goal_description,
definition_of_done=body.definition_of_done,
status=body.status,
sequencing_mode=body.sequencing_mode,
target_date=body.target_date,
sort_order=body.sort_order,
strand_project_id=body.strand_project_id,
initial_criterion_title=body.initial_criterion_title,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@items_router.get("/{item_id}")
def get_roadmap_item(
item_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
):
item = roadmap_service.get_roadmap_item(tenant_id=ctx.tenant_id, item_id=item_id)
if not item:
raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden")
return item
@items_router.patch("/{item_id}")
def update_roadmap_item(
item_id: str,
body: RoadmapItemUpdateRequest,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
try:
item = roadmap_service.update_roadmap_item(
tenant_id=ctx.tenant_id,
item_id=item_id,
user_id=ctx.user_id,
title=body.title,
item_type=body.item_type,
goal_description=body.goal_description,
definition_of_done=body.definition_of_done,
status=body.status,
sequencing_mode=body.sequencing_mode,
target_date=body.target_date,
clear_target_date=body.clear_target_date,
sort_order=body.sort_order,
strand_project_id=body.strand_project_id,
clear_strand_project=body.clear_strand_project,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden")
return item
@items_router.delete("/{item_id}", status_code=204)
def delete_roadmap_item(
item_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
try:
deleted = roadmap_service.delete_roadmap_item(
tenant_id=ctx.tenant_id, item_id=item_id, user_id=ctx.user_id
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not deleted:
raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden")
def _work_gate_or_400(*, tenant_id: str, item_id: str) -> dict[str, Any]:
item = roadmap_service.get_roadmap_item(tenant_id=tenant_id, item_id=item_id)
if not item:
raise HTTPException(status_code=404, detail="RoadmapItem nicht gefunden")
if item.get("item_type") == "join_gate":
raise HTTPException(
status_code=400,
detail="Join-Gates haben kein Activity Set (Übungen)",
)
return item
@items_router.get("/{item_id}/practices")
def list_gate_practices(
item_id: str,
ctx: TenantContext = Depends(require_capability("kairo.recurring.read")),
):
from services import recurring as recurring_service
item = _work_gate_or_400(tenant_id=ctx.tenant_id, item_id=item_id)
try:
return recurring_service.list_recurring_for_gate(
tenant_id=ctx.tenant_id,
initiative_id=str(item["initiative_id"]),
gate_id=item_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@items_router.post("/{item_id}/practices", status_code=201)
def create_gate_practice(
item_id: str,
body: GatePracticeCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.recurring.manage")),
):
item = _work_gate_or_400(tenant_id=ctx.tenant_id, item_id=item_id)
sched = body.schedule
try:
from services.scheduled_work import create_scheduled_action_at_gate
if sched:
return create_scheduled_action_at_gate(
tenant_id=ctx.tenant_id,
initiative_id=str(item["initiative_id"]),
gate_id=item_id,
title=body.title,
description=body.description,
user_id=ctx.user_id,
schedule_kind=sched.schedule_kind,
interval_days=sched.interval_days,
weekday_mask=sched.weekday_mask,
pause_until=sched.pause_until_date(),
)
return create_scheduled_action_at_gate(
tenant_id=ctx.tenant_id,
initiative_id=str(item["initiative_id"]),
gate_id=item_id,
title=body.title,
description=body.description,
user_id=ctx.user_id,
schedule_kind="weekdays",
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@items_router.post("/{item_id}/verify-reached")
def verify_roadmap_item_reached(
item_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
try:
return roadmap_service.verify_reached(
tenant_id=ctx.tenant_id, item_id=item_id, user_id=ctx.user_id
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@items_router.post("/{item_id}/reopen")
def reopen_roadmap_item(
item_id: str,
body: ReopenRequest,
ctx: TenantContext = Depends(require_capability("kairo.roadmap.reopen")),
):
try:
return roadmap_service.reopen_roadmap_item(
tenant_id=ctx.tenant_id,
item_id=item_id,
user_id=ctx.user_id,
reason=body.reason,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@items_router.get("/{item_id}/criteria")
def list_roadmap_item_criteria(
item_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
):
try:
progress = criteria_service.criteria_progress(
tenant_id=ctx.tenant_id, item_id=item_id
)
items = criteria_service.list_criteria_for_item(
tenant_id=ctx.tenant_id, item_id=item_id
)
return {"items": items, "progress": progress}
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@items_router.post("/{item_id}/criteria", status_code=201)
def create_roadmap_item_criterion(
item_id: str,
body: CriterionCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
try:
return criteria_service.create_criterion(
tenant_id=ctx.tenant_id,
item_id=item_id,
user_id=ctx.user_id,
title=body.title,
description=body.description,
criterion_kind=body.criterion_kind,
verification_hint=body.verification_hint,
sort_order=body.sort_order,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@deps_router.delete("/{dependency_id}", status_code=204)
def delete_roadmap_dependency(
dependency_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
try:
roadmap_service.delete_dependency(
tenant_id=ctx.tenant_id,
dependency_id=dependency_id,
user_id=ctx.user_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
criteria_router = APIRouter(prefix="/api/roadmap-criteria", tags=["roadmap"])
@criteria_router.patch("/{criterion_id}")
def update_roadmap_criterion(
criterion_id: str,
body: CriterionUpdateRequest,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
try:
item = criteria_service.update_criterion(
tenant_id=ctx.tenant_id,
criterion_id=criterion_id,
user_id=ctx.user_id,
title=body.title,
description=body.description,
criterion_kind=body.criterion_kind,
verification_hint=body.verification_hint,
sort_order=body.sort_order,
change_reason=body.change_reason,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="Kriterium nicht gefunden")
return item
@criteria_router.delete("/{criterion_id}", status_code=204)
def delete_roadmap_criterion(
criterion_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
try:
if not criteria_service.delete_criterion(
tenant_id=ctx.tenant_id, criterion_id=criterion_id, user_id=ctx.user_id
):
raise HTTPException(status_code=404, detail="Kriterium nicht gefunden")
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@criteria_router.post("/{criterion_id}/satisfy")
def satisfy_roadmap_criterion(
criterion_id: str,
body: CriterionSatisfyRequest,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
try:
return criteria_service.satisfy_criterion(
tenant_id=ctx.tenant_id,
criterion_id=criterion_id,
user_id=ctx.user_id,
evidence_id=body.evidence_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@criteria_router.post("/{criterion_id}/waive")
def waive_roadmap_criterion(
criterion_id: str,
body: CriterionWaiveDeferRequest,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
try:
return criteria_service.waive_criterion(
tenant_id=ctx.tenant_id,
criterion_id=criterion_id,
user_id=ctx.user_id,
decision_title=body.decision_title,
decision_description=body.decision_description,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@criteria_router.post("/{criterion_id}/defer")
def defer_roadmap_criterion(
criterion_id: str,
body: CriterionWaiveDeferRequest,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
try:
return criteria_service.defer_criterion(
tenant_id=ctx.tenant_id,
criterion_id=criterion_id,
user_id=ctx.user_id,
decision_title=body.decision_title,
decision_description=body.decision_description,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@items_router.get("/{item_id}/dependencies")
def list_roadmap_item_dependencies(
item_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
):
try:
return roadmap_service.list_dependencies(tenant_id=ctx.tenant_id, item_id=item_id)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@items_router.post("/{item_id}/dependencies", status_code=201)
def add_roadmap_item_dependency(
item_id: str,
body: DependencyCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
try:
return roadmap_service.add_dependency(
tenant_id=ctx.tenant_id,
from_item_id=item_id,
to_item_id=body.to_item_id,
dependency_type=body.dependency_type,
group_key=body.group_key,
user_id=ctx.user_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc

123
backend/routers/steering.py Normal file
View File

@ -0,0 +1,123 @@
"""Steering API — methods, profiles and context (AP1.1 / AP2.0 / AP2.4)."""
from __future__ import annotations
from typing import Optional
from capabilities import require_capability
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from entity_archetypes.registry import resolve_default_method_key, resolve_ui_profile
from method_profiles.registry import list_method_profiles
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_compatible_methods,
list_methods,
method_to_dict,
)
from tenant_context import TenantContext
router = APIRouter(prefix="/api/steering", tags=["steering"])
class SteeringContextUpdateRequest(BaseModel):
method_key: Optional[str] = Field(default=None, min_length=1)
method_profile_key: Optional[str] = Field(default=None, min_length=1)
clear_method_profile: bool = False
def _om_capabilities_for_archetype(archetype_key: str | None) -> frozenset[str] | None:
if not archetype_key:
return None
profile = resolve_ui_profile(archetype_key)
caps = profile.get("omCapabilities") or profile.get("dataSlices") or []
return frozenset(caps)
@router.get("/methods")
def list_steering_methods(
archetype_key: Optional[str] = Query(default=None),
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
):
_ = ctx
om_caps = _om_capabilities_for_archetype(archetype_key)
if archetype_key:
methods = list_compatible_methods(archetype_key, om_capabilities=om_caps)
default_key = resolve_default_method_key(archetype_key)
else:
methods = list_methods()
default_key = None
return [
{
**method_to_dict(m),
"is_default": m.key == default_key if default_key else False,
}
for m in methods
]
@router.get("/method-profiles")
def list_steering_method_profiles(
initiative_archetype_key: Optional[str] = Query(default=None),
ctx: TenantContext = Depends(require_capability("kairo.initiative.read")),
):
_ = ctx
return [
{
"key": p["key"],
"label": p["label"],
"description": p["description"],
"initiative_archetype_key": p["initiative_archetype_key"],
"method_key": p["method_key"],
}
for p in list_method_profiles(initiative_archetype_key=initiative_archetype_key)
]
@router.patch("/initiatives/{initiative_id}/context")
def patch_initiative_steering_context(
initiative_id: str,
body: SteeringContextUpdateRequest,
ctx: TenantContext = Depends(require_capability("kairo.initiative.manage")),
):
initiative = initiative_service.get_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
if not initiative:
raise HTTPException(status_code=404, detail="Vorhaben nicht gefunden")
if not body.method_key and not body.method_profile_key and not body.clear_method_profile:
raise HTTPException(
status_code=400,
detail="method_key, method_profile_key oder clear_method_profile erforderlich",
)
try:
if body.clear_method_profile:
sc_service.update_method_profile_key(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
method_profile_key=None,
user_id=ctx.user_id,
)
elif body.method_profile_key:
sc_service.update_method_profile_key(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
method_profile_key=body.method_profile_key,
user_id=ctx.user_id,
)
elif body.method_key:
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)

77
backend/routers/tasks.py Normal file
View File

@ -0,0 +1,77 @@
"""Task item API — AP1.5."""
from __future__ import annotations
from datetime import datetime
from typing import Literal, Optional
from capabilities import require_capability
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from services import tasks as task_service
from tenant_context import TenantContext
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
class TaskUpdateRequest(BaseModel):
title: Optional[str] = Field(default=None, min_length=1, max_length=255)
description: Optional[str] = None
status: Optional[Literal["open", "in_progress", "done", "discarded"]] = None
parent_task_id: Optional[str] = None
clear_parent_task: bool = False
roadmap_item_id: Optional[str] = None
clear_roadmap_item: bool = False
sort_order: Optional[int] = None
due_at: Optional[str] = None
clear_due_at: bool = False
@router.patch("/{task_id}")
def update_task(
task_id: str,
body: TaskUpdateRequest,
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
):
due_at = None
if body.due_at is not None:
try:
due_at = datetime.fromisoformat(body.due_at.replace("Z", "+00:00"))
except ValueError as exc:
raise HTTPException(status_code=400, detail="Ungültiges due_at") from exc
try:
item = task_service.update_task(
tenant_id=ctx.tenant_id,
task_id=task_id,
user_id=ctx.user_id,
title=body.title,
description=body.description,
status=body.status,
parent_task_id=body.parent_task_id,
clear_parent_task=body.clear_parent_task,
roadmap_item_id=body.roadmap_item_id,
clear_roadmap_item=body.clear_roadmap_item,
sort_order=body.sort_order,
due_at=due_at,
clear_due_at=body.clear_due_at,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not item:
raise HTTPException(status_code=404, detail="Task nicht gefunden")
return item
@router.delete("/{task_id}", status_code=204)
def delete_task(
task_id: str,
ctx: TenantContext = Depends(require_capability("kairo.action.manage")),
):
try:
deleted = task_service.delete_task(
tenant_id=ctx.tenant_id, task_id=task_id, user_id=ctx.user_id
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not deleted:
raise HTTPException(status_code=404, detail="Task nicht gefunden")

View File

@ -0,0 +1,166 @@
"""Work cycle (Sprint/Zeitbox) API — AP2.0f."""
from __future__ import annotations
from datetime import date
from typing import Literal, Optional
from capabilities import require_capability
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from services import work_cycle as work_cycle_service
from tenant_context import TenantContext
initiative_router = APIRouter(
prefix="/api/initiatives/{initiative_id}/work-cycles",
tags=["work-cycles"],
)
class WorkCycleCreateRequest(BaseModel):
title: str = Field(min_length=1, max_length=255)
goal_description: str = ""
status: Literal["planned", "active"] = "planned"
target_date: Optional[date] = None
sort_order: int = 0
@initiative_router.get("")
def list_work_cycles(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
):
return work_cycle_service.list_work_cycles_for_initiative(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
@initiative_router.get("/active")
def get_active_work_cycle(
initiative_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
):
item = work_cycle_service.get_active_work_cycle(
tenant_id=ctx.tenant_id, initiative_id=initiative_id
)
if not item:
return None
item["open_action_count"] = work_cycle_service.count_actions_in_work_cycle(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
work_cycle_id=item["id"],
)
return item
@initiative_router.post("", status_code=201)
def create_work_cycle(
initiative_id: str,
body: WorkCycleCreateRequest,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
try:
item = work_cycle_service.create_work_cycle(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
title=body.title,
goal_description=body.goal_description,
status=body.status,
target_date=body.target_date,
sort_order=body.sort_order,
user_id=ctx.user_id,
)
if body.status == "active":
item = work_cycle_service.activate_work_cycle(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
work_cycle_id=item["id"],
user_id=ctx.user_id,
)
return item
except ValueError as exc:
detail = str(exc)
if detail == "Initiative nicht gefunden":
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc
@initiative_router.post("/{work_cycle_id}/complete")
def complete_work_cycle(
initiative_id: str,
work_cycle_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
try:
return work_cycle_service.complete_work_cycle(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
work_cycle_id=work_cycle_id,
user_id=ctx.user_id,
)
except ValueError as exc:
detail = str(exc)
if detail in ("Initiative nicht gefunden", "Sprint nicht gefunden oder bereits abgeschlossen"):
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc
@initiative_router.get("/{work_cycle_id}/open-actions")
def list_work_cycle_open_actions(
initiative_id: str,
work_cycle_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.read")),
):
try:
work_cycle_service.validate_work_cycle_in_initiative(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
work_cycle_id=work_cycle_id,
)
return work_cycle_service.list_open_actions_in_work_cycle(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
work_cycle_id=work_cycle_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@initiative_router.post("/{work_cycle_id}/reactivate")
def reactivate_work_cycle(
initiative_id: str,
work_cycle_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
try:
return work_cycle_service.reactivate_work_cycle(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
work_cycle_id=work_cycle_id,
user_id=ctx.user_id,
)
except ValueError as exc:
detail = str(exc)
if detail in ("Initiative nicht gefunden", "Sprint nicht gefunden"):
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc
@initiative_router.post("/{work_cycle_id}/activate")
def activate_work_cycle(
initiative_id: str,
work_cycle_id: str,
ctx: TenantContext = Depends(require_capability("kairo.milestone.manage")),
):
try:
return work_cycle_service.activate_work_cycle(
tenant_id=ctx.tenant_id,
initiative_id=initiative_id,
work_cycle_id=work_cycle_id,
user_id=ctx.user_id,
)
except ValueError as exc:
detail = str(exc)
if detail in ("Initiative nicht gefunden", "Zeitbox nicht gefunden"):
raise HTTPException(status_code=404, detail=detail) from exc
raise HTTPException(status_code=400, detail=detail) from exc

View File

@ -0,0 +1,125 @@
"""Workspace read API — delegates to Data Layer (AP0.7)."""
from __future__ import annotations
from typing import Optional
from capabilities import require_capability
from data_layer import actions as dl_actions
from data_layer import actors as dl_actors
from data_layer import attention as dl_attention
from data_layer import initiatives as dl_initiatives
from data_layer import portfolio_steering as dl_portfolio_steering
from data_layer import workspace as dl_workspace
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from services import initiatives as initiative_service
from tenant_context import TenantContext
router = APIRouter(prefix="/api/workspace", tags=["workspace"])
class PortfolioReorderRequest(BaseModel):
initiative_ids: list[str] = Field(min_length=1)
def _require_actor_ctx(ctx: TenantContext) -> TenantContext:
if not ctx.actor_id:
raise HTTPException(status_code=400, detail="Kein Actor im TenantContext")
return ctx
@router.get("/summary")
def workspace_summary(
ctx: TenantContext = Depends(require_capability("kairo.workspace.read")),
):
return dl_workspace.get_workspace_summary(ctx)
@router.get("/actions/open")
def workspace_open_actions(
ctx: TenantContext = Depends(require_capability("kairo.workspace.read")),
):
try:
return dl_actions.get_my_open_actions(_require_actor_ctx(ctx))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/actions/blocked")
def workspace_blocked_actions(
ctx: TenantContext = Depends(require_capability("kairo.workspace.read")),
):
try:
return dl_actions.get_my_blocked_actions(_require_actor_ctx(ctx))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/actions/today")
def workspace_today_actions(
limit: Optional[int] = Query(default=15, ge=1, le=50),
ctx: TenantContext = Depends(require_capability("kairo.workspace.read")),
):
try:
return dl_actions.get_my_today_actions(_require_actor_ctx(ctx), limit=limit or 15)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/initiatives/active")
def workspace_active_initiatives(
limit: Optional[int] = Query(default=None, ge=1, le=100),
ctx: TenantContext = Depends(require_capability("kairo.workspace.read")),
):
return dl_initiatives.get_active_initiatives(ctx, limit=limit)
@router.get("/actors/workload")
def workspace_actor_workload(
ctx: TenantContext = Depends(require_capability("kairo.workspace.read")),
):
return dl_actors.get_actor_workload(ctx)
@router.get("/attention")
def workspace_attention(
ctx: TenantContext = Depends(require_capability("kairo.attention.read")),
):
return dl_attention.get_attention_items(ctx)
@router.get("/next-actions")
def workspace_next_actions(
limit: Optional[int] = Query(default=10, ge=1, le=50),
ctx: TenantContext = Depends(require_capability("kairo.attention.read")),
):
return dl_attention.get_next_action_candidates(ctx, limit=limit or 10)
@router.get("/steering")
def workspace_steering(
limit: Optional[int] = Query(default=30, ge=1, le=100),
ctx: TenantContext = Depends(require_capability("kairo.attention.read")),
):
"""Portfolio-Steuerung — Kernel-Aggregation über alle aktiven Initiativen (K-Ext-5)."""
return dl_portfolio_steering.get_portfolio_steering(
ctx,
total_attention=limit or 30,
total_next_work=min(limit or 30, 15),
)
@router.post("/portfolio/reorder")
def workspace_portfolio_reorder(
body: PortfolioReorderRequest,
ctx: TenantContext = Depends(require_capability("kairo.initiative.manage")),
):
try:
return initiative_service.reorder_portfolio(
tenant_id=ctx.tenant_id,
initiative_ids=body.initiative_ids,
user_id=ctx.user_id,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc

View File

@ -0,0 +1,25 @@
"""Shared Pydantic models for Schedule API payloads."""
from __future__ import annotations
from typing import Literal, Optional
from pydantic import BaseModel, Field, model_validator
class SchedulePayload(BaseModel):
schedule_kind: Literal["interval", "weekdays"] = "interval"
interval_days: int = Field(default=1, gt=0)
weekday_mask: int = Field(default=0, ge=0, le=127)
pause_until: Optional[str] = Field(default=None, description="ISO date YYYY-MM-DD")
@model_validator(mode="after")
def validate_kind(self) -> "SchedulePayload":
if self.schedule_kind == "weekdays" and self.weekday_mask <= 0:
raise ValueError("Mindestens ein Wochentag auswählen")
return self
def pause_until_date(self):
from services.schedule import _parse_pause_until
return _parse_pause_until(self.pause_until)

View File

@ -4,6 +4,21 @@
-- Reparatur: ungültige Human-Actors (user_id NULL verletzt CHECK constraint) -- Reparatur: ungültige Human-Actors (user_id NULL verletzt CHECK constraint)
DELETE FROM actors WHERE actor_type = 'human' AND user_id IS NULL; DELETE FROM actors WHERE actor_type = 'human' AND user_id IS NULL;
-- AP0.5: Vorhaben/Maßnahmen pytest-Artefakte vor Actor-Löschung entfernen
-- (initiatives.owner_actor_id → actors ON DELETE RESTRICT)
DELETE FROM initiatives
WHERE owner_actor_id IN (
SELECT id FROM actors WHERE user_id IN (SELECT id FROM users WHERE email ~* '@example\.com$')
);
DELETE FROM initiatives i
USING tenants t
WHERE i.tenant_id = t.id
AND t.slug ~ '^t-[a-f0-9]{10}$'
AND NOT EXISTS (
SELECT 1 FROM tenant_memberships tm WHERE tm.tenant_id = t.id
);
-- Abhängigkeiten vor User-Löschung (human actors: user_id NOT NULL constraint) -- Abhängigkeiten vor User-Löschung (human actors: user_id NOT NULL constraint)
DELETE FROM actors DELETE FROM actors
WHERE user_id IN (SELECT id FROM users WHERE email ~* '@example\.com$'); WHERE user_id IN (SELECT id FROM users WHERE email ~* '@example\.com$');

View File

@ -0,0 +1,456 @@
"""Dogfooding R1 — Referenz-Vorhaben „Jinkendo Kairo“ (idempotent, Nicht-Prod).
DEPRECATED (2026-07-12): Nur Dev-Visualisierung. Produkt-Anlage über AP2.2a Starter-Kits.
Siehe Kairo_MVP_Execution_Plan_v0.2.md nicht weiter ausbauen.
"""
from __future__ import annotations
import os
from typing import Any, Optional
GITEA_BASE = "http://192.168.2.144:3000/Lars/Kairo-Jinkendo"
INITIATIVE_TITLE = "Jinkendo Kairo"
TENANT_SLUG = "stommer"
def _gitea_commit(sha: str, label: str = "") -> str:
line = f"Commit: [{sha[:7]}]({GITEA_BASE}/commit/{sha})"
if label:
return f"{label}\n{line}\nBranch: develop"
return f"{line}\nBranch: develop"
def _resolve_context() -> tuple[str, str]:
from db import get_connection
from psycopg2.extras import RealDictCursor
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute("SELECT id FROM tenants WHERE slug = %s", (TENANT_SLUG,))
row = cur.fetchone()
if not row:
raise RuntimeError(
f"Tenant '{TENANT_SLUG}' nicht gefunden — zuerst Portal-Admin/seed_003"
)
tenant_id = str(row["id"])
cur.execute(
"""
SELECT a.id
FROM actors a
WHERE a.tenant_id = %s AND a.actor_type = 'human' AND a.is_active = TRUE
ORDER BY a.created_at
LIMIT 1
""",
(tenant_id,),
)
actor_row = cur.fetchone()
if not actor_row:
raise RuntimeError("Kein Human-Actor im Tenant — seed_003 zuerst")
return tenant_id, str(actor_row["id"])
finally:
conn.close()
def _find_initiative(tenant_id: str) -> Optional[dict[str, Any]]:
from services.initiatives import list_initiatives
for item in list_initiatives(tenant_id=tenant_id):
if item["title"] == INITIATIVE_TITLE:
return item
return None
def _find_by_title(items: list[dict[str, Any]], title: str) -> Optional[dict[str, Any]]:
for item in items:
if item["title"] == title:
return item
return None
def _close_gate_and_verify(*, tenant_id: str, item_id: str) -> None:
from services.roadmap import get_roadmap_item, verify_reached
from services.roadmap_criteria import list_criteria_for_item, waive_criterion
item = get_roadmap_item(tenant_id=tenant_id, item_id=item_id)
if item and item.get("status") == "reached":
return
for criterion in list_criteria_for_item(tenant_id=tenant_id, item_id=item_id):
if criterion["status"] not in {"satisfied", "waived", "deferred"}:
waive_criterion(
tenant_id=tenant_id,
criterion_id=criterion["id"],
decision_title="Dogfooding R1 — historisches Gate",
decision_description="Rückwirkende Abnahme per Seed.",
)
verify_reached(tenant_id=tenant_id, item_id=item_id)
def _ensure_gate_chain(
*,
tenant_id: str,
initiative_id: str,
gates: list[tuple[str, str, str]],
) -> dict[str, str]:
"""gates: (title, status, goal) -> id map."""
from services.roadmap import add_dependency, create_roadmap_item, list_roadmap_items_for_initiative
existing = list_roadmap_items_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
ids: dict[str, str] = {}
ordered: list[str] = []
for index, (title, status, goal) in enumerate(gates):
found = _find_by_title(existing, title)
if found:
gate_id = found["id"]
else:
created = create_roadmap_item(
tenant_id=tenant_id,
initiative_id=initiative_id,
title=title,
item_type="milestone",
goal_description=goal,
status="planned" if status == "reached" else status,
sort_order=index * 10,
initial_criterion_title=f"{title} — Abnahme",
)
gate_id = created["id"]
existing.append(created)
ids[title] = gate_id
ordered.append(gate_id)
if status == "reached":
_close_gate_and_verify(tenant_id=tenant_id, item_id=gate_id)
for i in range(1, len(ordered)):
try:
add_dependency(
tenant_id=tenant_id,
from_item_id=ordered[i],
to_item_id=ordered[i - 1],
dependency_type="requires",
)
except ValueError as exc:
if "existiert bereits" not in str(exc):
raise
return ids
def _ensure_projects(
*, tenant_id: str, initiative_id: str, titles: list[str]
) -> dict[str, str]:
from services.projects import create_project, list_projects_for_initiative
existing = list_projects_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
ids: dict[str, str] = {}
for index, title in enumerate(titles):
found = _find_by_title(existing, title)
if found:
ids[title] = found["id"]
continue
created = create_project(
tenant_id=tenant_id,
initiative_id=initiative_id,
title=title,
description=f"Ist-Struktur — {title}",
sort_order=index,
)
ids[title] = created["id"]
existing.append(created)
return ids
def _ensure_action(
*,
tenant_id: str,
initiative_id: str,
title: str,
status: str,
roadmap_item_id: Optional[str] = None,
project_id: Optional[str] = None,
description: str = "",
) -> dict[str, Any]:
from services.actions import create_action, list_actions_for_initiative
existing = list_actions_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
found = _find_by_title(existing, title)
if found:
if found.get("status") != status:
from services.actions import update_action
updated = update_action(
tenant_id=tenant_id,
action_id=found["id"],
status=status,
)
if updated:
found = updated
return found
return create_action(
tenant_id=tenant_id,
initiative_id=initiative_id,
title=title,
description=description,
status=status,
roadmap_item_id=roadmap_item_id,
project_id=project_id,
)
def _ensure_evidence(
*,
tenant_id: str,
initiative_id: str,
title: str,
description: str,
roadmap_item_id: Optional[str] = None,
action_id: Optional[str] = None,
actor_id: Optional[str] = None,
) -> None:
from services.evidence import create_evidence, list_evidence_for_initiative
for item in list_evidence_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
if item["title"] == title:
return
create_evidence(
tenant_id=tenant_id,
initiative_id=initiative_id,
title=title,
description=description,
status="accepted",
roadmap_item_id=roadmap_item_id,
action_id=action_id,
submitted_by_actor_id=actor_id,
)
def _ensure_decision(
*,
tenant_id: str,
initiative_id: str,
title: str,
outcome: str,
) -> None:
from db import get_connection
from psycopg2.extras import RealDictCursor
from services.decisions import create_decision
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT 1 FROM decisions
WHERE tenant_id = %s AND initiative_id = %s AND title = %s
""",
(tenant_id, initiative_id, title),
)
if cur.fetchone():
return
finally:
conn.close()
create_decision(
tenant_id=tenant_id,
initiative_id=initiative_id,
title=title,
description=outcome,
status="decided",
outcome=outcome,
)
def _ensure_portfolio_first(*, tenant_id: str, initiative_id: str) -> None:
from services.initiatives import list_initiatives, reorder_portfolio
all_items = list_initiatives(tenant_id=tenant_id)
ids = [initiative_id] + [i["id"] for i in all_items if i["id"] != initiative_id]
reorder_portfolio(tenant_id=tenant_id, initiative_ids=ids)
def run() -> None:
if os.getenv("ENVIRONMENT", "development").strip().lower() == "production":
print("[dogfooding] Übersprungen in Production")
return
tenant_id, owner_actor_id = _resolve_context()
initiative = _find_initiative(tenant_id)
if not initiative:
from services.initiatives import create_initiative
initiative = create_initiative(
tenant_id=tenant_id,
title=INITIATIVE_TITLE,
owner_actor_id=owner_actor_id,
archetype_key="initiative.product",
method_profile_key="product.kairo_dev",
goal=(
"Operativer Program Director — MVP v0.3 Stufe A (B2b). "
f"Repository: {GITEA_BASE} (Branch develop)"
),
vision="Kairo steuert Kairo — Dogfooding Referenz-Vorhaben.",
target_state_summary="Leitfrage in ≤2 Min; Gates + Ist gespiegelt aus Git/Docs.",
priority="high",
)
print(f"[dogfooding] Initiative angelegt: {initiative['id']}")
else:
print(f"[dogfooding] Initiative vorhanden: {initiative['id']}")
initiative_id = initiative["id"]
gate_defs = [
("G1 — Foundation AP0", "reached", "Tenant, Actor, Auth, OM, Migrationen"),
("G2 — IA & Steering Skeleton", "reached", "AP1.2c, AP1.0, Snapshot, Signals"),
("G3 — Gates & Checkliste", "reached", "AP1.4/4b/4c, Verify, Kriterien"),
("G4 — Ist-Hierarchie & Plan/Ist", "reached", "AP1.5/5c, AP1.6, AP1.14, Journey"),
("G5 — PM Work Modes & Plan-Outline", "active", "AP1.9a/9c ✓; AP1.12 ◐; AP1.16 ◐"),
("G6 — Archetyp-Steuerung MVP", "active", "AP2.0 ◐; AP2.0d ✓; Execution-Graph ✓"),
("G7 — MVP-Abnahfe B2b", "planned", "Validation, Leitfrage, Cockpit-Signale"),
("G8 — Agent & Integration", "planned", "AP1.7, Gitea-Webhook, MCP"),
]
gate_ids = _ensure_gate_chain(
tenant_id=tenant_id,
initiative_id=initiative_id,
gates=gate_defs,
)
project_ids = _ensure_projects(
tenant_id=tenant_id,
initiative_id=initiative_id,
titles=["Backend", "Frontend", "Docs & ADPs", "Deploy / CI"],
)
g5 = gate_ids["G5 — PM Work Modes & Plan-Outline"]
g6 = gate_ids["G6 — Archetyp-Steuerung MVP"]
g4 = gate_ids["G4 — Ist-Hierarchie & Plan/Ist"]
done_actions = [
(
"AP1.6b — Journey Gate-Fortschritt",
"done",
g4,
project_ids["Frontend"],
"b85a6ec",
),
(
"AP1.8a — Portfolio-Rang Cockpit",
"done",
g5,
project_ids["Frontend"],
"a19c6ab",
),
(
"AP1.14 — Plan/Ist-Snapshots",
"done",
g4,
project_ids["Backend"],
"064779e",
),
(
"AP2.0 — Archetyp-Slice",
"done",
g6,
project_ids["Backend"],
"3bd9832",
),
(
"AP1.16 — Execution-Graph & Durchführungsplan",
"done",
g6,
project_ids["Backend"],
"b9bfa9e",
),
(
"AP1.9c — Cockpit-Signale auf Portfolio-Kacheln",
"done",
g5,
project_ids["Frontend"],
"b9bfa9e",
),
(
"AP2.0d — Next-Action-Strategien pro Archetyp",
"done",
g6,
project_ids["Backend"],
"284b9d5",
),
(
"DOC-Sync — Status-Review & Roadmap",
"done",
g6,
project_ids["Docs & ADPs"],
None,
),
]
for title, status, gate_id, project_id, sha in done_actions:
action = _ensure_action(
tenant_id=tenant_id,
initiative_id=initiative_id,
title=title,
status=status,
roadmap_item_id=gate_id,
project_id=project_id,
)
if sha:
_ensure_evidence(
tenant_id=tenant_id,
initiative_id=initiative_id,
title=f"Merge {title}",
description=_gitea_commit(sha, title),
roadmap_item_id=gate_id,
action_id=action["id"],
actor_id=owner_actor_id,
)
open_actions = [
("Dogfooding R2 — Ist laufend pflegen", g6, project_ids["Docs & ADPs"]),
("AP1.7 — Operational Actor API", g6, project_ids["Backend"]),
("AP2.0f — work_cycle / Sprint-Zeitbox", g6, project_ids["Backend"]),
]
for title, gate_id, project_id in open_actions:
_ensure_action(
tenant_id=tenant_id,
initiative_id=initiative_id,
title=title,
status="open",
roadmap_item_id=gate_id,
project_id=project_id,
)
_ensure_decision(
tenant_id=tenant_id,
initiative_id=initiative_id,
title="Join/Branch Graph — deferred",
outcome="OR-Alternativpfade und volle Join-Logik erst bei Use Case (AP1.15d). "
"Siehe ADP_Roadmap_Graph_and_Gate_Checklist_v0.1.md",
)
_ensure_decision(
tenant_id=tenant_id,
initiative_id=initiative_id,
title="MCP nach Operational API",
outcome="MCP-Adapter erst nach AP1.7 (/api/operational). Schicht 4 eingefroren bis B2b-Validation.",
)
_ensure_decision(
tenant_id=tenant_id,
initiative_id=initiative_id,
title="MVP v0.3 PO-Freigabe",
outcome="Abnahfe Stufe A: A1, A2, B2b + B3 minimal. Dokument: Kairo_MVP_Definition_v0.3.md",
)
_ensure_portfolio_first(tenant_id=tenant_id, initiative_id=initiative_id)
from services.actors import create_actor, list_actors
agents = list_actors(tenant_id=tenant_id, actor_type="agent", q="Cursor")
if not any(a["name"] == "Cursor Agent" for a in agents):
create_actor(tenant_id=tenant_id, actor_type="agent", name="Cursor Agent")
print("[dogfooding] Agent „Cursor Agent“ angelegt — Service Token manuell erstellen")
print("[dogfooding] R2 sync — Ist-Stand gespiegelt (AP1.9c, AP1.16, AP2.0d done)")

762
backend/services/actions.py Normal file
View File

@ -0,0 +1,762 @@
"""Action (Maßnahme) service — tenant-scoped CRUD, assignments, open actions."""
from __future__ import annotations
from typing import Any, Literal, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from services.audit import log_audit
from services.initiatives import PRIORITIES, get_initiative
from services.plan_ist import validate_roadmap_item_in_initiative
from services.projects import project_is_leaf
from services.work_cycle import validate_work_cycle_in_initiative
ActionStatus = Literal[
"open", "ready", "in_progress", "blocked", "review_required", "done", "discarded"
]
ACTION_STATUSES = frozenset(
{"open", "ready", "in_progress", "blocked", "review_required", "done", "discarded"}
)
OPEN_ACTION_STATUSES = frozenset(
{"open", "ready", "in_progress", "blocked", "review_required"}
)
_ACTION_COLUMNS = """
id, tenant_id, initiative_id, project_id, roadmap_item_id, work_cycle_id, schedule_id,
title, description, status, priority, due_at, sort_order, action_kind, parent_action_id,
created_at, updated_at
"""
ACTION_KINDS = frozenset({"delivery", "planning", "review", "bug", "issue", "tech_debt"})
UNPLAN_ALLOWED_STATUSES = frozenset({"open", "ready"})
CARRYOVER_UNPLAN_STATUSES = frozenset(
{"open", "ready", "in_progress", "blocked", "review_required"}
)
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
result = dict(row)
for key in (
"id",
"tenant_id",
"initiative_id",
"project_id",
"roadmap_item_id",
"work_cycle_id",
"parent_action_id",
"owner_actor_id",
"schedule_id",
):
if result.get(key):
result[key] = str(result[key])
if result.get("created_at"):
result["created_at"] = result["created_at"].isoformat()
if result.get("updated_at"):
result["updated_at"] = result["updated_at"].isoformat()
if result.get("due_at"):
result["due_at"] = result["due_at"].isoformat()
return result
def _validate_parent_action_in_initiative(
*,
cur,
tenant_id: str,
initiative_id: str,
parent_action_id: Optional[str],
action_id: Optional[str] = None,
) -> None:
if not parent_action_id:
return
if action_id and parent_action_id == action_id:
raise ValueError("Arbeitspaket kann nicht auf sich selbst verweisen")
cur.execute(
"""
SELECT action_kind
FROM actions
WHERE id = %s AND tenant_id = %s AND initiative_id = %s
""",
(parent_action_id, tenant_id, initiative_id),
)
row = cur.fetchone()
if not row:
raise ValueError("Referenz-Arbeitspaket (Feature) nicht gefunden")
action_kind = row["action_kind"] if isinstance(row, dict) else row[0]
if action_kind in ("bug", "issue", "tech_debt"):
raise ValueError("Referenz muss ein Feature-Arbeitspaket sein, kein Bug/Issue/Schuld")
def _action_kind_to_item_kind(action_kind: str) -> str:
if action_kind == "bug":
return "bug"
if action_kind == "issue":
return "issue"
if action_kind == "tech_debt":
return "tech_debt"
return "story"
def _validate_action_kind(action_kind: str) -> None:
if action_kind not in ACTION_KINDS:
raise ValueError(f"Ungültiger action_kind: {action_kind}")
def _validate_action_status(status: str) -> None:
if status not in ACTION_STATUSES:
raise ValueError(f"Ungültiger Action-Status: {status}")
def _validate_priority(priority: str) -> None:
if priority not in PRIORITIES:
raise ValueError(f"Ungültige Priorität: {priority}")
def _load_assignments(*, tenant_id: str, action_ids: list[str]) -> dict[str, list[str]]:
if not action_ids:
return {}
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT action_id, actor_id
FROM action_assignments
WHERE tenant_id = %s AND action_id = ANY(%s::uuid[])
ORDER BY created_at
""",
(tenant_id, action_ids),
)
result: dict[str, list[str]] = {aid: [] for aid in action_ids}
for row in cur.fetchall():
result[str(row["action_id"])].append(str(row["actor_id"]))
return result
finally:
conn.close()
def _attach_assignments(
actions: list[dict[str, Any]], *, tenant_id: str
) -> list[dict[str, Any]]:
if not actions:
return actions
action_ids = [a["id"] for a in actions]
assignments = _load_assignments(tenant_id=tenant_id, action_ids=action_ids)
for action in actions:
action["assigned_actor_ids"] = assignments.get(action["id"], [])
return actions
def _actor_in_tenant(*, tenant_id: str, actor_id: str) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT 1 FROM actors WHERE id = %s AND tenant_id = %s AND is_active = TRUE",
(actor_id, tenant_id),
)
return cur.fetchone() is not None
finally:
conn.close()
def _validate_project_in_initiative(
*, tenant_id: str, initiative_id: str, project_id: Optional[str]
) -> None:
if not project_id:
return
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT 1 FROM projects
WHERE id = %s AND tenant_id = %s AND initiative_id = %s
""",
(project_id, tenant_id, initiative_id),
)
if not cur.fetchone():
raise ValueError("Projekt gehört nicht zum Vorhaben")
if not project_is_leaf(tenant_id=tenant_id, project_id=project_id):
raise ValueError(
"Arbeitspakete nur an Blatt-Projekten (ohne Unterprojekte)"
)
finally:
conn.close()
def create_action(
*,
tenant_id: str,
initiative_id: str,
title: str,
description: str = "",
status: ActionStatus = "open",
priority: str = "normal",
due_at: Optional[Any] = None,
project_id: Optional[str] = None,
roadmap_item_id: Optional[str] = None,
work_cycle_id: Optional[str] = None,
assigned_actor_ids: Optional[list[str]] = None,
sort_order: int = 0,
action_kind: str = "delivery",
parent_action_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> dict[str, Any]:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
_validate_action_status(status)
_validate_priority(priority)
_validate_action_kind(action_kind)
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden")
_validate_project_in_initiative(
tenant_id=tenant_id, initiative_id=initiative_id, project_id=project_id
)
assigned_actor_ids = assigned_actor_ids or []
for actor_id in assigned_actor_ids:
if not _actor_in_tenant(tenant_id=tenant_id, actor_id=actor_id):
raise ValueError(f"Actor {actor_id} gehört nicht zum Tenant")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
validate_roadmap_item_in_initiative(
cur,
tenant_id=tenant_id,
initiative_id=initiative_id,
roadmap_item_id=roadmap_item_id,
)
validate_work_cycle_in_initiative(
tenant_id=tenant_id,
initiative_id=initiative_id,
work_cycle_id=work_cycle_id,
cur=cur,
)
_validate_parent_action_in_initiative(
cur=cur,
tenant_id=tenant_id,
initiative_id=initiative_id,
parent_action_id=parent_action_id,
)
cur.execute(
f"""
INSERT INTO actions (
tenant_id, initiative_id, project_id, roadmap_item_id, work_cycle_id,
title, description, status, priority, due_at, sort_order, action_kind,
parent_action_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING {_ACTION_COLUMNS}
""",
(
tenant_id,
initiative_id,
project_id,
roadmap_item_id,
work_cycle_id,
title,
description,
status,
priority,
due_at,
sort_order,
action_kind,
parent_action_id,
),
)
row = _serialize_row(dict(cur.fetchone()))
for actor_id in assigned_actor_ids:
cur.execute(
"""
INSERT INTO action_assignments (tenant_id, action_id, actor_id)
VALUES (%s, %s, %s)
""",
(tenant_id, row["id"], actor_id),
)
conn.commit()
finally:
conn.close()
row["assigned_actor_ids"] = assigned_actor_ids
log_audit(
"action.created",
user_id=user_id,
tenant_id=tenant_id,
details={
"action_id": row["id"],
"initiative_id": initiative_id,
"title": title,
"status": status,
"assigned_actor_ids": assigned_actor_ids,
},
)
return row
def list_actions_for_initiative(
*, tenant_id: str, initiative_id: str
) -> list[dict[str, Any]]:
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
return []
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
SELECT {_ACTION_COLUMNS}
FROM actions
WHERE tenant_id = %s AND initiative_id = %s
ORDER BY sort_order ASC, title ASC, created_at DESC
""",
(tenant_id, initiative_id),
)
actions = [_serialize_row(dict(row)) for row in cur.fetchall()]
finally:
conn.close()
return _attach_assignments(actions, tenant_id=tenant_id)
def get_action(*, tenant_id: str, action_id: str) -> Optional[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
SELECT {_ACTION_COLUMNS}
FROM actions
WHERE id = %s AND tenant_id = %s
""",
(action_id, tenant_id),
)
row = cur.fetchone()
if not row:
return None
action = _serialize_row(dict(row))
finally:
conn.close()
enriched = _attach_assignments([action], tenant_id=tenant_id)[0]
from services.scheduled_work import attach_action_schedule_fields
return attach_action_schedule_fields(enriched, tenant_id=tenant_id)
def update_action(
*,
tenant_id: str,
action_id: str,
user_id: Optional[str] = None,
title: Optional[str] = None,
description: Optional[str] = None,
status: Optional[ActionStatus] = None,
priority: Optional[str] = None,
due_at: Optional[Any] = None,
clear_due_at: bool = False,
project_id: Optional[str] = None,
clear_project: bool = False,
roadmap_item_id: Optional[str] = None,
clear_roadmap_item: bool = False,
work_cycle_id: Optional[str] = None,
clear_work_cycle: bool = False,
sort_order: Optional[int] = None,
action_kind: Optional[str] = None,
parent_action_id: Optional[str] = None,
clear_parent_action: bool = False,
) -> Optional[dict[str, Any]]:
existing = get_action(tenant_id=tenant_id, action_id=action_id)
if not existing:
return None
updates: list[str] = []
params: list[Any] = []
old_status = existing["status"]
if title is not None:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
updates.append("title = %s")
params.append(title)
if description is not None:
updates.append("description = %s")
params.append(description)
if status is not None:
_validate_action_status(status)
updates.append("status = %s")
params.append(status)
if priority is not None:
_validate_priority(priority)
updates.append("priority = %s")
params.append(priority)
if clear_due_at:
updates.append("due_at = NULL")
elif due_at is not None:
updates.append("due_at = %s")
params.append(due_at)
if clear_project:
updates.append("project_id = NULL")
elif project_id is not None:
_validate_project_in_initiative(
tenant_id=tenant_id,
initiative_id=existing["initiative_id"],
project_id=project_id,
)
updates.append("project_id = %s")
params.append(project_id)
if clear_roadmap_item:
updates.append("roadmap_item_id = NULL")
elif roadmap_item_id is not None:
conn = get_connection()
try:
with conn.cursor() as cur:
validate_roadmap_item_in_initiative(
cur,
tenant_id=tenant_id,
initiative_id=existing["initiative_id"],
roadmap_item_id=roadmap_item_id,
)
finally:
conn.close()
updates.append("roadmap_item_id = %s")
params.append(roadmap_item_id)
if clear_work_cycle:
updates.append("work_cycle_id = NULL")
elif work_cycle_id is not None:
validate_work_cycle_in_initiative(
tenant_id=tenant_id,
initiative_id=existing["initiative_id"],
work_cycle_id=work_cycle_id,
)
updates.append("work_cycle_id = %s")
params.append(work_cycle_id)
if sort_order is not None:
updates.append("sort_order = %s")
params.append(sort_order)
if action_kind is not None:
_validate_action_kind(action_kind)
updates.append("action_kind = %s")
params.append(action_kind)
if clear_parent_action:
updates.append("parent_action_id = NULL")
elif parent_action_id is not None:
updates.append("parent_action_id = %s")
params.append(parent_action_id)
if not updates:
return existing
updates.append("updated_at = NOW()")
params.extend([action_id, tenant_id])
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
if parent_action_id is not None and not clear_parent_action:
_validate_parent_action_in_initiative(
cur=cur,
tenant_id=tenant_id,
initiative_id=existing["initiative_id"],
parent_action_id=parent_action_id,
action_id=action_id,
)
cur.execute(
f"""
UPDATE actions
SET {", ".join(updates)}
WHERE id = %s AND tenant_id = %s
RETURNING {_ACTION_COLUMNS}
""",
params,
)
row = cur.fetchone()
if not row:
return None
conn.commit()
finally:
conn.close()
result = get_action(tenant_id=tenant_id, action_id=action_id)
assert result is not None
log_audit(
"action.updated",
user_id=user_id,
tenant_id=tenant_id,
details={"action_id": action_id, "fields": updates},
)
if status is not None and status != old_status:
log_audit(
"action.status_changed",
user_id=user_id,
tenant_id=tenant_id,
details={
"action_id": action_id,
"from_status": old_status,
"to_status": status,
},
)
if status == "done":
from services.steering_events import emit_steering_event
emit_steering_event(
tenant_id=tenant_id,
initiative_id=str(existing["initiative_id"]),
event_kind="actor_result",
payload={
"action_id": action_id,
"from_status": old_status,
"to_status": status,
},
user_id=user_id,
)
return result
def set_action_assignments(
*,
tenant_id: str,
action_id: str,
actor_ids: list[str],
user_id: Optional[str] = None,
) -> Optional[dict[str, Any]]:
existing = get_action(tenant_id=tenant_id, action_id=action_id)
if not existing:
return None
unique_actor_ids = list(dict.fromkeys(actor_ids))
for actor_id in unique_actor_ids:
if not _actor_in_tenant(tenant_id=tenant_id, actor_id=actor_id):
raise ValueError(f"Actor {actor_id} gehört nicht zum Tenant")
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM action_assignments WHERE action_id = %s AND tenant_id = %s",
(action_id, tenant_id),
)
for actor_id in unique_actor_ids:
cur.execute(
"""
INSERT INTO action_assignments (tenant_id, action_id, actor_id)
VALUES (%s, %s, %s)
""",
(tenant_id, action_id, actor_id),
)
cur.execute(
"UPDATE actions SET updated_at = NOW() WHERE id = %s AND tenant_id = %s",
(action_id, tenant_id),
)
conn.commit()
finally:
conn.close()
log_audit(
"action.assigned",
user_id=user_id,
tenant_id=tenant_id,
details={
"action_id": action_id,
"actor_ids": unique_actor_ids,
"previous_actor_ids": existing.get("assigned_actor_ids", []),
},
)
return get_action(tenant_id=tenant_id, action_id=action_id)
def delete_action(
*,
tenant_id: str,
action_id: str,
user_id: Optional[str] = None,
) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM actions WHERE id = %s AND tenant_id = %s RETURNING id",
(action_id, tenant_id),
)
deleted = cur.fetchone() is not None
conn.commit()
finally:
conn.close()
if deleted:
log_audit(
"action.deleted",
user_id=user_id,
tenant_id=tenant_id,
details={"action_id": action_id},
)
return deleted
def list_open_actions_for_actor(
*, tenant_id: str, actor_id: str
) -> list[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT a.id, a.tenant_id, a.initiative_id, a.title, a.description,
a.status, a.priority, a.due_at, a.created_at, a.updated_at,
i.title AS initiative_title
FROM actions a
JOIN action_assignments aa ON aa.action_id = a.id AND aa.tenant_id = a.tenant_id
JOIN initiatives i ON i.id = a.initiative_id AND i.tenant_id = a.tenant_id
WHERE a.tenant_id = %s
AND aa.actor_id = %s
AND a.status = ANY(%s)
ORDER BY
CASE a.priority
WHEN 'high' THEN 0
WHEN 'normal' THEN 1
WHEN 'low' THEN 2
END,
a.updated_at DESC
""",
(tenant_id, actor_id, list(OPEN_ACTION_STATUSES)),
)
actions = [_serialize_row(dict(row)) for row in cur.fetchall()]
finally:
conn.close()
return _attach_assignments(actions, tenant_id=tenant_id)
def unplan_action_to_backlog(
*,
tenant_id: str,
action_id: str,
user_id: Optional[str] = None,
carryover: bool = False,
) -> dict[str, Any]:
"""Entfernt ein Arbeitspaket aus dem Sprint und stellt es im Eingang wieder her."""
existing = get_action(tenant_id=tenant_id, action_id=action_id)
if not existing:
raise ValueError("Arbeitspaket nicht gefunden")
allowed = CARRYOVER_UNPLAN_STATUSES if carryover else UNPLAN_ALLOWED_STATUSES
if existing["status"] not in allowed:
raise ValueError(
"Arbeitspaket kann in diesem Status nicht zurück in den Eingang geschoben werden"
if carryover
else "Nur offene oder bereite Arbeitspakete können zurück in den Eingang"
)
initiative_id = existing["initiative_id"]
item_kind = _action_kind_to_item_kind(existing.get("action_kind") or "delivery")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id
FROM backlog_items
WHERE tenant_id = %s AND converted_action_id = %s
""",
(tenant_id, action_id),
)
linked = cur.fetchone()
if linked:
backlog_id = str(linked["id"])
cur.execute(
"""
UPDATE backlog_items
SET status = 'accepted',
converted_action_id = NULL,
title = %s,
description = %s,
priority = %s,
roadmap_item_id = %s,
item_kind = %s,
parent_action_id = %s,
updated_at = NOW()
WHERE id = %s AND tenant_id = %s
RETURNING id, tenant_id, initiative_id, title, description, status,
priority, roadmap_item_id, converted_action_id, sort_order,
item_kind, parent_action_id, created_at, updated_at
""",
(
existing["title"],
existing.get("description") or "",
existing["priority"],
existing.get("roadmap_item_id"),
item_kind,
existing.get("parent_action_id"),
backlog_id,
tenant_id,
),
)
backlog_row = _serialize_row(dict(cur.fetchone()))
else:
cur.execute(
"""
SELECT COALESCE(MAX(sort_order), -10) + 10 AS next_order
FROM backlog_items
WHERE tenant_id = %s AND initiative_id = %s
""",
(tenant_id, initiative_id),
)
next_order = int(cur.fetchone()["next_order"])
cur.execute(
"""
INSERT INTO backlog_items (
tenant_id, initiative_id, title, description, status, priority,
roadmap_item_id, sort_order, item_kind, parent_action_id
)
VALUES (%s, %s, %s, %s, 'accepted', %s, %s, %s, %s, %s)
RETURNING id, tenant_id, initiative_id, title, description, status,
priority, roadmap_item_id, converted_action_id, sort_order,
item_kind, parent_action_id, created_at, updated_at
""",
(
tenant_id,
initiative_id,
existing["title"],
existing.get("description") or "",
existing["priority"],
existing.get("roadmap_item_id"),
next_order,
item_kind,
existing.get("parent_action_id"),
),
)
backlog_row = _serialize_row(dict(cur.fetchone()))
cur.execute(
"""
UPDATE actions
SET status = 'discarded',
work_cycle_id = NULL,
updated_at = NOW()
WHERE id = %s AND tenant_id = %s
RETURNING id, tenant_id, initiative_id, project_id, roadmap_item_id,
work_cycle_id, title, description, status, priority, due_at,
sort_order, action_kind, parent_action_id, created_at, updated_at
""",
(action_id, tenant_id),
)
action_row = _serialize_row(dict(cur.fetchone()))
conn.commit()
finally:
conn.close()
action_result = get_action(tenant_id=tenant_id, action_id=action_id)
log_audit(
"action.unplanned_to_backlog",
user_id=user_id,
tenant_id=tenant_id,
details={
"action_id": action_id,
"backlog_item_id": backlog_row["id"],
"initiative_id": initiative_id,
},
)
return {"backlog_item": backlog_row, "action": action_result or action_row}

View File

@ -0,0 +1,280 @@
"""Actor Service Tokens — AP1.7c (machine auth for Operational API)."""
from __future__ import annotations
import hashlib
import json
import secrets
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from services.audit import log_audit
TOKEN_PREFIX = "kairo_at_"
DEFAULT_AGENT_CAPABILITIES: tuple[str, ...] = (
"kairo.initiative.read",
"kairo.action.read",
"kairo.action.manage",
"kairo.blocker.read",
"kairo.blocker.manage",
"kairo.evidence.read",
"kairo.evidence.manage",
"kairo.backlog.read",
"kairo.backlog.manage",
"kairo.decision.read",
"kairo.decision.manage",
"kairo.recurring.read",
"kairo.recurring.manage",
)
ALLOWED_TOKEN_CAPABILITIES = frozenset(DEFAULT_AGENT_CAPABILITIES)
def _hash_token(raw_token: str) -> str:
return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
def _generate_token_parts() -> tuple[str, str, str]:
prefix = secrets.token_hex(6)
secret = secrets.token_urlsafe(24)
raw = f"{TOKEN_PREFIX}{prefix}_{secret}"
return prefix, secret, raw
def _validate_capabilities(capabilities: list[str]) -> list[str]:
if not capabilities:
return list(DEFAULT_AGENT_CAPABILITIES)
invalid = [cap for cap in capabilities if cap not in ALLOWED_TOKEN_CAPABILITIES]
if invalid:
raise ValueError(f"Capabilities nicht erlaubt für Service Token: {', '.join(invalid)}")
return sorted(set(capabilities))
def _serialize_token_row(row: dict[str, Any], *, include_actor: bool = False) -> dict[str, Any]:
item = {
"id": str(row["id"]),
"tenant_id": str(row["tenant_id"]),
"actor_id": str(row["actor_id"]),
"label": row["label"],
"token_prefix": row["token_prefix"],
"capabilities": list(row.get("capabilities") or []),
"is_active": bool(row["is_active"]),
"expires_at": row["expires_at"].isoformat() if row.get("expires_at") else None,
"last_used_at": row["last_used_at"].isoformat() if row.get("last_used_at") else None,
"created_at": row["created_at"].isoformat() if row.get("created_at") else None,
"revoked_at": row["revoked_at"].isoformat() if row.get("revoked_at") else None,
}
if include_actor and row.get("actor_name"):
item["actor_name"] = row["actor_name"]
item["actor_type"] = row.get("actor_type")
return item
def create_service_token(
*,
tenant_id: str,
actor_id: str,
label: str,
created_by_user_id: Optional[str] = None,
capabilities: Optional[list[str]] = None,
expires_in_days: Optional[int] = 365,
) -> dict[str, Any]:
label = label.strip()
if not label:
raise ValueError("Label ist erforderlich")
caps = _validate_capabilities(list(capabilities or []))
prefix, _secret, raw_token = _generate_token_parts()
token_hash = _hash_token(raw_token)
expires_at = None
if expires_in_days and expires_in_days > 0:
expires_at = datetime.now(timezone.utc) + timedelta(days=expires_in_days)
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, actor_type, name, is_active
FROM actors
WHERE id = %s AND tenant_id = %s
""",
(actor_id, tenant_id),
)
actor = cur.fetchone()
if not actor:
raise ValueError("Actor nicht gefunden")
if not actor["is_active"]:
raise ValueError("Actor ist inaktiv")
cur.execute(
"""
INSERT INTO actor_service_tokens (
tenant_id, actor_id, label, token_prefix, token_hash,
capabilities, expires_at, created_by_user_id
)
VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s, %s)
RETURNING id, tenant_id, actor_id, label, token_prefix, capabilities,
is_active, expires_at, last_used_at, created_at, revoked_at
""",
(
tenant_id,
actor_id,
label,
prefix,
token_hash,
json.dumps(caps),
expires_at,
created_by_user_id,
),
)
row = dict(cur.fetchone())
conn.commit()
finally:
conn.close()
log_audit(
"actor_service_token.created",
user_id=created_by_user_id,
tenant_id=tenant_id,
details={"token_id": str(row["id"]), "actor_id": actor_id, "label": label},
)
result = _serialize_token_row(row)
result["token"] = raw_token
return result
def list_service_tokens_for_actor(
*, tenant_id: str, actor_id: str, include_revoked: bool = False
) -> list[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
conditions = ["t.tenant_id = %s", "t.actor_id = %s"]
params: list[Any] = [tenant_id, actor_id]
if not include_revoked:
conditions.append("t.revoked_at IS NULL")
where = " AND ".join(conditions)
cur.execute(
f"""
SELECT t.id, t.tenant_id, t.actor_id, t.label, t.token_prefix,
t.capabilities, t.is_active, t.expires_at, t.last_used_at,
t.created_at, t.revoked_at
FROM actor_service_tokens t
WHERE {where}
ORDER BY t.created_at DESC
""",
params,
)
return [_serialize_token_row(dict(row)) for row in cur.fetchall()]
finally:
conn.close()
def revoke_service_token(
*, tenant_id: str, token_id: str, user_id: Optional[str] = None
) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE actor_service_tokens
SET is_active = FALSE, revoked_at = NOW()
WHERE id = %s AND tenant_id = %s AND revoked_at IS NULL
""",
(token_id, tenant_id),
)
updated = cur.rowcount > 0
conn.commit()
finally:
conn.close()
if updated:
log_audit(
"actor_service_token.revoked",
user_id=user_id,
tenant_id=tenant_id,
details={"token_id": token_id},
)
return updated
def validate_service_token(raw_token: str) -> Optional[dict[str, Any]]:
"""Resolve token → auth context dict or None."""
token = (raw_token or "").strip()
if not token.startswith(TOKEN_PREFIX) or len(token) < 20:
return None
token_hash = _hash_token(token)
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT
t.id,
t.tenant_id,
t.actor_id,
t.capabilities,
t.is_active,
t.expires_at,
t.revoked_at,
a.name AS actor_name,
a.actor_type,
a.is_active AS actor_is_active,
tn.slug AS tenant_slug,
tn.name AS tenant_name
FROM actor_service_tokens t
JOIN actors a ON a.id = t.actor_id AND a.tenant_id = t.tenant_id
JOIN tenants tn ON tn.id = t.tenant_id
WHERE t.token_hash = %s
LIMIT 1
""",
(token_hash,),
)
row = cur.fetchone()
if not row:
return None
row = dict(row)
if not row["is_active"] or row.get("revoked_at"):
return None
if not row["actor_is_active"] or not row.get("tenant_slug"):
return None
if row.get("expires_at"):
expires = row["expires_at"]
if expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc)
if expires <= datetime.now(timezone.utc):
return None
cur.execute(
"""
UPDATE actor_service_tokens
SET last_used_at = NOW()
WHERE id = %s
""",
(str(row["id"]),),
)
conn.commit()
caps = row.get("capabilities") or []
if isinstance(caps, str):
caps = json.loads(caps)
return {
"token_id": str(row["id"]),
"tenant_id": str(row["tenant_id"]),
"tenant_slug": row["tenant_slug"],
"tenant_name": row["tenant_name"],
"actor_id": str(row["actor_id"]),
"actor_name": row["actor_name"],
"actor_type": row["actor_type"],
"capabilities": frozenset(str(c) for c in caps),
}
finally:
conn.close()

View File

@ -1,4 +1,4 @@
"""Actor creation helpers — User and Actor remain separate concepts.""" """Actor creation and directory helpers — User and Actor remain separate concepts."""
from __future__ import annotations from __future__ import annotations
@ -10,6 +10,20 @@ from db import get_connection
ActorType = Literal["human", "agent", "working_group", "external_system"] ActorType = Literal["human", "agent", "working_group", "external_system"]
ACTOR_TYPES = frozenset({"human", "agent", "working_group", "external_system"})
def _serialize_actor(row: dict[str, Any], *, include_user_id: bool = False) -> dict[str, Any]:
result = {
"id": str(row["id"]),
"name": row["name"],
"actor_type": row["actor_type"],
"is_active": bool(row["is_active"]),
}
if include_user_id and row.get("user_id"):
result["user_id"] = str(row["user_id"])
return result
def create_actor( def create_actor(
*, *,
@ -66,3 +80,70 @@ def get_human_actor(tenant_id: str, user_id: str) -> Optional[dict[str, Any]]:
return result return result
finally: finally:
conn.close() conn.close()
def list_actors(
*,
tenant_id: str,
actor_type: Optional[str] = None,
include_inactive: bool = False,
q: Optional[str] = None,
) -> list[dict[str, Any]]:
if actor_type is not None and actor_type not in ACTOR_TYPES:
raise ValueError(f"Ungültiger actor_type: {actor_type}")
conditions = ["tenant_id = %s"]
params: list[Any] = [tenant_id]
if not include_inactive:
conditions.append("is_active = TRUE")
if actor_type:
conditions.append("actor_type = %s")
params.append(actor_type)
if q:
conditions.append("name ILIKE %s")
params.append(f"%{q.strip()}%")
where = " AND ".join(conditions)
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
SELECT id, actor_type, name, user_id, is_active
FROM actors
WHERE {where}
ORDER BY
CASE actor_type
WHEN 'human' THEN 0
WHEN 'working_group' THEN 1
WHEN 'agent' THEN 2
ELSE 3
END,
name
""",
params,
)
return [_serialize_actor(dict(row)) for row in cur.fetchall()]
finally:
conn.close()
def get_actor(*, tenant_id: str, actor_id: str) -> Optional[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, actor_type, name, user_id, is_active
FROM actors
WHERE id = %s AND tenant_id = %s
""",
(actor_id, tenant_id),
)
row = cur.fetchone()
if not row:
return None
return _serialize_actor(dict(row))
finally:
conn.close()

View File

@ -0,0 +1,539 @@
"""Archetyp Starter-Kits — AP2.2a (guided creation without Structure Builder)."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Optional
from entity_archetypes import resolve_default_method_key
# Stufe-A Archetypen mit automatischem Starter-Kit bei Anlage
STARTER_KIT_ARCHETYPES = frozenset(
{
"initiative.maturity_journey",
"initiative.linear_project",
"initiative.product",
"initiative.program",
}
)
_GUIDANCE: dict[str, str] = {
"initiative.maturity_journey": (
"Aktuelle Stufe und heutige Übung steuern deinen nächsten Schritt."
),
"initiative.linear_project": (
"Kritischer Pfad und Abhängigkeiten steuern Next — nicht die Gesamtliste."
),
"initiative.product": (
"Eingang triagieren, dann committete Actions — optional Sprint-Zeitbox (B3)."
),
"initiative.program": (
"Programm mit Abschluss — Gate-Horizont in Kontrolle."
),
}
_PROFILE_HINTS: dict[str, list[str]] = {
"initiative.maturity_journey": ["maturity.karate_kumite"],
"initiative.linear_project": ["project.agile_iteration"],
"initiative.product": ["product.kairo_dev"],
"initiative.program": [],
}
def _find_by_title(items: list[dict[str, Any]], title: str) -> Optional[dict[str, Any]]:
for item in items:
if item.get("title") == title:
return item
return None
def supports_starter_kit(archetype_key: str) -> bool:
return archetype_key in STARTER_KIT_ARCHETYPES
def get_starter_preview(
*,
archetype_key: str,
method_profile_key: Optional[str] = None,
) -> dict[str, Any]:
method_key = resolve_default_method_key(archetype_key)
items: list[dict[str, Any]] = []
if archetype_key == "initiative.maturity_journey":
items = [
{"kind": "maturity_stage", "title": "Stufe 1 — Basis", "status": "active"},
{"kind": "maturity_stage", "title": "Stufe 2 — Aufbau", "status": "planned"},
{"kind": "maturity_stage", "title": "Stufe 3 — Ziel", "status": "planned"},
{"kind": "project", "title": "Training"},
{"kind": "recurring", "title": "Vorbeuge-Dehnung"},
{"kind": "recurring", "title": "Hüftöffner (Schmetterling)"},
]
elif archetype_key == "initiative.linear_project":
items = [
{"kind": "milestone", "title": "G1 — Planung", "status": "active"},
{"kind": "milestone", "title": "G2 — Vorbereitung", "status": "planned"},
{"kind": "milestone", "title": "G3 — Umsetzung", "status": "planned"},
{"kind": "milestone", "title": "G4 — Abschluss", "status": "planned"},
{"kind": "project", "title": "Hauptpfad"},
{"kind": "project", "title": "Begleitung"},
{"kind": "action", "title": "Erster Schritt — Planung konkretisieren"},
]
elif archetype_key == "initiative.product":
items = [
{"kind": "milestone", "title": "Orientierung", "status": "active"},
{"kind": "milestone", "title": "Nächster Release-Horizont", "status": "planned"},
{"kind": "project", "title": "Entwicklung"},
{"kind": "project", "title": "Betrieb"},
{"kind": "backlog_item", "title": "Idee / Issue"},
{"kind": "hint", "title": "Optional: Sprint-Zeitbox unter Plan anlegen (B3)"},
]
elif archetype_key == "initiative.program":
items = [
{"kind": "milestone", "title": "Phase 1 — Start", "status": "active"},
{"kind": "milestone", "title": "Phase 2 — Lieferung", "status": "planned"},
{"kind": "milestone", "title": "Abschluss", "status": "planned"},
]
return {
"archetype_key": archetype_key,
"method_key": method_key,
"method_profile_key": method_profile_key,
"supported": supports_starter_kit(archetype_key),
"guidance": _GUIDANCE.get(archetype_key, ""),
"method_profile_hints": _PROFILE_HINTS.get(archetype_key, []),
"items": items,
}
def apply_starter_kit(
*,
tenant_id: str,
initiative_id: str,
archetype_key: str,
owner_actor_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> dict[str, Any]:
"""Apply idempotent starter structure for supported Stufe-A archetypes."""
if not supports_starter_kit(archetype_key):
return {"applied": False, "reason": "unsupported_archetype"}
from services.roadmap import list_roadmap_items_for_initiative
existing = list_roadmap_items_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id
)
if existing:
return {"applied": False, "reason": "structure_exists"}
created: dict[str, list[str]] = {
"roadmap_items": [],
"projects": [],
"recurring": [],
"backlog_items": [],
"actions": [],
}
if archetype_key == "initiative.maturity_journey":
created = _apply_a1(
tenant_id=tenant_id,
initiative_id=initiative_id,
user_id=user_id,
)
elif archetype_key == "initiative.linear_project":
created = _apply_a2(
tenant_id=tenant_id,
initiative_id=initiative_id,
user_id=user_id,
)
elif archetype_key == "initiative.product":
created = _apply_b2b(
tenant_id=tenant_id,
initiative_id=initiative_id,
user_id=user_id,
)
elif archetype_key == "initiative.program":
created = _apply_b2a(
tenant_id=tenant_id,
initiative_id=initiative_id,
user_id=user_id,
)
from services.audit import log_audit
log_audit(
"initiative.starter_kit_applied",
user_id=user_id,
tenant_id=tenant_id,
details={
"initiative_id": initiative_id,
"archetype_key": archetype_key,
"created": created,
},
)
return {
"applied": True,
"archetype_key": archetype_key,
"guidance": _GUIDANCE.get(archetype_key, ""),
"created": created,
}
def _apply_a1(
*,
tenant_id: str,
initiative_id: str,
user_id: Optional[str],
) -> dict[str, list[str]]:
from services.projects import create_project, list_projects_for_initiative
from services.roadmap import create_roadmap_item
roadmap_ids: list[str] = []
stage_defs = [
("Stufe 1 — Basis", "active"),
("Stufe 2 — Aufbau", "planned"),
("Stufe 3 — Ziel", "planned"),
]
stage_items: list[dict[str, Any]] = []
for index, (title, status) in enumerate(stage_defs):
item = create_roadmap_item(
tenant_id=tenant_id,
initiative_id=initiative_id,
title=title,
item_type="maturity_stage",
status=status, # type: ignore[arg-type]
sort_order=index * 10,
user_id=user_id,
)
roadmap_ids.append(item["id"])
stage_items.append(item)
projects = list_projects_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
if not _find_by_title(projects, "Training"):
create_project(
tenant_id=tenant_id,
initiative_id=initiative_id,
title="Training",
description="Übungs- und Trainingsstruktur",
sort_order=0,
user_id=user_id,
)
from services.maturity_practice import seed_practices_for_stage
exercises = seed_practices_for_stage(
tenant_id=tenant_id,
initiative_id=initiative_id,
stage=stage_items[0],
user_id=user_id,
)
return {
"roadmap_items": roadmap_ids,
"projects": ["Training"],
"recurring": [e["title"] for e in exercises],
}
def _apply_a2(
*,
tenant_id: str,
initiative_id: str,
user_id: Optional[str],
) -> dict[str, list[str]]:
from services.projects import create_project, list_projects_for_initiative
from services.roadmap import add_dependency, create_roadmap_item
gate_titles = [
("G1 — Planung", "active"),
("G2 — Vorbereitung", "planned"),
("G3 — Umsetzung", "planned"),
("G4 — Abschluss", "planned"),
]
gate_ids: list[str] = []
for index, (title, status) in enumerate(gate_titles):
item = create_roadmap_item(
tenant_id=tenant_id,
initiative_id=initiative_id,
title=title,
item_type="milestone",
status=status, # type: ignore[arg-type]
sort_order=index * 10,
user_id=user_id,
)
gate_ids.append(item["id"])
for index in range(1, len(gate_ids)):
try:
add_dependency(
tenant_id=tenant_id,
from_item_id=gate_ids[index],
to_item_id=gate_ids[index - 1],
dependency_type="requires",
)
except ValueError as exc:
if "existiert bereits" not in str(exc):
raise
projects = list_projects_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
for index, title in enumerate(["Hauptpfad", "Begleitung"]):
if not _find_by_title(projects, title):
create_project(
tenant_id=tenant_id,
initiative_id=initiative_id,
title=title,
description=f"Ist-Struktur — {title}",
sort_order=index,
user_id=user_id,
)
from services.actions import create_action, list_actions_for_initiative
projects = list_projects_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
hauptpfad = _find_by_title(projects, "Hauptpfad")
actions = list_actions_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
if not _find_by_title(actions, "Erster Schritt — Planung konkretisieren"):
create_action(
tenant_id=tenant_id,
initiative_id=initiative_id,
title="Erster Schritt — Planung konkretisieren",
description="Starter-Arbeitspaket am ersten Gate",
status="open",
roadmap_item_id=gate_ids[0],
project_id=hauptpfad["id"] if hauptpfad else None,
sort_order=0,
user_id=user_id,
)
return {
"roadmap_items": gate_ids,
"projects": ["Hauptpfad", "Begleitung"],
"actions": ["Erster Schritt — Planung konkretisieren"],
}
def _retire_legacy_linear_stages_for_progression(
*,
tenant_id: str,
initiative_id: str,
keep_gate_ids: frozenset[str],
user_id: Optional[str] = None,
) -> None:
"""Starter-Kit-Stufen zurückstellen, wenn Join-Graph angelegt wird."""
from services.maturity_practice import list_practices_for_stage, update_recurring_element
from services.roadmap import list_roadmap_items_for_initiative, update_roadmap_item
for item in list_roadmap_items_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id
):
if item.get("item_type") != "maturity_stage":
continue
if str(item["id"]) in keep_gate_ids:
continue
if item.get("status") not in ("active", "at_risk"):
continue
update_roadmap_item(
tenant_id=tenant_id,
item_id=item["id"],
status="planned",
user_id=user_id,
)
for practice in list_practices_for_stage(
tenant_id=tenant_id,
initiative_id=initiative_id,
stage_id=item["id"],
):
if practice.get("status") == "active":
update_recurring_element(
tenant_id=tenant_id,
recurring_id=practice["id"],
status="paused",
user_id=user_id,
)
def apply_a1_progression_demo(
*,
tenant_id: str,
initiative_id: str,
user_id: Optional[str] = None,
) -> dict[str, Any]:
"""PO-Kernbild: 2 parallele Gates → Join → Ausgänge (AP-A1-PM-6). Idempotent wenn Struktur existiert."""
from services.maturity_practice import seed_practices_for_stage
from services.roadmap import add_dependency, create_roadmap_item, list_roadmap_items_for_initiative
existing = list_roadmap_items_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id
)
if any(item.get("item_type") == "join_gate" for item in existing):
return {"applied": False, "reason": "progression_exists"}
d1 = create_roadmap_item(
tenant_id=tenant_id,
initiative_id=initiative_id,
title="Dehnung — Einstieg",
item_type="maturity_stage",
status="active",
sort_order=10,
user_id=user_id,
)
h1 = create_roadmap_item(
tenant_id=tenant_id,
initiative_id=initiative_id,
title="Hüftrotation — Einstieg",
item_type="maturity_stage",
status="active",
sort_order=20,
user_id=user_id,
)
join = create_roadmap_item(
tenant_id=tenant_id,
initiative_id=initiative_id,
title="Mawashi Geri zur Hüfte",
item_type="join_gate",
status="planned",
sort_order=30,
user_id=user_id,
)
d2 = create_roadmap_item(
tenant_id=tenant_id,
initiative_id=initiative_id,
title="Dehnung — nach Join",
item_type="maturity_stage",
status="planned",
sort_order=40,
user_id=user_id,
)
h2 = create_roadmap_item(
tenant_id=tenant_id,
initiative_id=initiative_id,
title="Hüftrotation — nach Join",
item_type="maturity_stage",
status="planned",
sort_order=50,
user_id=user_id,
)
for from_id, to_id in (
(join["id"], d1["id"]),
(join["id"], h1["id"]),
(d2["id"], join["id"]),
(h2["id"], join["id"]),
):
try:
add_dependency(
tenant_id=tenant_id,
from_item_id=from_id,
to_item_id=to_id,
dependency_type="requires",
user_id=user_id,
)
except ValueError as exc:
if "existiert bereits" not in str(exc):
raise
seed_practices_for_stage(
tenant_id=tenant_id, initiative_id=initiative_id, stage=d1, user_id=user_id
)
seed_practices_for_stage(
tenant_id=tenant_id, initiative_id=initiative_id, stage=h1, user_id=user_id
)
_retire_legacy_linear_stages_for_progression(
tenant_id=tenant_id,
initiative_id=initiative_id,
keep_gate_ids=frozenset(
str(gate["id"]) for gate in (d1, h1, d2, h2)
),
user_id=user_id,
)
return {
"applied": True,
"join": join["title"],
"active_gates": [d1["title"], h1["title"]],
}
def _apply_b2b(
*,
tenant_id: str,
initiative_id: str,
user_id: Optional[str],
) -> dict[str, list[str]]:
from services.backlog import create_backlog_item, list_backlog_for_initiative
from services.projects import create_project, list_projects_for_initiative
from services.roadmap import create_roadmap_item
roadmap_ids: list[str] = []
for index, (title, status) in enumerate(
[("Orientierung", "active"), ("Nächster Release-Horizont", "planned")]
):
item = create_roadmap_item(
tenant_id=tenant_id,
initiative_id=initiative_id,
title=title,
item_type="milestone",
status=status, # type: ignore[arg-type]
sort_order=index * 10,
user_id=user_id,
)
roadmap_ids.append(item["id"])
projects = list_projects_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
for index, title in enumerate(["Entwicklung", "Betrieb"]):
if not _find_by_title(projects, title):
create_project(
tenant_id=tenant_id,
initiative_id=initiative_id,
title=title,
description=f"Ist-Struktur — {title}",
sort_order=index,
user_id=user_id,
)
backlog = list_backlog_for_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
if not _find_by_title(backlog, "Idee / Issue"):
create_backlog_item(
tenant_id=tenant_id,
initiative_id=initiative_id,
title="Idee / Issue",
description="Product Backlog — noch nicht committet",
user_id=user_id,
)
return {
"roadmap_items": roadmap_ids,
"projects": ["Entwicklung", "Betrieb"],
"backlog_items": ["Idee / Issue"],
}
def _apply_b2a(
*,
tenant_id: str,
initiative_id: str,
user_id: Optional[str],
) -> dict[str, list[str]]:
from services.roadmap import create_roadmap_item
roadmap_ids: list[str] = []
for index, (title, status) in enumerate(
[
("Phase 1 — Start", "active"),
("Phase 2 — Lieferung", "planned"),
("Abschluss", "planned"),
]
):
item = create_roadmap_item(
tenant_id=tenant_id,
initiative_id=initiative_id,
title=title,
item_type="milestone",
status=status, # type: ignore[arg-type]
sort_order=index * 10,
user_id=user_id,
)
roadmap_ids.append(item["id"])
return {"roadmap_items": roadmap_ids}

View File

@ -23,7 +23,7 @@ def log_audit(
INSERT INTO audit_log (action, user_id, tenant_id, details) INSERT INTO audit_log (action, user_id, tenant_id, details)
VALUES (%s, %s, %s, %s::jsonb) VALUES (%s, %s, %s, %s::jsonb)
""", """,
(action, user_id, tenant_id, json.dumps(details or {})), (action, user_id or None, tenant_id, json.dumps(details or {})),
) )
conn.commit() conn.commit()
finally: finally:

587
backend/services/backlog.py Normal file
View File

@ -0,0 +1,587 @@
"""BacklogItem service — tenant-scoped CRUD + convert (AP0.8c)."""
from __future__ import annotations
from typing import Any, Literal, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from services.actions import create_action
from services.audit import log_audit
from services.initiatives import PRIORITIES, get_initiative
from services.operating_context import get_operating_context
from services.plan_ist import validate_roadmap_item_in_initiative
BacklogStatus = Literal["new", "triaged", "accepted", "rejected", "converted"]
BacklogItemKind = Literal["story", "bug", "issue", "epic", "tech_debt"]
BACKLOG_STATUSES = frozenset({"new", "triaged", "accepted", "rejected", "converted"})
BACKLOG_ITEM_KINDS = frozenset({"story", "bug", "issue", "epic", "tech_debt"})
CONVERTIBLE_ITEM_KINDS = frozenset({"story", "bug", "issue", "tech_debt"})
_BACKLOG_COLUMNS = """
id, tenant_id, initiative_id, title, description, status,
priority, roadmap_item_id, converted_action_id, sort_order, item_kind,
parent_action_id, parent_backlog_id, created_at, updated_at
"""
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
result = dict(row)
for key in (
"id",
"tenant_id",
"initiative_id",
"converted_action_id",
"roadmap_item_id",
"parent_action_id",
"parent_backlog_id",
):
if result.get(key):
result[key] = str(result[key])
if result.get("created_at"):
result["created_at"] = result["created_at"].isoformat()
if result.get("updated_at"):
result["updated_at"] = result["updated_at"].isoformat()
return result
def _validate_status(status: str) -> None:
if status not in BACKLOG_STATUSES:
raise ValueError(f"Ungültiger Backlog-Status: {status}")
def _validate_priority(priority: str) -> None:
if priority not in PRIORITIES:
raise ValueError(f"Ungültige Priorität: {priority}")
def _validate_item_kind(item_kind: str) -> None:
if item_kind not in BACKLOG_ITEM_KINDS:
raise ValueError(f"Ungültiger Backlog-Typ: {item_kind}")
def _backlog_vocabulary_for_initiative(*, tenant_id: str, initiative_id: str) -> dict[str, Any]:
context = get_operating_context(tenant_id=tenant_id, initiative_id=initiative_id)
if not context:
raise ValueError("Initiative nicht gefunden")
return context.get("backlog_vocabulary") or {}
def _validate_item_kind_for_initiative(
*, tenant_id: str, initiative_id: str, item_kind: str
) -> dict[str, Any]:
_validate_item_kind(item_kind)
vocabulary = _backlog_vocabulary_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id
)
allowed = vocabulary.get("kinds") or []
if allowed and item_kind not in allowed:
raise ValueError(
f"Backlog-Typ '{item_kind}' ist für dieses Vorhaben nicht erlaubt"
)
return vocabulary
def _item_kind_to_action_kind(item_kind: str) -> str:
if item_kind == "bug":
return "bug"
if item_kind == "issue":
return "issue"
if item_kind == "tech_debt":
return "tech_debt"
return "delivery"
def _validate_parent_action_in_initiative(
*,
cur,
tenant_id: str,
initiative_id: str,
parent_action_id: Optional[str],
) -> None:
if not parent_action_id:
return
cur.execute(
"""
SELECT action_kind, parent_action_id
FROM actions
WHERE id = %s AND tenant_id = %s AND initiative_id = %s
""",
(parent_action_id, tenant_id, initiative_id),
)
row = cur.fetchone()
if not row:
raise ValueError("Referenz-Arbeitspaket (Feature) nicht gefunden")
action_kind = row["action_kind"] if isinstance(row, dict) else row[0]
if action_kind in ("bug", "issue"):
raise ValueError("Referenz muss ein Feature-Arbeitspaket sein, kein Bug/Issue")
def _validate_backlog_hierarchy(
*,
cur,
tenant_id: str,
initiative_id: str,
item_kind: str,
parent_backlog_id: Optional[str],
parent_action_id: Optional[str],
backlog_item_id: Optional[str] = None,
epic_hierarchy: bool = True,
) -> None:
if item_kind == "epic" and not epic_hierarchy:
raise ValueError("Epic ist für dieses Vorhaben nicht verfügbar")
if item_kind == "epic":
if parent_backlog_id:
raise ValueError("Epic kann keinem übergeordneten Backlog-Item zugeordnet werden")
if parent_action_id:
raise ValueError("Epic kann keinem Arbeitspaket zugeordnet werden")
return
if not parent_backlog_id:
return
if not epic_hierarchy:
raise ValueError("Epic-Zuordnung ist für dieses Vorhaben nicht verfügbar")
if backlog_item_id and parent_backlog_id == backlog_item_id:
raise ValueError("Item kann nicht sich selbst als Epic referenzieren")
cur.execute(
"""
SELECT id, item_kind, initiative_id
FROM backlog_items
WHERE id = %s AND tenant_id = %s
""",
(parent_backlog_id, tenant_id),
)
parent = cur.fetchone()
if not parent:
raise ValueError("Epic-Referenz nicht gefunden")
parent_initiative = parent["initiative_id"] if isinstance(parent, dict) else parent[2]
parent_kind = parent["item_kind"] if isinstance(parent, dict) else parent[1]
if str(parent_initiative) != initiative_id:
raise ValueError("Epic gehört zu einem anderen Vorhaben")
if parent_kind != "epic":
raise ValueError("Übergeordnetes Item muss ein Epic sein")
def create_backlog_item(
*,
tenant_id: str,
initiative_id: str,
title: str,
description: str = "",
status: BacklogStatus = "new",
priority: str = "normal",
roadmap_item_id: Optional[str] = None,
sort_order: Optional[int] = None,
item_kind: BacklogItemKind = "story",
parent_action_id: Optional[str] = None,
parent_backlog_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> dict[str, Any]:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
_validate_status(status)
_validate_priority(priority)
vocabulary = _validate_item_kind_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id, item_kind=item_kind
)
epic_hierarchy = bool((vocabulary.get("capabilities") or {}).get("epic_hierarchy"))
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
validate_roadmap_item_in_initiative(
cur,
tenant_id=tenant_id,
initiative_id=initiative_id,
roadmap_item_id=roadmap_item_id,
)
_validate_parent_action_in_initiative(
cur=cur,
tenant_id=tenant_id,
initiative_id=initiative_id,
parent_action_id=parent_action_id,
)
_validate_backlog_hierarchy(
cur=cur,
tenant_id=tenant_id,
initiative_id=initiative_id,
item_kind=item_kind,
parent_backlog_id=parent_backlog_id,
parent_action_id=parent_action_id,
epic_hierarchy=epic_hierarchy,
)
cur.execute(
"""
SELECT COALESCE(MAX(sort_order), -10) + 10 AS next_order
FROM backlog_items
WHERE tenant_id = %s AND initiative_id = %s
""",
(tenant_id, initiative_id),
)
next_order = int(cur.fetchone()["next_order"])
cur.execute(
f"""
INSERT INTO backlog_items (
tenant_id, initiative_id, title, description, status, priority,
roadmap_item_id, sort_order, item_kind, parent_action_id, parent_backlog_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING {_BACKLOG_COLUMNS}
""",
(
tenant_id,
initiative_id,
title,
description,
status,
priority,
roadmap_item_id,
sort_order if sort_order is not None else next_order,
item_kind,
parent_action_id,
parent_backlog_id,
),
)
row = _serialize_row(dict(cur.fetchone()))
conn.commit()
finally:
conn.close()
log_audit(
"backlog.created",
user_id=user_id,
tenant_id=tenant_id,
details={"backlog_item_id": row["id"], "initiative_id": initiative_id, "title": title},
)
return row
def list_backlog_for_initiative(*, tenant_id: str, initiative_id: str) -> list[dict[str, Any]]:
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
SELECT {_BACKLOG_COLUMNS}
FROM backlog_items
WHERE tenant_id = %s AND initiative_id = %s
ORDER BY sort_order ASC, created_at ASC, title
""",
(tenant_id, initiative_id),
)
return [_serialize_row(dict(r)) for r in cur.fetchall()]
finally:
conn.close()
def get_backlog_item(*, tenant_id: str, backlog_item_id: str) -> Optional[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
SELECT {_BACKLOG_COLUMNS}
FROM backlog_items
WHERE id = %s AND tenant_id = %s
""",
(backlog_item_id, tenant_id),
)
row = cur.fetchone()
return _serialize_row(dict(row)) if row else None
finally:
conn.close()
def update_backlog_item(
*,
tenant_id: str,
backlog_item_id: str,
user_id: Optional[str] = None,
title: Optional[str] = None,
description: Optional[str] = None,
status: Optional[BacklogStatus] = None,
priority: Optional[str] = None,
roadmap_item_id: Optional[str] = None,
clear_roadmap_item: bool = False,
sort_order: Optional[int] = None,
item_kind: Optional[BacklogItemKind] = None,
parent_action_id: Optional[str] = None,
clear_parent_action: bool = False,
parent_backlog_id: Optional[str] = None,
clear_parent_backlog: bool = False,
) -> Optional[dict[str, Any]]:
existing = get_backlog_item(tenant_id=tenant_id, backlog_item_id=backlog_item_id)
if not existing:
return None
if existing["status"] == "converted":
raise ValueError("Konvertiertes Backlog-Item kann nicht bearbeitet werden")
old_status = existing["status"]
updates: list[str] = []
params: list[Any] = []
if title is not None:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
updates.append("title = %s")
params.append(title)
if description is not None:
updates.append("description = %s")
params.append(description)
if status is not None:
_validate_status(status)
updates.append("status = %s")
params.append(status)
if priority is not None:
_validate_priority(priority)
updates.append("priority = %s")
params.append(priority)
if clear_roadmap_item:
updates.append("roadmap_item_id = NULL")
elif roadmap_item_id is not None:
conn = get_connection()
try:
with conn.cursor() as cur:
validate_roadmap_item_in_initiative(
cur,
tenant_id=tenant_id,
initiative_id=existing["initiative_id"],
roadmap_item_id=roadmap_item_id,
)
finally:
conn.close()
updates.append("roadmap_item_id = %s")
params.append(roadmap_item_id)
if sort_order is not None:
updates.append("sort_order = %s")
params.append(sort_order)
if item_kind is not None:
_validate_item_kind(item_kind)
updates.append("item_kind = %s")
params.append(item_kind)
effective_kind = item_kind if item_kind is not None else existing.get("item_kind") or "story"
vocabulary = _validate_item_kind_for_initiative(
tenant_id=tenant_id,
initiative_id=existing["initiative_id"],
item_kind=effective_kind,
)
epic_hierarchy = bool((vocabulary.get("capabilities") or {}).get("epic_hierarchy"))
if clear_parent_action:
updates.append("parent_action_id = NULL")
elif parent_action_id is not None:
updates.append("parent_action_id = %s")
params.append(parent_action_id)
if clear_parent_backlog:
updates.append("parent_backlog_id = NULL")
elif parent_backlog_id is not None:
updates.append("parent_backlog_id = %s")
params.append(parent_backlog_id)
if not updates:
return existing
effective_parent_backlog = (
None
if clear_parent_backlog
else parent_backlog_id
if parent_backlog_id is not None
else existing.get("parent_backlog_id")
)
effective_parent_action = (
None
if clear_parent_action
else parent_action_id
if parent_action_id is not None
else existing.get("parent_action_id")
)
updates.append("updated_at = NOW()")
params.extend([backlog_item_id, tenant_id])
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
_validate_backlog_hierarchy(
cur=cur,
tenant_id=tenant_id,
initiative_id=existing["initiative_id"],
item_kind=effective_kind,
parent_backlog_id=effective_parent_backlog,
parent_action_id=effective_parent_action,
backlog_item_id=backlog_item_id,
epic_hierarchy=epic_hierarchy,
)
if parent_action_id is not None and not clear_parent_action:
_validate_parent_action_in_initiative(
cur=cur,
tenant_id=tenant_id,
initiative_id=existing["initiative_id"],
parent_action_id=parent_action_id,
)
cur.execute(
f"""
UPDATE backlog_items
SET {", ".join(updates)}
WHERE id = %s AND tenant_id = %s
RETURNING {_BACKLOG_COLUMNS}
""",
params,
)
row = cur.fetchone()
if not row:
return None
result = _serialize_row(dict(row))
conn.commit()
finally:
conn.close()
log_audit(
"backlog.updated",
user_id=user_id,
tenant_id=tenant_id,
details={"backlog_item_id": backlog_item_id},
)
if status is not None and status != old_status:
log_audit(
"backlog.status_changed",
user_id=user_id,
tenant_id=tenant_id,
details={
"backlog_item_id": backlog_item_id,
"from_status": old_status,
"to_status": status,
},
)
return result
def delete_backlog_item(
*,
tenant_id: str,
backlog_item_id: str,
user_id: Optional[str] = None,
) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT id FROM backlog_items
WHERE parent_backlog_id = %s AND tenant_id = %s AND status != 'converted'
LIMIT 1
""",
(backlog_item_id, tenant_id),
)
if cur.fetchone():
raise ValueError(
"Epic mit untergeordneten Items kann nicht gelöscht werden"
)
cur.execute(
"DELETE FROM backlog_items WHERE id = %s AND tenant_id = %s RETURNING id",
(backlog_item_id, tenant_id),
)
deleted = cur.fetchone() is not None
conn.commit()
finally:
conn.close()
if deleted:
log_audit(
"backlog.deleted",
user_id=user_id,
tenant_id=tenant_id,
details={"backlog_item_id": backlog_item_id},
)
return deleted
def convert_backlog_to_action(
*,
tenant_id: str,
backlog_item_id: str,
user_id: Optional[str] = None,
assigned_actor_ids: Optional[list[str]] = None,
work_cycle_id: Optional[str] = None,
assign_active_sprint: bool = True,
) -> dict[str, Any]:
existing = get_backlog_item(tenant_id=tenant_id, backlog_item_id=backlog_item_id)
if not existing:
raise ValueError("Backlog-Item nicht gefunden")
if existing["status"] == "converted":
raise ValueError("Backlog-Item wurde bereits konvertiert")
if existing["status"] not in ("accepted", "triaged", "new"):
raise ValueError("Backlog-Item kann in diesem Status nicht konvertiert werden")
item_kind = existing.get("item_kind") or "story"
if item_kind not in CONVERTIBLE_ITEM_KINDS:
raise ValueError("Epic kann nicht direkt in ein Arbeitspaket umgewandelt werden")
resolved_cycle_id = work_cycle_id
if not resolved_cycle_id and assign_active_sprint:
from services.work_cycle import get_active_work_cycle
active = get_active_work_cycle(
tenant_id=tenant_id, initiative_id=existing["initiative_id"]
)
if active:
resolved_cycle_id = active["id"]
action = create_action(
tenant_id=tenant_id,
initiative_id=existing["initiative_id"],
title=existing["title"],
description=existing["description"] or "",
priority=existing["priority"],
roadmap_item_id=existing.get("roadmap_item_id"),
work_cycle_id=resolved_cycle_id,
assigned_actor_ids=assigned_actor_ids or [],
action_kind=_item_kind_to_action_kind(item_kind),
parent_action_id=existing.get("parent_action_id"),
user_id=user_id,
)
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
UPDATE backlog_items
SET status = 'converted',
converted_action_id = %s,
updated_at = NOW()
WHERE id = %s AND tenant_id = %s
RETURNING {_BACKLOG_COLUMNS}
""",
(action["id"], backlog_item_id, tenant_id),
)
row = _serialize_row(dict(cur.fetchone()))
conn.commit()
finally:
conn.close()
log_audit(
"backlog.converted_to_action",
user_id=user_id,
tenant_id=tenant_id,
details={
"backlog_item_id": backlog_item_id,
"action_id": action["id"],
"initiative_id": existing["initiative_id"],
},
)
return {"backlog_item": row, "action": action}

View File

@ -0,0 +1,306 @@
"""Blocker service — tenant-scoped CRUD (AP0.8b)."""
from __future__ import annotations
from typing import Any, Literal, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from services.audit import log_audit
from services.initiatives import get_initiative
BlockerStatus = Literal["open", "in_progress", "resolved", "accepted_risk", "dismissed"]
BLOCKER_STATUSES = frozenset(
{"open", "in_progress", "resolved", "accepted_risk", "dismissed"}
)
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
result = dict(row)
for key in ("id", "tenant_id", "initiative_id", "action_id", "reported_by_actor_id"):
if result.get(key):
result[key] = str(result[key])
if result.get("created_at"):
result["created_at"] = result["created_at"].isoformat()
if result.get("updated_at"):
result["updated_at"] = result["updated_at"].isoformat()
return result
def _validate_status(status: str) -> None:
if status not in BLOCKER_STATUSES:
raise ValueError(f"Ungültiger Blocker-Status: {status}")
def _actor_in_tenant(*, tenant_id: str, actor_id: str) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT 1 FROM actors WHERE id = %s AND tenant_id = %s AND is_active = TRUE",
(actor_id, tenant_id),
)
return cur.fetchone() is not None
finally:
conn.close()
def _action_in_initiative(*, tenant_id: str, initiative_id: str, action_id: str) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT 1 FROM actions
WHERE id = %s AND tenant_id = %s AND initiative_id = %s
""",
(action_id, tenant_id, initiative_id),
)
return cur.fetchone() is not None
finally:
conn.close()
def create_blocker(
*,
tenant_id: str,
initiative_id: str,
title: str,
description: str = "",
status: BlockerStatus = "open",
action_id: Optional[str] = None,
reported_by_actor_id: Optional[str] = None,
user_id: Optional[str] = None,
set_action_blocked: bool = False,
) -> dict[str, Any]:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
_validate_status(status)
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden")
if action_id and not _action_in_initiative(
tenant_id=tenant_id, initiative_id=initiative_id, action_id=action_id
):
raise ValueError("Maßnahme gehört nicht zum Vorhaben")
if reported_by_actor_id and not _actor_in_tenant(
tenant_id=tenant_id, actor_id=reported_by_actor_id
):
raise ValueError("Reporter-Actor gehört nicht zum Tenant")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
INSERT INTO blockers (
tenant_id, initiative_id, action_id, title, description,
status, reported_by_actor_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING id, tenant_id, initiative_id, action_id, title, description,
status, reported_by_actor_id, created_at, updated_at
""",
(
tenant_id,
initiative_id,
action_id,
title,
description,
status,
reported_by_actor_id,
),
)
row = _serialize_row(dict(cur.fetchone()))
if set_action_blocked and action_id:
cur.execute(
"""
UPDATE actions SET status = 'blocked', updated_at = NOW()
WHERE id = %s AND tenant_id = %s
""",
(action_id, tenant_id),
)
conn.commit()
finally:
conn.close()
log_audit(
"blocker.created",
user_id=user_id,
tenant_id=tenant_id,
details={"blocker_id": row["id"], "initiative_id": initiative_id, "title": title},
)
return row
def list_blockers_for_initiative(*, tenant_id: str, initiative_id: str) -> list[dict[str, Any]]:
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, tenant_id, initiative_id, action_id, title, description,
status, reported_by_actor_id, created_at, updated_at
FROM blockers
WHERE tenant_id = %s AND initiative_id = %s
ORDER BY updated_at DESC, title
""",
(tenant_id, initiative_id),
)
return [_serialize_row(dict(r)) for r in cur.fetchall()]
finally:
conn.close()
def get_blocker(*, tenant_id: str, blocker_id: str) -> Optional[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, tenant_id, initiative_id, action_id, title, description,
status, reported_by_actor_id, created_at, updated_at
FROM blockers
WHERE id = %s AND tenant_id = %s
""",
(blocker_id, tenant_id),
)
row = cur.fetchone()
return _serialize_row(dict(row)) if row else None
finally:
conn.close()
def update_blocker(
*,
tenant_id: str,
blocker_id: str,
user_id: Optional[str] = None,
title: Optional[str] = None,
description: Optional[str] = None,
status: Optional[BlockerStatus] = None,
action_id: Optional[str] = None,
clear_action_id: bool = False,
) -> Optional[dict[str, Any]]:
existing = get_blocker(tenant_id=tenant_id, blocker_id=blocker_id)
if not existing:
return None
old_status = existing["status"]
updates: list[str] = []
params: list[Any] = []
if title is not None:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
updates.append("title = %s")
params.append(title)
if description is not None:
updates.append("description = %s")
params.append(description)
if status is not None:
_validate_status(status)
updates.append("status = %s")
params.append(status)
if clear_action_id:
updates.append("action_id = NULL")
elif action_id is not None:
if not _action_in_initiative(
tenant_id=tenant_id,
initiative_id=existing["initiative_id"],
action_id=action_id,
):
raise ValueError("Maßnahme gehört nicht zum Vorhaben")
updates.append("action_id = %s")
params.append(action_id)
if not updates:
return existing
updates.append("updated_at = NOW()")
params.extend([blocker_id, tenant_id])
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
UPDATE blockers
SET {", ".join(updates)}
WHERE id = %s AND tenant_id = %s
RETURNING id, tenant_id, initiative_id, action_id, title, description,
status, reported_by_actor_id, created_at, updated_at
""",
params,
)
row = cur.fetchone()
if not row:
return None
result = _serialize_row(dict(row))
conn.commit()
finally:
conn.close()
log_audit(
"blocker.updated",
user_id=user_id,
tenant_id=tenant_id,
details={"blocker_id": blocker_id},
)
if status is not None and status != old_status:
log_audit(
"blocker.status_changed",
user_id=user_id,
tenant_id=tenant_id,
details={
"blocker_id": blocker_id,
"from_status": old_status,
"to_status": status,
},
)
from services.operating_transitions import after_blocker_status_change
after_blocker_status_change(
tenant_id=tenant_id,
blocker_id=blocker_id,
action_id=result.get("action_id"),
initiative_id=result.get("initiative_id"),
old_status=old_status,
new_status=status,
user_id=user_id,
)
return result
def delete_blocker(
*,
tenant_id: str,
blocker_id: str,
user_id: Optional[str] = None,
) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM blockers WHERE id = %s AND tenant_id = %s RETURNING id",
(blocker_id, tenant_id),
)
deleted = cur.fetchone() is not None
conn.commit()
finally:
conn.close()
if deleted:
log_audit(
"blocker.deleted",
user_id=user_id,
tenant_id=tenant_id,
details={"blocker_id": blocker_id},
)
return deleted

View File

@ -0,0 +1,278 @@
"""CadenceInstance service — AP A1.1 minimal daily practice tracking."""
from __future__ import annotations
from datetime import datetime, time, timedelta, timezone
from typing import Any, Literal, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from services.audit import log_audit
CadenceStatus = Literal["open", "completed"]
_CADENCE_COLUMNS = """
id, tenant_id, recurring_element_id, status, due_at,
completed_at, measurement_note, created_at, updated_at
"""
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
result = dict(row)
for key in ("id", "tenant_id", "recurring_element_id"):
if result.get(key):
result[key] = str(result[key])
for ts_key in ("due_at", "completed_at", "created_at", "updated_at"):
if result.get(ts_key):
result[ts_key] = result[ts_key].isoformat()
return result
def get_open_instance(
*, tenant_id: str, recurring_element_id: str
) -> Optional[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
SELECT {_CADENCE_COLUMNS}
FROM cadence_instances
WHERE tenant_id = %s
AND recurring_element_id = %s
AND status = 'open'
LIMIT 1
""",
(tenant_id, recurring_element_id),
)
row = cur.fetchone()
return _serialize_row(dict(row)) if row else None
finally:
conn.close()
def ensure_open_instance(
*,
tenant_id: str,
recurring_element_id: str,
due_at: Optional[datetime] = None,
) -> dict[str, Any]:
"""Ensure exactly one open CadenceInstance (idempotent)."""
existing = get_open_instance(
tenant_id=tenant_id, recurring_element_id=recurring_element_id
)
if existing:
return existing
due = due_at or datetime.now(timezone.utc)
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
INSERT INTO cadence_instances (
tenant_id, recurring_element_id, status, due_at
)
VALUES (%s, %s, 'open', %s)
RETURNING {_CADENCE_COLUMNS}
""",
(tenant_id, recurring_element_id, due),
)
row = _serialize_row(dict(cur.fetchone()))
conn.commit()
finally:
conn.close()
return row
def _next_due_at(
*,
now: datetime,
interval_days: int | None = None,
schedule: dict[str, Any] | None = None,
) -> datetime:
if schedule:
from services.schedule import next_due_after_completion
return next_due_after_completion(schedule=schedule, completed_at=now)
days = max(int(interval_days or 1), 1)
next_date = now.date() + timedelta(days=days)
return datetime.combine(next_date, time.min, tzinfo=timezone.utc)
def _parse_due(value: Any) -> Optional[datetime]:
if value is None:
return None
if isinstance(value, datetime):
return value if value.tzinfo else value.replace(tzinfo=timezone.utc)
try:
return datetime.fromisoformat(str(value).replace("Z", "+00:00"))
except ValueError:
return None
def complete_open_instance(
*,
tenant_id: str,
recurring_element_id: str,
user_id: Optional[str] = None,
measurement_note: str = "",
) -> dict[str, Any]:
"""
Close open instance, advance recurring next_due_at, open next instance.
Returns { completed, recurring, next_instance }.
"""
from services.recurring import get_recurring_element, update_recurring_element
recurring = get_recurring_element(
tenant_id=tenant_id, recurring_id=recurring_element_id
)
if not recurring:
raise ValueError("Recurring-Element nicht gefunden")
if recurring["status"] != "active":
raise ValueError("Recurring-Element ist nicht aktiv")
open_inst = get_open_instance(
tenant_id=tenant_id, recurring_element_id=recurring_element_id
)
if not open_inst:
open_inst = ensure_open_instance(
tenant_id=tenant_id,
recurring_element_id=recurring_element_id,
due_at=datetime.now(timezone.utc),
)
now = datetime.now(timezone.utc)
note = (measurement_note or "").strip()
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
UPDATE cadence_instances
SET status = 'completed',
completed_at = %s,
measurement_note = %s,
updated_at = NOW()
WHERE id = %s AND tenant_id = %s AND status = 'open'
RETURNING {_CADENCE_COLUMNS}
""",
(now, note, open_inst["id"], tenant_id),
)
completed_row = cur.fetchone()
if not completed_row:
raise ValueError("Keine offene Cadence-Instanz")
completed = _serialize_row(dict(completed_row))
conn.commit()
finally:
conn.close()
interval = recurring.get("interval_days") or 1
from services.schedule import resolve_schedule_for_recurring
schedule = resolve_schedule_for_recurring(recurring, tenant_id=tenant_id)
next_due = _next_due_at(now=now, interval_days=interval, schedule=schedule)
update_recurring_element(
tenant_id=tenant_id,
recurring_id=recurring_element_id,
user_id=user_id,
next_due_at=next_due,
)
log_audit(
"cadence_instance.completed",
user_id=user_id,
tenant_id=tenant_id,
details={
"cadence_instance_id": completed["id"],
"recurring_id": recurring_element_id,
"measurement_note": note[:200] if note else "",
},
)
updated_recurring = get_recurring_element(
tenant_id=tenant_id, recurring_id=recurring_element_id
)
return {
"completed_instance": completed,
"next_due_at": next_due.isoformat(),
"recurring": updated_recurring,
}
def attach_open_instances_to_recurring_list(
items: list[dict[str, Any]], *, tenant_id: str
) -> list[dict[str, Any]]:
if not items:
return items
ids = [item["id"] for item in items]
now = datetime.now(timezone.utc)
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
SELECT {_CADENCE_COLUMNS}
FROM cadence_instances
WHERE tenant_id = %s
AND recurring_element_id = ANY(%s::uuid[])
AND status = 'open'
""",
(tenant_id, ids),
)
by_recurring = {
str(row["recurring_element_id"]): _serialize_row(dict(row))
for row in cur.fetchall()
}
cur.execute(
"""
SELECT DISTINCT ON (recurring_element_id)
recurring_element_id, measurement_note, completed_at
FROM cadence_instances
WHERE tenant_id = %s
AND recurring_element_id = ANY(%s::uuid[])
AND status = 'completed'
AND completed_at >= CURRENT_DATE
ORDER BY recurring_element_id, completed_at DESC
""",
(tenant_id, ids),
)
completed_today = {
str(row["recurring_element_id"]): {
"measurement_note": row.get("measurement_note") or "",
"completed_at": row["completed_at"].isoformat()
if row.get("completed_at")
else None,
}
for row in cur.fetchall()
}
finally:
conn.close()
enriched = []
for item in items:
copy = dict(item)
today = completed_today.get(item["id"])
copy["today_completed"] = today is not None
copy["today_measurement_note"] = today.get("measurement_note", "") if today else ""
open_inst = by_recurring.get(item["id"])
next_due = _parse_due(item.get("next_due_at"))
due_now = next_due is None or next_due <= now
if (
not open_inst
and item.get("status") == "active"
and due_now
and not copy["today_completed"]
):
open_inst = ensure_open_instance(
tenant_id=tenant_id,
recurring_element_id=item["id"],
due_at=next_due or now,
)
copy["open_cadence_instance"] = open_inst
enriched.append(copy)
return enriched

View File

@ -0,0 +1,265 @@
"""Decision service — tenant-scoped CRUD (AP0.9c)."""
from __future__ import annotations
from typing import Any, Literal, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from services.audit import log_audit
from services.initiatives import get_initiative
DecisionStatus = Literal["proposed", "decided", "superseded"]
DECISION_STATUSES = frozenset({"proposed", "decided", "superseded"})
_DECISION_COLUMNS = """
id, tenant_id, initiative_id, title, description, status, outcome,
decided_by_actor_id, created_at, updated_at
"""
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
result = dict(row)
for key in ("id", "tenant_id", "initiative_id", "decided_by_actor_id"):
if result.get(key):
result[key] = str(result[key])
for ts_key in ("created_at", "updated_at"):
if result.get(ts_key):
result[ts_key] = result[ts_key].isoformat()
return result
def _validate_status(status: str) -> None:
if status not in DECISION_STATUSES:
raise ValueError(f"Ungültiger Decision-Status: {status}")
def _actor_in_tenant(*, tenant_id: str, actor_id: str) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT 1 FROM actors WHERE id = %s AND tenant_id = %s AND is_active = TRUE",
(actor_id, tenant_id),
)
return cur.fetchone() is not None
finally:
conn.close()
def create_decision(
*,
tenant_id: str,
initiative_id: str,
title: str,
description: str = "",
status: DecisionStatus = "proposed",
outcome: str = "",
decided_by_actor_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> dict[str, Any]:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
_validate_status(status)
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden")
if decided_by_actor_id and not _actor_in_tenant(
tenant_id=tenant_id, actor_id=decided_by_actor_id
):
raise ValueError("Actor gehört nicht zum Tenant")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
INSERT INTO decisions (
tenant_id, initiative_id, title, description, status, outcome,
decided_by_actor_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING {_DECISION_COLUMNS}
""",
(
tenant_id,
initiative_id,
title,
description,
status,
outcome,
decided_by_actor_id,
),
)
row = _serialize_row(dict(cur.fetchone()))
conn.commit()
finally:
conn.close()
log_audit(
"decision.created",
user_id=user_id,
tenant_id=tenant_id,
details={"decision_id": row["id"], "initiative_id": initiative_id},
)
return row
def list_decisions_for_initiative(
*, tenant_id: str, initiative_id: str
) -> list[dict[str, Any]]:
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
SELECT {_DECISION_COLUMNS}
FROM decisions
WHERE tenant_id = %s AND initiative_id = %s
ORDER BY updated_at DESC, title
""",
(tenant_id, initiative_id),
)
return [_serialize_row(dict(r)) for r in cur.fetchall()]
finally:
conn.close()
def get_decision(*, tenant_id: str, decision_id: str) -> Optional[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
SELECT {_DECISION_COLUMNS}
FROM decisions
WHERE id = %s AND tenant_id = %s
""",
(decision_id, tenant_id),
)
row = cur.fetchone()
return _serialize_row(dict(row)) if row else None
finally:
conn.close()
def update_decision(
*,
tenant_id: str,
decision_id: str,
user_id: Optional[str] = None,
title: Optional[str] = None,
description: Optional[str] = None,
status: Optional[DecisionStatus] = None,
outcome: Optional[str] = None,
decided_by_actor_id: Optional[str] = None,
) -> Optional[dict[str, Any]]:
existing = get_decision(tenant_id=tenant_id, decision_id=decision_id)
if not existing:
return None
old_status = existing["status"]
updates: list[str] = []
params: list[Any] = []
if title is not None:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
updates.append("title = %s")
params.append(title)
if description is not None:
updates.append("description = %s")
params.append(description)
if status is not None:
_validate_status(status)
updates.append("status = %s")
params.append(status)
if outcome is not None:
updates.append("outcome = %s")
params.append(outcome)
if decided_by_actor_id is not None:
if decided_by_actor_id and not _actor_in_tenant(
tenant_id=tenant_id, actor_id=decided_by_actor_id
):
raise ValueError("Actor gehört nicht zum Tenant")
updates.append("decided_by_actor_id = %s")
params.append(decided_by_actor_id or None)
if not updates:
return existing
updates.append("updated_at = NOW()")
params.extend([decision_id, tenant_id])
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
UPDATE decisions
SET {", ".join(updates)}
WHERE id = %s AND tenant_id = %s
RETURNING {_DECISION_COLUMNS}
""",
params,
)
row = cur.fetchone()
if not row:
return None
result = _serialize_row(dict(row))
conn.commit()
finally:
conn.close()
log_audit(
"decision.updated",
user_id=user_id,
tenant_id=tenant_id,
details={"decision_id": decision_id},
)
if status is not None and status != old_status:
log_audit(
"decision.status_changed",
user_id=user_id,
tenant_id=tenant_id,
details={
"decision_id": decision_id,
"from_status": old_status,
"to_status": status,
},
)
return result
def delete_decision(
*,
tenant_id: str,
decision_id: str,
user_id: Optional[str] = None,
) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM decisions WHERE id = %s AND tenant_id = %s RETURNING id",
(decision_id, tenant_id),
)
deleted = cur.fetchone() is not None
conn.commit()
finally:
conn.close()
if deleted:
log_audit(
"decision.deleted",
user_id=user_id,
tenant_id=tenant_id,
details={"decision_id": decision_id},
)
return deleted

View File

@ -0,0 +1,396 @@
"""Evidence service — tenant-scoped CRUD (AP0.9b)."""
from __future__ import annotations
from typing import Any, Literal, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from services.audit import log_audit
from services.initiatives import get_initiative
EvidenceStatus = Literal["submitted", "accepted", "rejected"]
EVIDENCE_STATUSES = frozenset({"submitted", "accepted", "rejected"})
_EVIDENCE_COLUMNS = """
id, tenant_id, initiative_id, action_id, milestone_id, roadmap_item_id,
title, description, status, submitted_by_actor_id, created_at, updated_at
"""
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
result = dict(row)
for key in (
"id",
"tenant_id",
"initiative_id",
"action_id",
"milestone_id",
"roadmap_item_id",
"submitted_by_actor_id",
):
if result.get(key):
result[key] = str(result[key])
for ts_key in ("created_at", "updated_at"):
if result.get(ts_key):
result[ts_key] = result[ts_key].isoformat()
return result
def _validate_status(status: str) -> None:
if status not in EVIDENCE_STATUSES:
raise ValueError(f"Ungültiger Evidence-Status: {status}")
def _actor_in_tenant(*, tenant_id: str, actor_id: str) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT 1 FROM actors WHERE id = %s AND tenant_id = %s AND is_active = TRUE",
(actor_id, tenant_id),
)
return cur.fetchone() is not None
finally:
conn.close()
def _action_in_initiative(*, tenant_id: str, initiative_id: str, action_id: str) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT 1 FROM actions
WHERE id = %s AND tenant_id = %s AND initiative_id = %s
""",
(action_id, tenant_id, initiative_id),
)
return cur.fetchone() is not None
finally:
conn.close()
def _roadmap_item_in_initiative(
*, tenant_id: str, initiative_id: str, roadmap_item_id: str
) -> Optional[dict[str, str]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT ri.id, ri.item_type
FROM roadmap_items ri
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
WHERE ri.id = %s AND ri.tenant_id = %s AND r.initiative_id = %s
""",
(roadmap_item_id, tenant_id, initiative_id),
)
row = cur.fetchone()
if not row:
return None
return {"id": str(row["id"]), "item_type": row["item_type"]}
finally:
conn.close()
def _milestone_in_initiative(
*, tenant_id: str, initiative_id: str, milestone_id: str
) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"""
SELECT 1 FROM milestones
WHERE id = %s AND tenant_id = %s AND initiative_id = %s
""",
(milestone_id, tenant_id, initiative_id),
)
if cur.fetchone():
return True
cur.execute(
"""
SELECT 1
FROM roadmap_items ri
JOIN roadmaps r ON r.id = ri.roadmap_id AND r.tenant_id = ri.tenant_id
WHERE ri.id = %s AND ri.tenant_id = %s AND r.initiative_id = %s
""",
(milestone_id, tenant_id, initiative_id),
)
return cur.fetchone() is not None
finally:
conn.close()
def _resolve_gate_links(
*,
tenant_id: str,
initiative_id: str,
milestone_id: Optional[str] = None,
roadmap_item_id: Optional[str] = None,
) -> tuple[Optional[str], Optional[str]]:
"""Gibt (milestone_id, roadmap_item_id) für Evidence-Insert zurück."""
resolved_milestone = milestone_id
resolved_roadmap = roadmap_item_id
if roadmap_item_id:
item = _roadmap_item_in_initiative(
tenant_id=tenant_id,
initiative_id=initiative_id,
roadmap_item_id=roadmap_item_id,
)
if not item:
raise ValueError("Plan-Element gehört nicht zum Vorhaben")
resolved_roadmap = item["id"]
if item["item_type"] == "milestone":
resolved_milestone = item["id"]
elif milestone_id:
if not _milestone_in_initiative(
tenant_id=tenant_id,
initiative_id=initiative_id,
milestone_id=milestone_id,
):
raise ValueError("Meilenstein gehört nicht zum Vorhaben")
resolved_roadmap = milestone_id
return resolved_milestone, resolved_roadmap
def create_evidence(
*,
tenant_id: str,
initiative_id: str,
title: str,
description: str = "",
status: EvidenceStatus = "submitted",
action_id: Optional[str] = None,
milestone_id: Optional[str] = None,
roadmap_item_id: Optional[str] = None,
submitted_by_actor_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> dict[str, Any]:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
_validate_status(status)
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden")
if action_id and not _action_in_initiative(
tenant_id=tenant_id, initiative_id=initiative_id, action_id=action_id
):
raise ValueError("Maßnahme gehört nicht zum Vorhaben")
milestone_id, roadmap_item_id = _resolve_gate_links(
tenant_id=tenant_id,
initiative_id=initiative_id,
milestone_id=milestone_id,
roadmap_item_id=roadmap_item_id,
)
if submitted_by_actor_id and not _actor_in_tenant(
tenant_id=tenant_id, actor_id=submitted_by_actor_id
):
raise ValueError("Actor gehört nicht zum Tenant")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
INSERT INTO evidence (
tenant_id, initiative_id, action_id, milestone_id, roadmap_item_id,
title, description, status, submitted_by_actor_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING {_EVIDENCE_COLUMNS}
""",
(
tenant_id,
initiative_id,
action_id,
milestone_id,
roadmap_item_id,
title,
description,
status,
submitted_by_actor_id,
),
)
row = _serialize_row(dict(cur.fetchone()))
conn.commit()
finally:
conn.close()
log_audit(
"evidence.created",
user_id=user_id,
tenant_id=tenant_id,
details={"evidence_id": row["id"], "initiative_id": initiative_id},
)
return row
def list_evidence_for_initiative(
*, tenant_id: str, initiative_id: str
) -> list[dict[str, Any]]:
if not get_initiative(tenant_id=tenant_id, initiative_id=initiative_id):
raise ValueError("Initiative nicht gefunden")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
SELECT {_EVIDENCE_COLUMNS}
FROM evidence
WHERE tenant_id = %s AND initiative_id = %s
ORDER BY updated_at DESC, title
""",
(tenant_id, initiative_id),
)
return [_serialize_row(dict(r)) for r in cur.fetchall()]
finally:
conn.close()
def get_evidence(*, tenant_id: str, evidence_id: str) -> Optional[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
SELECT {_EVIDENCE_COLUMNS}
FROM evidence
WHERE id = %s AND tenant_id = %s
""",
(evidence_id, tenant_id),
)
row = cur.fetchone()
return _serialize_row(dict(row)) if row else None
finally:
conn.close()
def update_evidence(
*,
tenant_id: str,
evidence_id: str,
user_id: Optional[str] = None,
title: Optional[str] = None,
description: Optional[str] = None,
status: Optional[EvidenceStatus] = None,
action_id: Optional[str] = None,
milestone_id: Optional[str] = None,
roadmap_item_id: Optional[str] = None,
clear_action_id: bool = False,
clear_milestone_id: bool = False,
clear_roadmap_item_id: bool = False,
) -> Optional[dict[str, Any]]:
existing = get_evidence(tenant_id=tenant_id, evidence_id=evidence_id)
if not existing:
return None
updates: list[str] = []
params: list[Any] = []
if title is not None:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
updates.append("title = %s")
params.append(title)
if description is not None:
updates.append("description = %s")
params.append(description)
if status is not None:
_validate_status(status)
updates.append("status = %s")
params.append(status)
if clear_action_id:
updates.append("action_id = NULL")
elif action_id is not None:
if not _action_in_initiative(
tenant_id=tenant_id,
initiative_id=existing["initiative_id"],
action_id=action_id,
):
raise ValueError("Maßnahme gehört nicht zum Vorhaben")
updates.append("action_id = %s")
params.append(action_id)
if clear_milestone_id or clear_roadmap_item_id:
updates.append("milestone_id = NULL")
updates.append("roadmap_item_id = NULL")
elif roadmap_item_id is not None or milestone_id is not None:
link_milestone, link_roadmap = _resolve_gate_links(
tenant_id=tenant_id,
initiative_id=existing["initiative_id"],
milestone_id=milestone_id,
roadmap_item_id=roadmap_item_id,
)
updates.append("milestone_id = %s")
params.append(link_milestone)
updates.append("roadmap_item_id = %s")
params.append(link_roadmap)
if not updates:
return existing
updates.append("updated_at = NOW()")
params.extend([evidence_id, tenant_id])
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
UPDATE evidence
SET {", ".join(updates)}
WHERE id = %s AND tenant_id = %s
RETURNING {_EVIDENCE_COLUMNS}
""",
params,
)
row = cur.fetchone()
if not row:
return None
result = _serialize_row(dict(row))
conn.commit()
finally:
conn.close()
log_audit(
"evidence.updated",
user_id=user_id,
tenant_id=tenant_id,
details={"evidence_id": evidence_id},
)
return result
def delete_evidence(
*,
tenant_id: str,
evidence_id: str,
user_id: Optional[str] = None,
) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM evidence WHERE id = %s AND tenant_id = %s RETURNING id",
(evidence_id, tenant_id),
)
deleted = cur.fetchone() is not None
conn.commit()
finally:
conn.close()
if deleted:
log_audit(
"evidence.deleted",
user_id=user_id,
tenant_id=tenant_id,
details={"evidence_id": evidence_id},
)
return deleted

View File

@ -0,0 +1,225 @@
"""Execution plan — Action dependencies CRUD (AP1.16a/b)."""
from __future__ import annotations
from typing import Any, Literal, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from services.audit import log_audit
from services.actions import get_action
DependencyKind = Literal["requires", "blocks", "relates"]
DEPENDENCY_KINDS = frozenset({"requires", "blocks", "relates"})
def _validate_dependency_kind(kind: str) -> None:
if kind not in DEPENDENCY_KINDS:
raise ValueError(f"Ungültiger dependency_kind: {kind}")
def _serialize_dependency(row: dict[str, Any]) -> dict[str, Any]:
result = dict(row)
for key in (
"id",
"tenant_id",
"initiative_id",
"predecessor_action_id",
"successor_action_id",
):
if result.get(key):
result[key] = str(result[key])
if result.get("created_at"):
result["created_at"] = result["created_at"].isoformat()
return result
def _would_create_cycle(
*,
dependencies: list[dict[str, Any]],
predecessor_id: str,
successor_id: str,
) -> bool:
"""Prüft Zyklus wenn successor transitiv predecessor requires."""
graph: dict[str, list[str]] = {}
for dep in dependencies:
if dep.get("dependency_kind", "requires") != "requires":
continue
pred = str(dep["predecessor_action_id"])
succ = str(dep["successor_action_id"])
graph.setdefault(succ, []).append(pred)
graph.setdefault(successor_id, []).append(predecessor_id)
visiting: set[str] = set()
visited: set[str] = set()
def dfs(node: str) -> bool:
if node in visiting:
return True
if node in visited:
return False
visiting.add(node)
for upstream in graph.get(node, []):
if dfs(upstream):
return True
visiting.remove(node)
visited.add(node)
return False
return dfs(successor_id)
def list_dependencies_for_initiative(
*, tenant_id: str, initiative_id: str
) -> list[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, tenant_id, initiative_id,
predecessor_action_id, successor_action_id,
dependency_kind, created_at
FROM action_dependencies
WHERE tenant_id = %s AND initiative_id = %s
ORDER BY created_at
""",
(tenant_id, initiative_id),
)
return [_serialize_dependency(dict(row)) for row in cur.fetchall()]
finally:
conn.close()
def add_dependency(
*,
tenant_id: str,
predecessor_action_id: str,
successor_action_id: str,
dependency_kind: DependencyKind = "requires",
user_id: Optional[str] = None,
) -> dict[str, Any]:
_validate_dependency_kind(dependency_kind)
if predecessor_action_id == successor_action_id:
raise ValueError("Abhängigkeit auf sich selbst nicht erlaubt")
predecessor = get_action(tenant_id=tenant_id, action_id=predecessor_action_id)
successor = get_action(tenant_id=tenant_id, action_id=successor_action_id)
if not predecessor or not successor:
raise ValueError("Action nicht gefunden")
if predecessor["initiative_id"] != successor["initiative_id"]:
raise ValueError("Abhängigkeiten nur innerhalb eines Vorhabens")
initiative_id = predecessor["initiative_id"]
existing = list_dependencies_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id
)
if _would_create_cycle(
dependencies=existing,
predecessor_id=predecessor_action_id,
successor_id=successor_action_id,
):
raise ValueError("Abhängigkeit würde Zyklus erzeugen")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT 1 FROM action_dependencies
WHERE tenant_id = %s
AND predecessor_action_id = %s
AND successor_action_id = %s
AND dependency_kind = %s
""",
(
tenant_id,
predecessor_action_id,
successor_action_id,
dependency_kind,
),
)
if cur.fetchone():
raise ValueError("Abhängigkeit existiert bereits")
cur.execute(
"""
INSERT INTO action_dependencies (
tenant_id, initiative_id,
predecessor_action_id, successor_action_id,
dependency_kind
)
VALUES (%s, %s, %s, %s, %s)
RETURNING id, tenant_id, initiative_id,
predecessor_action_id, successor_action_id,
dependency_kind, created_at
""",
(
tenant_id,
initiative_id,
predecessor_action_id,
successor_action_id,
dependency_kind,
),
)
row = _serialize_dependency(dict(cur.fetchone()))
conn.commit()
finally:
conn.close()
log_audit(
"action.dependency_added",
user_id=user_id,
tenant_id=tenant_id,
details={
"predecessor_action_id": predecessor_action_id,
"successor_action_id": successor_action_id,
"dependency_kind": dependency_kind,
},
)
return row
def delete_dependency(
*, tenant_id: str, dependency_id: str, user_id: Optional[str] = None
) -> bool:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, predecessor_action_id, successor_action_id, dependency_kind
FROM action_dependencies
WHERE id = %s AND tenant_id = %s
""",
(dependency_id, tenant_id),
)
row = cur.fetchone()
if not row:
raise ValueError("Abhängigkeit nicht gefunden")
cur.execute(
"""
DELETE FROM action_dependencies
WHERE id = %s AND tenant_id = %s
""",
(dependency_id, tenant_id),
)
conn.commit()
finally:
conn.close()
log_audit(
"action.dependency_removed",
user_id=user_id,
tenant_id=tenant_id,
details={
"dependency_id": dependency_id,
"predecessor_action_id": str(row["predecessor_action_id"]),
"successor_action_id": str(row["successor_action_id"]),
},
)
return True

View File

@ -0,0 +1,401 @@
"""Initiative (Vorhaben) service — tenant-scoped CRUD."""
from __future__ import annotations
from typing import Any, Literal, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from entity_archetypes import validate_archetype_key
from services.audit import log_audit
InitiativeStatus = Literal["active", "paused", "completed", "archived"]
Priority = Literal["low", "normal", "high"]
INITIATIVE_STATUSES = frozenset({"active", "paused", "completed", "archived"})
PRIORITIES = frozenset({"low", "normal", "high"})
_INITIATIVE_COLUMNS = """
id, tenant_id, title, goal, vision, target_state_summary, archetype_key,
status, priority, portfolio_rank, owner_actor_id, created_at, updated_at
"""
def _serialize_row(row: dict[str, Any]) -> dict[str, Any]:
result = dict(row)
for key in ("id", "tenant_id", "owner_actor_id"):
if result.get(key):
result[key] = str(result[key])
if result.get("created_at"):
result["created_at"] = result["created_at"].isoformat()
if result.get("updated_at"):
result["updated_at"] = result["updated_at"].isoformat()
return result
def _validate_initiative_status(status: str) -> None:
if status not in INITIATIVE_STATUSES:
raise ValueError(f"Ungültiger Initiative-Status: {status}")
def _validate_priority(priority: str) -> None:
if priority not in PRIORITIES:
raise ValueError(f"Ungültige Priorität: {priority}")
def _actor_in_tenant(*, tenant_id: str, actor_id: str) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"SELECT 1 FROM actors WHERE id = %s AND tenant_id = %s AND is_active = TRUE",
(actor_id, tenant_id),
)
return cur.fetchone() is not None
finally:
conn.close()
def _validate_archetype_key(archetype_key: str) -> None:
validate_archetype_key(entity_type="initiative", archetype_key=archetype_key)
def create_initiative(
*,
tenant_id: str,
title: str,
owner_actor_id: str,
goal: str = "",
vision: str = "",
target_state_summary: str = "",
archetype_key: str = "initiative.generic",
status: InitiativeStatus = "active",
priority: Priority = "normal",
user_id: Optional[str] = None,
method_profile_key: Optional[str] = None,
apply_starter_kit: bool = True,
) -> dict[str, Any]:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
_validate_initiative_status(status)
_validate_priority(priority)
_validate_archetype_key(archetype_key)
if not _actor_in_tenant(tenant_id=tenant_id, actor_id=owner_actor_id):
raise ValueError("Owner-Actor gehört nicht zum Tenant")
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT COALESCE(MAX(portfolio_rank), -1) + 1 AS next_rank
FROM initiatives
WHERE tenant_id = %s
""",
(tenant_id,),
)
next_rank = int(cur.fetchone()["next_rank"])
cur.execute(
f"""
INSERT INTO initiatives (
tenant_id, title, goal, vision, target_state_summary, archetype_key,
status, priority, portfolio_rank, owner_actor_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING {_INITIATIVE_COLUMNS}
""",
(
tenant_id,
title,
goal,
vision,
target_state_summary,
archetype_key,
status,
priority,
next_rank,
owner_actor_id,
),
)
row = _serialize_row(dict(cur.fetchone()))
conn.commit()
finally:
conn.close()
log_audit(
"initiative.created",
user_id=user_id,
tenant_id=tenant_id,
details={"initiative_id": row["id"], "title": title, "status": status},
)
from services.steering_context import create_context_for_new_initiative
create_context_for_new_initiative(
tenant_id=tenant_id,
initiative_id=row["id"],
user_id=user_id,
archetype_key=archetype_key,
method_profile_key=method_profile_key,
)
if apply_starter_kit:
from services.archetype_starter_kit import apply_starter_kit
starter = apply_starter_kit(
tenant_id=tenant_id,
initiative_id=row["id"],
archetype_key=archetype_key,
owner_actor_id=owner_actor_id,
user_id=user_id,
)
row["starter_kit"] = starter
return row
def list_initiatives(*, tenant_id: str) -> list[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
SELECT {_INITIATIVE_COLUMNS}
FROM initiatives
WHERE tenant_id = %s
ORDER BY portfolio_rank ASC NULLS LAST, updated_at DESC, title
""",
(tenant_id,),
)
return [_serialize_row(dict(row)) for row in cur.fetchall()]
finally:
conn.close()
def get_initiative(*, tenant_id: str, initiative_id: str) -> Optional[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
SELECT {_INITIATIVE_COLUMNS}
FROM initiatives
WHERE id = %s AND tenant_id = %s
""",
(initiative_id, tenant_id),
)
row = cur.fetchone()
return _serialize_row(dict(row)) if row else None
finally:
conn.close()
def update_initiative(
*,
tenant_id: str,
initiative_id: str,
user_id: Optional[str] = None,
title: Optional[str] = None,
goal: Optional[str] = None,
vision: Optional[str] = None,
target_state_summary: Optional[str] = None,
archetype_key: Optional[str] = None,
status: Optional[InitiativeStatus] = None,
priority: Optional[Priority] = None,
owner_actor_id: Optional[str] = None,
) -> Optional[dict[str, Any]]:
existing = get_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
if not existing:
return None
updates: list[str] = []
params: list[Any] = []
old_status = existing["status"]
if title is not None:
title = title.strip()
if not title:
raise ValueError("Titel ist erforderlich")
updates.append("title = %s")
params.append(title)
if goal is not None:
updates.append("goal = %s")
params.append(goal)
if vision is not None:
updates.append("vision = %s")
params.append(vision)
if target_state_summary is not None:
updates.append("target_state_summary = %s")
params.append(target_state_summary)
if archetype_key is not None:
_validate_archetype_key(archetype_key)
updates.append("archetype_key = %s")
params.append(archetype_key)
if status is not None:
_validate_initiative_status(status)
updates.append("status = %s")
params.append(status)
if priority is not None:
_validate_priority(priority)
updates.append("priority = %s")
params.append(priority)
if owner_actor_id is not None:
if not _actor_in_tenant(tenant_id=tenant_id, actor_id=owner_actor_id):
raise ValueError("Owner-Actor gehört nicht zum Tenant")
updates.append("owner_actor_id = %s")
params.append(owner_actor_id)
if not updates:
return existing
updates.append("updated_at = NOW()")
params.extend([initiative_id, tenant_id])
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
f"""
UPDATE initiatives
SET {", ".join(updates)}
WHERE id = %s AND tenant_id = %s
RETURNING {_INITIATIVE_COLUMNS}
""",
params,
)
row = cur.fetchone()
if not row:
return None
result = _serialize_row(dict(row))
conn.commit()
finally:
conn.close()
log_audit(
"initiative.updated",
user_id=user_id,
tenant_id=tenant_id,
details={"initiative_id": initiative_id, "fields": updates},
)
if status is not None and status != old_status:
log_audit(
"initiative.status_changed",
user_id=user_id,
tenant_id=tenant_id,
details={
"initiative_id": initiative_id,
"from_status": old_status,
"to_status": status,
},
)
return result
def delete_initiative(
*,
tenant_id: str,
initiative_id: str,
user_id: Optional[str] = None,
) -> bool:
conn = get_connection()
try:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM initiatives WHERE id = %s AND tenant_id = %s RETURNING id",
(initiative_id, tenant_id),
)
deleted = cur.fetchone() is not None
conn.commit()
finally:
conn.close()
if deleted:
log_audit(
"initiative.deleted",
user_id=user_id,
tenant_id=tenant_id,
details={"initiative_id": initiative_id},
)
return deleted
def load_portfolio_rank_map(*, tenant_id: str) -> dict[str, Optional[int]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, portfolio_rank
FROM initiatives
WHERE tenant_id = %s
""",
(tenant_id,),
)
return {
str(row["id"]): row["portfolio_rank"]
for row in cur.fetchall()
}
finally:
conn.close()
def reorder_portfolio(
*,
tenant_id: str,
initiative_ids: list[str],
user_id: Optional[str] = None,
) -> list[dict[str, Any]]:
"""Setzt portfolio_rank für alle übergebenen Initiativen (0 = höchste Prio)."""
if not initiative_ids:
raise ValueError("initiative_ids darf nicht leer sein")
unique_ids = list(dict.fromkeys(initiative_ids))
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id
FROM initiatives
WHERE tenant_id = %s
ORDER BY portfolio_rank ASC NULLS LAST, updated_at DESC, title
""",
(tenant_id,),
)
all_ids = [str(row["id"]) for row in cur.fetchall()]
all_set = set(all_ids)
if set(unique_ids) - all_set:
raise ValueError("Unbekannte Initiative im Tenant")
if len(unique_ids) != len(all_ids):
raise ValueError("Alle Initiativen des Tenants müssen enthalten sein")
for rank, initiative_id in enumerate(unique_ids):
cur.execute(
"""
UPDATE initiatives
SET portfolio_rank = %s, updated_at = NOW()
WHERE id = %s AND tenant_id = %s
""",
(rank, initiative_id, tenant_id),
)
cur.execute(
f"""
SELECT {_INITIATIVE_COLUMNS}
FROM initiatives
WHERE tenant_id = %s
ORDER BY portfolio_rank ASC NULLS LAST, updated_at DESC, title
""",
(tenant_id,),
)
rows = [_serialize_row(dict(row)) for row in cur.fetchall()]
conn.commit()
finally:
conn.close()
log_audit(
"portfolio.reordered",
user_id=user_id,
tenant_id=tenant_id,
details={"initiative_ids": unique_ids},
)
return rows

View File

@ -0,0 +1,248 @@
"""A1 — Stage-bound practice exercises (Activity Set)."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Any, Optional
from services.recurring import (
list_recurring_for_initiative,
update_recurring_element,
)
# Starter exercise sets per stage title (Spagat reference path).
STAGE_PRACTICE_SETS: dict[str, list[tuple[str, str]]] = {
"Stufe 1 — Basis": [
("Vorbeuge-Dehnung", "3×30 Sek. — Hüfte und Beinrückseite"),
("Hüftöffner (Schmetterling)", "Sitzend, leichter Druck"),
],
"Stufe 2 — Aufbau": [
("Seitlicher Spagat — links", "Halten, optional Messwert (Grad)"),
("Seitlicher Spagat — rechts", "Halten, optional Messwert (Grad)"),
("Aktive Vorbeuge", "3× kurz halten"),
],
"Stufe 3 — Ziel": [
("Voller Spagat", "Zielposition — Messwert dokumentieren"),
],
"Dehnung — Einstieg": [
("Vorbeuge halten", "3×30 Sek."),
],
"Hüftrotation — Einstieg": [
("Hüftkreisen im Stand", "10 Wiederholungen je Richtung"),
],
"Dehnung — nach Join": [
("Enddehnung Mawashi-Vorbereitung", "2×45 Sek."),
],
"Hüftrotation — nach Join": [
("Explosive Hüftrotation", "3×8 Wiederholungen"),
],
}
def get_active_maturity_stages(
*, tenant_id: str, initiative_id: str
) -> list[dict[str, Any]]:
from services.roadmap import list_roadmap_items_for_initiative
stages = [
item
for item in list_roadmap_items_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id
)
if item.get("item_type") == "maturity_stage" and item.get("status") == "active"
]
stages.sort(key=lambda s: (s.get("sort_order", 0), s.get("title", "")))
return stages
def get_active_maturity_stage(
*, tenant_id: str, initiative_id: str
) -> Optional[dict[str, Any]]:
stages = get_active_maturity_stages(
tenant_id=tenant_id, initiative_id=initiative_id
)
return stages[0] if stages else None
def list_practices_for_stage(
*,
tenant_id: str,
initiative_id: str,
stage_id: str,
) -> list[dict[str, Any]]:
items = list_recurring_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id
)
return [
item
for item in items
if item.get("roadmap_item_id") == stage_id
]
def pause_all_practices(
*,
tenant_id: str,
initiative_id: str,
user_id: Optional[str] = None,
) -> list[str]:
paused: list[str] = []
for item in list_recurring_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id
):
if item.get("status") == "active":
update_recurring_element(
tenant_id=tenant_id,
recurring_id=item["id"],
status="paused",
user_id=user_id,
)
paused.append(item["title"])
return paused
def pause_practices_on_same_lane_except_stage(
*,
tenant_id: str,
initiative_id: str,
keep_stage_id: str,
user_id: Optional[str] = None,
) -> list[str]:
from services.progression_graph import progression_lanes_for_initiative
lanes = progression_lanes_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id
)
keep_lane = lanes.get(str(keep_stage_id))
if not keep_lane:
return pause_all_practices(
tenant_id=tenant_id, initiative_id=initiative_id, user_id=user_id
)
lane_gate_ids = {
gate_id for gate_id, lane in lanes.items() if lane == keep_lane
}
paused: list[str] = []
for item in list_recurring_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id
):
if item.get("status") != "active":
continue
if item.get("roadmap_item_id") == keep_stage_id:
continue
if str(item.get("roadmap_item_id") or "") not in lane_gate_ids:
continue
update_recurring_element(
tenant_id=tenant_id,
recurring_id=item["id"],
status="paused",
user_id=user_id,
)
paused.append(item["title"])
return paused
def pause_practices_except_stage(
*,
tenant_id: str,
initiative_id: str,
keep_stage_id: str,
strand_project_id: Optional[str] = None,
user_id: Optional[str] = None,
) -> list[str]:
del strand_project_id
return pause_practices_on_same_lane_except_stage(
tenant_id=tenant_id,
initiative_id=initiative_id,
keep_stage_id=keep_stage_id,
user_id=user_id,
)
def seed_practices_for_stage(
*,
tenant_id: str,
initiative_id: str,
stage: dict[str, Any],
user_id: Optional[str] = None,
) -> list[dict[str, Any]]:
"""Idempotent: create missing exercises for a stage; activate existing."""
stage_id = stage["id"]
stage_title = stage.get("title") or ""
defs = STAGE_PRACTICE_SETS.get(stage_title)
if not defs:
defs = [
(
f"Übung — {stage_title}",
f"Routine für {stage_title}",
)
]
existing = list_practices_for_stage(
tenant_id=tenant_id,
initiative_id=initiative_id,
stage_id=stage_id,
)
existing_by_title = {item["title"]: item for item in existing}
created: list[dict[str, Any]] = []
now = datetime.now(timezone.utc)
from services.scheduled_work import create_scheduled_action_at_gate
for title, description in defs:
if title in existing_by_title:
item = existing_by_title[title]
if item.get("status") != "active":
updated = update_recurring_element(
tenant_id=tenant_id,
recurring_id=item["id"],
status="active",
next_due_at=now,
user_id=user_id,
)
if updated:
created.append(updated)
else:
created.append(item)
continue
row = create_scheduled_action_at_gate(
tenant_id=tenant_id,
initiative_id=initiative_id,
gate_id=stage_id,
title=title,
description=description,
user_id=user_id,
schedule_kind="weekdays",
)
created.append(row)
return created
def activate_stage_practice_set(
*,
tenant_id: str,
initiative_id: str,
stage: dict[str, Any],
user_id: Optional[str] = None,
) -> dict[str, Any]:
paused = pause_practices_on_same_lane_except_stage(
tenant_id=tenant_id,
initiative_id=initiative_id,
keep_stage_id=stage["id"],
user_id=user_id,
)
exercises = seed_practices_for_stage(
tenant_id=tenant_id,
initiative_id=initiative_id,
stage=stage,
user_id=user_id,
)
return {
"stage_id": stage["id"],
"stage_title": stage.get("title"),
"paused_practices": paused,
"active_practices": [e["title"] for e in exercises],
"practice_ids": [e["id"] for e in exercises],
}

View File

@ -0,0 +1,44 @@
"""AP2.0e — maturity stage transition on verify reached (delegates to progression_graph)."""
from __future__ import annotations
from typing import Any, Optional
def on_maturity_stage_reached(
*,
tenant_id: str,
initiative_id: str,
reached_item_id: str,
user_id: Optional[str] = None,
) -> Optional[dict[str, Any]]:
from services.progression_graph import on_work_gate_reached
return on_work_gate_reached(
tenant_id=tenant_id,
initiative_id=initiative_id,
reached_item_id=reached_item_id,
user_id=user_id,
)
def on_maturity_stage_reopened(
*,
tenant_id: str,
initiative_id: str,
stage_id: str,
user_id: Optional[str] = None,
) -> dict[str, Any]:
"""Re-enable exercise set for reopened stage; pause others on same strand."""
from services.maturity_practice import activate_stage_practice_set
from services.roadmap import get_roadmap_item
stage = get_roadmap_item(tenant_id=tenant_id, item_id=stage_id)
if not stage:
return {"stage_id": stage_id, "active_practices": []}
return activate_stage_practice_set(
tenant_id=tenant_id,
initiative_id=initiative_id,
stage=stage,
user_id=user_id,
)

View File

@ -0,0 +1,103 @@
"""Milestone service — Kompatibilitäts-Wrapper auf RoadmapItems (AP0.8d → AP1.4)."""
from __future__ import annotations
from datetime import date
from typing import Any, Literal, Optional
from services import roadmap as roadmap_service
MilestoneStatus = Literal[
"planned", "active", "at_risk", "reached", "moved", "discarded"
]
MILESTONE_STATUSES = roadmap_service.ROADMAP_ITEM_STATUSES
def _as_milestone(item: dict[str, Any]) -> dict[str, Any]:
return {
"id": item["id"],
"tenant_id": item["tenant_id"],
"initiative_id": item["initiative_id"],
"title": item["title"],
"goal_description": item.get("goal_description", ""),
"status": item["status"],
"target_date": item.get("target_date"),
"created_at": item["created_at"],
"updated_at": item["updated_at"],
}
def create_milestone(
*,
tenant_id: str,
initiative_id: str,
title: str,
goal_description: str = "",
status: MilestoneStatus = "planned",
target_date: Optional[date] = None,
user_id: Optional[str] = None,
) -> dict[str, Any]:
item = roadmap_service.create_roadmap_item(
tenant_id=tenant_id,
initiative_id=initiative_id,
title=title,
item_type="milestone",
goal_description=goal_description,
status=status,
target_date=target_date,
user_id=user_id,
)
return _as_milestone(item)
def list_milestones_for_initiative(*, tenant_id: str, initiative_id: str) -> list[dict[str, Any]]:
items = roadmap_service.list_roadmap_items_for_initiative(
tenant_id=tenant_id, initiative_id=initiative_id
)
return [_as_milestone(i) for i in items if i["item_type"] == "milestone"]
def get_milestone(*, tenant_id: str, milestone_id: str) -> Optional[dict[str, Any]]:
item = roadmap_service.get_roadmap_item(tenant_id=tenant_id, item_id=milestone_id)
if not item or item["item_type"] != "milestone":
return None
return _as_milestone(item)
def update_milestone(
*,
tenant_id: str,
milestone_id: str,
user_id: Optional[str] = None,
title: Optional[str] = None,
goal_description: Optional[str] = None,
status: Optional[MilestoneStatus] = None,
target_date: Optional[date] = None,
clear_target_date: bool = False,
) -> Optional[dict[str, Any]]:
item = roadmap_service.update_roadmap_item(
tenant_id=tenant_id,
item_id=milestone_id,
user_id=user_id,
title=title,
goal_description=goal_description,
status=status,
target_date=target_date,
clear_target_date=clear_target_date,
)
return _as_milestone(item) if item else None
def delete_milestone(
*,
tenant_id: str,
milestone_id: str,
user_id: Optional[str] = None,
) -> bool:
item = roadmap_service.get_roadmap_item(tenant_id=tenant_id, item_id=milestone_id)
if not item or item["item_type"] != "milestone":
return False
return roadmap_service.delete_roadmap_item(
tenant_id=tenant_id, item_id=milestone_id, user_id=user_id
)

View File

@ -0,0 +1,248 @@
"""Operating Context read model — AP2.3a / AP2.4."""
from __future__ import annotations
from typing import Any, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from entity_archetypes.registry import (
get_ui_profile_json,
resolve_default_method_key,
resolve_ui_profile,
)
from services.initiatives import get_initiative
from services.steering_context import ensure_steering_context, get_steering_context
from steering.graph.profiles import LIGHT, graph_profile_as_dict
from steering.backlog_vocabulary import resolve_backlog_vocabulary
from steering.effective_contract import resolve_effective_steering_elements
from steering.methods.registry import (
get_method,
list_compatible_methods,
list_composable_modifiers,
method_to_dict,
ui_features_as_dict,
)
def _load_ui_profile_from_db(archetype_key: str) -> Optional[dict[str, Any]]:
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT ui_profile_json
FROM entity_archetypes
WHERE archetype_key = %s
""",
(archetype_key,),
)
row = cur.fetchone()
if not row:
return None
profile = row.get("ui_profile_json")
if isinstance(profile, dict) and profile:
return dict(profile)
return None
finally:
conn.close()
def _seed_ui_profile(archetype_key: str) -> dict[str, Any]:
seed_profile = get_ui_profile_json(archetype_key)
if seed_profile:
return seed_profile
return resolve_ui_profile(archetype_key)
def _normalize_ui_profile_capabilities(profile: dict[str, Any]) -> dict[str, Any]:
"""Spiegelt resolve_ui_profile: omCapabilities aus dataSlices falls fehlend."""
result = dict(profile)
slices = list(result.get("dataSlices") or [])
caps = list(result.get("omCapabilities") or slices)
result["dataSlices"] = slices
result["omCapabilities"] = caps
return result
def _resolve_ui_profile(archetype_key: str) -> dict[str, Any]:
seed_profile = _seed_ui_profile(archetype_key)
db_profile = _load_ui_profile_from_db(archetype_key)
if not db_profile:
return _normalize_ui_profile_capabilities(seed_profile)
merged = {**seed_profile, **db_profile}
if not (merged.get("omCapabilities") or merged.get("dataSlices")):
seed_slices = seed_profile.get("dataSlices") or []
merged["dataSlices"] = list(seed_slices)
return _normalize_ui_profile_capabilities(merged)
def _resolve_om_capabilities(ui_profile: dict[str, Any]) -> frozenset[str]:
caps = ui_profile.get("omCapabilities") or ui_profile.get("dataSlices") or []
return frozenset(caps)
def _resolve_data_slices(
*,
om_capabilities: frozenset[str],
method_key: str,
) -> list[str]:
"""Schnittmenge Archetyp-OM ∩ Method-Slices."""
method = get_method(method_key)
if method and method.data_slices:
method_slices = set(method.data_slices)
return [s for s in om_capabilities if s in method_slices]
return list(om_capabilities)
def _method_capabilities(method_key: str) -> dict[str, Any]:
method = get_method(method_key)
if not method:
return {
"lifecycle_steps": [],
"next_action_strategy_key": "default",
}
return {
"lifecycle_steps": list(method.default_lifecycle_steps),
"next_action_strategy_key": method.next_action_strategy_key,
}
def _resolve_method_contract(method_key: str) -> dict[str, Any]:
method = get_method(method_key)
if not method:
return {
"steering_elements": [],
"ui_features": {},
"graph_profile": graph_profile_as_dict(LIGHT),
}
return {
"steering_elements": sorted(method.steering_elements),
"ui_features": ui_features_as_dict(method.ui_features),
"graph_profile": graph_profile_as_dict(method.graph_profile),
}
def _compatible_methods_payload(
archetype_key: str,
*,
om_capabilities: frozenset[str],
active_method_key: str,
) -> list[dict[str, Any]]:
default_key = resolve_default_method_key(archetype_key)
methods = list_compatible_methods(
archetype_key, om_capabilities=om_capabilities
)
return [
{
**method_to_dict(m, include_compatibility=False),
"is_default": m.key == default_key,
"is_active": m.key == active_method_key,
}
for m in methods
]
def _resolve_agent_slot_config(metadata: dict[str, Any]) -> dict[str, Any]:
"""Governance-Konfiguration für Agent-Slots — keine Prompts."""
defaults: dict[str, Any] = {
"enabled_slots": ["review.due", "recurring.due", "review.guardrail"],
"guardrail_pack": "default_v0",
"stale_debt_days": 30,
}
custom = metadata.get("agent_slots") or metadata.get("agent_slot_config") or {}
if isinstance(custom, dict):
return {**defaults, **custom}
return defaults
def get_operating_context(
*, tenant_id: str, initiative_id: str
) -> Optional[dict[str, Any]]:
initiative = get_initiative(tenant_id=tenant_id, initiative_id=initiative_id)
if not initiative:
return None
steering = get_steering_context(tenant_id=tenant_id, initiative_id=initiative_id)
if not steering:
steering = ensure_steering_context(
tenant_id=tenant_id, initiative_id=initiative_id
)
archetype_key = initiative["archetype_key"]
ui_profile = _resolve_ui_profile(archetype_key)
om_capabilities = _resolve_om_capabilities(ui_profile)
method_key = steering["method_key"]
metadata = steering.get("lifecycle_metadata") or {}
if isinstance(metadata, str):
metadata = {}
method_profile_key = metadata.get("method_profile_key")
data_slices = _resolve_data_slices(
om_capabilities=om_capabilities,
method_key=method_key,
)
method_contract = _resolve_method_contract(method_key)
active_composition_modifier = (
"agile_iteration"
if _resolve_active_agile_composition(
tenant_id=tenant_id,
initiative_id=initiative_id,
primary_method_key=method_key,
)
else None
)
effective_steering_elements = resolve_effective_steering_elements(
method_key,
data_slices=data_slices,
active_composition_modifier=active_composition_modifier,
)
backlog_vocabulary = resolve_backlog_vocabulary(
data_slices=data_slices,
method_key=method_key,
steering_elements=effective_steering_elements,
)
agent_slot_config = _resolve_agent_slot_config(metadata)
return {
"initiative_id": initiative_id,
"archetype_key": archetype_key,
"method_key": method_key,
"method_profile_key": method_profile_key,
"ui_profile": ui_profile,
"om_capabilities": sorted(om_capabilities),
"data_slices": data_slices,
"method_capabilities": _method_capabilities(method_key),
"steering_elements": sorted(effective_steering_elements),
"primary_steering_elements": method_contract["steering_elements"],
"ui_features": method_contract["ui_features"],
"graph_profile": method_contract["graph_profile"],
"compatible_methods": _compatible_methods_payload(
archetype_key,
om_capabilities=om_capabilities,
active_method_key=method_key,
),
"composable_modifiers": [
method_to_dict(m, include_compatibility=False)
for m in list_composable_modifiers(method_key)
],
"active_composition_modifier": active_composition_modifier,
"backlog_vocabulary": backlog_vocabulary,
"agent_slot_config": agent_slot_config,
}
def _resolve_active_agile_composition(
*,
tenant_id: str,
initiative_id: str,
primary_method_key: str,
) -> bool:
from services.work_cycle import get_active_work_cycle
from steering.methods.registry import get_method
agile = get_method("agile_iteration")
if not agile or primary_method_key not in agile.composes_with:
return False
return get_active_work_cycle(tenant_id=tenant_id, initiative_id=initiative_id) is not None

View File

@ -0,0 +1,187 @@
"""Minimale Operating-Transitions — AP1.0 mit Hook Dispatch."""
from __future__ import annotations
from typing import Any, Optional
from psycopg2.extras import RealDictCursor
from db import get_connection
from steering.hooks.dispatch import dispatch_hook
RESOLVED_BLOCKER_STATUSES = frozenset({"resolved", "accepted_risk", "dismissed"})
def after_blocker_status_change(
*,
tenant_id: str,
blocker_id: str,
action_id: Optional[str],
initiative_id: Optional[str] = None,
old_status: str,
new_status: str,
user_id: Optional[str] = None,
) -> list[dict[str, Any]]:
"""Blocker geschlossen → verknüpfte blockierte Maßnahme entblocken."""
effects: list[dict[str, Any]] = []
if new_status == old_status:
return effects
if new_status not in RESOLVED_BLOCKER_STATUSES or not action_id:
return effects
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT id, status FROM actions
WHERE id = %s AND tenant_id = %s
""",
(action_id, tenant_id),
)
action = cur.fetchone()
if not action or action["status"] != "blocked":
return effects
cur.execute(
"""
SELECT COUNT(*) AS remaining
FROM blockers
WHERE tenant_id = %s AND action_id = %s
AND id != %s AND status IN ('open', 'in_progress')
""",
(tenant_id, action_id, blocker_id),
)
row = cur.fetchone()
remaining = int(row["remaining"]) if row else 0
if remaining > 0:
effects.append(
{
"kind": "action_still_blocked",
"action_id": str(action_id),
"reason": "other_open_blockers",
}
)
return effects
cur.execute(
"""
UPDATE actions SET status = 'open', updated_at = NOW()
WHERE id = %s AND tenant_id = %s AND status = 'blocked'
RETURNING id
""",
(action_id, tenant_id),
)
if cur.fetchone():
effects.append(
{
"kind": "action_unblocked",
"action_id": str(action_id),
"trigger": "blocker_resolved",
"blocker_id": blocker_id,
}
)
conn.commit()
finally:
conn.close()
if effects:
dispatch_hook(
"on_blocker_resolved",
None,
{
"blocker_id": blocker_id,
"action_id": action_id,
"initiative_id": initiative_id,
"effects": effects,
},
tenant_id=tenant_id,
user_id=user_id,
)
if initiative_id and user_id:
from services.steering_events import emit_steering_event
emit_steering_event(
tenant_id=tenant_id,
initiative_id=initiative_id,
event_kind="situational",
payload={"reason": "blocker_resolved", "blocker_id": blocker_id},
user_id=user_id,
)
if effects and user_id:
from services.audit import log_audit
log_audit(
"operating_transition.action_unblocked",
user_id=user_id,
tenant_id=tenant_id,
details={"effects": effects, "blocker_id": blocker_id},
)
return effects
def after_review_status_change(
*,
tenant_id: str,
review_id: str,
action_id: Optional[str],
initiative_id: Optional[str] = None,
old_status: str,
new_status: str,
user_id: Optional[str] = None,
) -> list[dict[str, Any]]:
"""Review abgeschlossen → Maßnahme aus review_required weiterführen."""
effects: list[dict[str, Any]] = []
if new_status == old_status or new_status != "completed" or not action_id:
return effects
conn = get_connection()
try:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
UPDATE actions
SET status = 'done', updated_at = NOW()
WHERE id = %s AND tenant_id = %s AND status = 'review_required'
RETURNING id
""",
(action_id, tenant_id),
)
if cur.fetchone():
effects.append(
{
"kind": "action_completed_after_review",
"action_id": str(action_id),
"trigger": "review_completed",
"review_id": review_id,
}
)
conn.commit()
finally:
conn.close()
if effects:
dispatch_hook(
"on_review_completed",
None,
{
"review_id": review_id,
"action_id": action_id,
"initiative_id": initiative_id,
"effects": effects,
},
tenant_id=tenant_id,
user_id=user_id,
)
if effects and user_id:
from services.audit import log_audit
log_audit(
"operating_transition.action_completed_after_review",
user_id=user_id,
tenant_id=tenant_id,
details={"effects": effects, "review_id": review_id},
)
return effects

Some files were not shown because too many files have changed in this diff Show More