From b9bfa9e5bd66b83e3ab9397ba806ddef34a37a02 Mon Sep 17 00:00:00 2001 From: Lars Date: Sun, 12 Jul 2026 11:48:34 +0200 Subject: [PATCH] AP1.16d + AP1.9c: Planning Debt in Attention und Cockpit-Signale. Attention-Regeln planning_debt/execution_waiting; Portfolio-Kacheln zeigen Steuerungssignale. Co-authored-by: Cursor --- backend/steering/graph/execution_engine.py | 71 +++++++++++++++++++ backend/steering/signals/default_rules.py | 58 +++++++++++++++ backend/tests/test_ap16_execution_graph.py | 44 ++++++++++++ .../Kairo_Corrected_MVP_Roadmap_v0.2.md | 4 +- .../Kairo_Implementation_Truth_Table_v0.1.md | 4 +- .../Sprint1_AP1_16_Execution_Plan_v0.1.md | 15 ++-- .../InitiativePortfolioSignalChips.jsx | 27 +++++++ frontend/src/constants/operating.js | 2 + frontend/src/styles/components.css | 37 ++++++++++ frontend/src/utils/attentionSignals.js | 55 ++++++++++++++ frontend/src/utils/attentionSignals.test.js | 31 ++++++++ frontend/src/widgets/AttentionWidget.jsx | 3 + .../src/widgets/InitiativePortfolioWidget.jsx | 13 +++- 13 files changed, 352 insertions(+), 12 deletions(-) create mode 100644 frontend/src/components/InitiativePortfolioSignalChips.jsx create mode 100644 frontend/src/utils/attentionSignals.js create mode 100644 frontend/src/utils/attentionSignals.test.js diff --git a/backend/steering/graph/execution_engine.py b/backend/steering/graph/execution_engine.py index de95f95..5b436d5 100644 --- a/backend/steering/graph/execution_engine.py +++ b/backend/steering/graph/execution_engine.py @@ -111,6 +111,77 @@ def compute_planning_debt( return debts +def planning_debt_to_attention_items( + *, + initiative_id: str, + debts: list[dict[str, Any]], +) -> list[dict[str, Any]]: + """Map Planning Debt read model → Attention Items (AP1.16d).""" + items: list[dict[str, Any]] = [] + for debt in debts: + gate_id = str(debt["roadmap_item_id"]) + items.append( + { + "kind": "planning_debt", + "severity": "warning", + "title": debt.get("title") or "Zielzustand", + "summary": debt.get("message") or "Durchführungsplan fehlt", + "scope_type": "milestone", + "scope_id": gate_id, + "initiative_id": initiative_id, + "action_id": None, + "blocker_id": None, + "milestone_id": gate_id, + "reason_code": "planning_debt", + "data_source": "execution_graph", + } + ) + return items + + +def execution_waiting_to_attention_items( + *, + initiative_id: str, + actions: list[dict[str, Any]], + graph_state: dict[str, Any], +) -> list[dict[str, Any]]: + """Actions blocked by Execution-Graph (nicht Status=blocked) → Attention.""" + action_by_id = {str(action["id"]): action for action in actions} + items: list[dict[str, Any]] = [] + for action_id in graph_state.get("blocked_actions", []): + action = action_by_id.get(str(action_id)) + if not action: + continue + if action.get("status") in ("done", "discarded", "blocked"): + continue + meta = graph_state.get("items", {}).get(str(action_id), {}) + blocked_by = meta.get("blocked_by") or [] + summary = ( + "Wartet auf Vorgänger-Arbeitspaket" + if len(blocked_by) == 1 + else f"Wartet auf {len(blocked_by)} Vorgänger" + ) + items.append( + { + "kind": "execution_waiting", + "severity": "info", + "title": action.get("title") or "Arbeitspaket", + "summary": summary, + "scope_type": "action", + "scope_id": str(action_id), + "initiative_id": initiative_id, + "action_id": str(action_id), + "blocker_id": None, + "milestone_id": ( + str(action["roadmap_item_id"]) if action.get("roadmap_item_id") else None + ), + "reason_code": "execution_waiting", + "data_source": "execution_graph", + } + ) + return items + + def compute_execution_graph_state( *, actions: list[dict[str, Any]], diff --git a/backend/steering/signals/default_rules.py b/backend/steering/signals/default_rules.py index fb65041..43888a4 100644 --- a/backend/steering/signals/default_rules.py +++ b/backend/steering/signals/default_rules.py @@ -18,6 +18,8 @@ AttentionKind = Literal[ "stale_initiative", "milestone_at_risk", "gate_graph_blocked", + "planning_debt", + "execution_waiting", "overdue_action", "review_due", "recurring_due", @@ -316,6 +318,61 @@ def _graph_blocked_gates(cur, ctx: TenantContext) -> list[dict[str, Any]]: return items +def _execution_plan_attention(cur, ctx: TenantContext) -> list[dict[str, Any]]: + """Planning Debt + Execution-Waiting — AP1.16d / Execution-Graph.""" + from services import actions as action_service + from services import roadmap as roadmap_service + from steering.graph.execution_engine import ( + compute_execution_graph_state, + compute_planning_debt, + execution_waiting_to_attention_items, + planning_debt_to_attention_items, + ) + from services.execution_plan import list_dependencies_for_initiative + + cur.execute( + """ + SELECT id FROM initiatives + WHERE tenant_id = %s AND status IN ('active', 'paused') + ORDER BY updated_at DESC + LIMIT 30 + """, + (ctx.tenant_id,), + ) + + items: list[dict[str, Any]] = [] + for row in cur.fetchall(): + initiative_id = str(row["id"]) + 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 + ) + debts = compute_planning_debt(actions=actions, roadmap_items=roadmap_items) + items.extend( + planning_debt_to_attention_items(initiative_id=initiative_id, debts=debts) + ) + + dependencies = list_dependencies_for_initiative( + tenant_id=ctx.tenant_id, initiative_id=initiative_id + ) + graph_state = compute_execution_graph_state( + actions=actions, dependencies=dependencies + ) + items.extend( + execution_waiting_to_attention_items( + initiative_id=initiative_id, + actions=actions, + graph_state=graph_state, + ) + ) + + if len(items) >= 30: + return items[:30] + return items + + def _milestones_at_risk(cur, ctx: TenantContext) -> list[dict[str, Any]]: cur.execute( """ @@ -491,6 +548,7 @@ def get_attention_items(ctx: TenantContext) -> list[dict[str, Any]]: items.extend(_initiatives_without_next_action(cur, ctx)) items.extend(_stale_initiatives(cur, ctx)) items.extend(_graph_blocked_gates(cur, ctx)) + items.extend(_execution_plan_attention(cur, ctx)) items.extend(_milestones_at_risk(cur, ctx)) items.extend(_overdue_actions(cur, ctx)) items.extend(_actions_review_required(cur, ctx)) diff --git a/backend/tests/test_ap16_execution_graph.py b/backend/tests/test_ap16_execution_graph.py index e16669e..04269a8 100644 --- a/backend/tests/test_ap16_execution_graph.py +++ b/backend/tests/test_ap16_execution_graph.py @@ -3,6 +3,8 @@ from steering.graph.execution_engine import ( compute_execution_graph_state, compute_planning_debt, + execution_waiting_to_attention_items, + planning_debt_to_attention_items, ) @@ -114,3 +116,45 @@ def test_scope_filters_to_gate(): scope_roadmap_item_id="g1", ) assert set(state["items"].keys()) == {"a"} + + +def test_planning_debt_attention_items(): + items = planning_debt_to_attention_items( + initiative_id="init-1", + debts=[ + { + "roadmap_item_id": "gate-1", + "title": "G5 Plan", + "message": "Aktiver Zielzustand ohne Durchführungsplan", + } + ], + ) + assert len(items) == 1 + assert items[0]["kind"] == "planning_debt" + assert items[0]["initiative_id"] == "init-1" + assert items[0]["milestone_id"] == "gate-1" + + +def test_execution_waiting_attention_items(): + actions = [ + _action("a", "open", sort_order=0), + _action("b", "open", sort_order=1), + ] + state = compute_execution_graph_state( + actions=actions, + dependencies=[ + { + "predecessor_action_id": "a", + "successor_action_id": "b", + "dependency_kind": "requires", + } + ], + ) + items = execution_waiting_to_attention_items( + initiative_id="init-1", + actions=actions, + graph_state=state, + ) + assert len(items) == 1 + assert items[0]["kind"] == "execution_waiting" + assert items[0]["action_id"] == "b" diff --git a/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md b/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md index e02eb97..cfda666 100644 --- a/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md +++ b/docs/product/Kairo_Corrected_MVP_Roadmap_v0.2.md @@ -128,7 +128,7 @@ Phase H2 PM Work Modes AP1.9 ✓ 9a / → 9b–9e Phase H3 Plan Outline AP1.12 + AP1.10 ◐ 12a–d, 10c Phase I Gate-Graph AP1.13 + AP1.15 ◐ 13a/b, 15a–c Phase I2 Plan/Ist Snapshots AP1.14 ✓ -Phase I3 Execution-Plan AP1.16 ◐ 16a–b ✓ / 16c UI / 16d offen +Phase I3 Execution-Plan AP1.16 ◐ 16a–c ✓ / 16d ◐ / AP1.9c ◐ Phase J Portfolio AP1.8 ◐ 8a ✓ / 8b deferred Phase K Archetyp-Steuerung AP2.0 ◐ 2.0a–c ✓ / → 2.0d–f Phase L Agent Interface AP1.7 ○ @@ -180,7 +180,7 @@ Siehe **`Kairo_Status_Review_and_Next_Steps_v0.1.md` §5** für vollständige Ro |-------|-------|--------|------------| | D0 | DOC-Sync (Truth Table, Gap, Review) | ✓ | eine Wahrheit | | D1 | Dogfooding R1 — Kairo-Jinkendo in Kairo | **→ nächstes** | B2b-Validation | -| 1 | AP1.9c Cockpit-Signale | offen | MVP §5.7 | +| 1 | AP1.9c Cockpit-Signale | **◐ Code** | Portfolio-Kacheln via Attention | | 2 | AP1.16a–b Execution-Graph (Schema + Engine) | **◐ Code** | **vor** AP2.0d; Remote-Verifikation nach Deploy | | 3 | AP2.0d Next-Action-Strategien | offen | MVP Stufe A; nutzt `ready_actions` | | 4 | AP1.16c–d Plan-Outline-Kanten + Planning Debt | offen | nach 16a–b | diff --git a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md index 3eb417b..b2591f4 100644 --- a/docs/product/Kairo_Implementation_Truth_Table_v0.1.md +++ b/docs/product/Kairo_Implementation_Truth_Table_v0.1.md @@ -65,7 +65,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden. | Initiative Archetypes | ◐ | Migration 016, Registry AP2.0b | | Method Profiles (Code-Seeds) | ◐ | `product.kairo_dev`, Kumite, Buch — AP2.0b | | Execution Graph Engine | ◐ | AP1.16b; API `/execution/graph-state` | -| Planning Debt (Attention) | ◐ | Read Model AP1.16b; Cockpit AP1.16d offen | +| Planning Debt (Attention) | ◐ | AP1.16d: Attention + Cockpit-Kacheln AP1.9c | | `work_cycle` / Sprint | ✗ | 📄 MVP v0.3 B3; Migration AP2.0f geplant | --- @@ -99,7 +99,7 @@ Verhindert, dass Zielbild-Dokumente als Ist-Stand gelesen werden. | Sicht | Stand | Anmerkung | |-------|-------|-----------| | PM Work Modes Shell | ✓ | AP1.9a: Cockpit, Work, Plan, Control + Redirects | -| Cockpit (Portfolio) | ◐ | Widget-Grid; Rang AP1.8a; Signale auf Kacheln ✗ AP1.9c | +| Cockpit (Portfolio) | ◐ | Widget-Grid; Rang AP1.8a; Signale AP1.9c ◐ | | Ausführen (/work) | ◐ | Heute, Meine APs | | Planen (/plan) | ◐ | Outline AP1.12, Gates, Inbox, Struktur, Profil | | Kontrolle (/control) | ◐ | Status, Plan/Ist AP1.14, Journey AP1.6b | diff --git a/docs/sprints/Sprint1_AP1_16_Execution_Plan_v0.1.md b/docs/sprints/Sprint1_AP1_16_Execution_Plan_v0.1.md index 3e1ede8..7150e06 100644 --- a/docs/sprints/Sprint1_AP1_16_Execution_Plan_v0.1.md +++ b/docs/sprints/Sprint1_AP1_16_Execution_Plan_v0.1.md @@ -69,21 +69,22 @@ Kanten-Richtung in DB: `predecessor_action_id` → `successor_action_id` (Vorgä | 9 | `GET/POST/DELETE …/execution/dependencies` | | 10 | pytest: Engine + Cycle-Detection (Unit) | -### AP1.16c — Frontend (folgt) +### AP1.16c — Frontend ✓ | # | Inhalt | |---|--------| | 11 | Plan-Outline „Arbeit“: blocked/ready Badges | -| 12 | Vorgänger-Kanten (Liste oder Mini-Graph) | -| 13 | Dependency anlegen/löschen im Action-Kontext | +| 12 | Action-Detail: Vorgänger-Verwaltung | +| 13 | Planning Debt Banner auf Plan → Arbeit | -### AP1.16d — Steuerung (folgt) +### AP1.16d + AP1.9c — Attention & Cockpit ◐ | # | Inhalt | |---|--------| -| 14 | Method Profile: `planning_levels`, `planning_mode` | -| 15 | Planning Debt in Attention / Cockpit | -| 16 | AP2.0d: Next-Action nutzt `ready_actions` | +| 14 | Attention: `planning_debt`, `execution_waiting` | +| 15 | Cockpit Portfolio-Kacheln: Attention-Chips (AP1.9c) | +| 16 | Method Profile `planning_levels` | deferred | +| 17 | AP2.0d: Next-Action nutzt `ready_actions` | offen | --- diff --git a/frontend/src/components/InitiativePortfolioSignalChips.jsx b/frontend/src/components/InitiativePortfolioSignalChips.jsx new file mode 100644 index 0000000..98a5308 --- /dev/null +++ b/frontend/src/components/InitiativePortfolioSignalChips.jsx @@ -0,0 +1,27 @@ +import { attentionKindLabel, topAttentionSignals } from '../utils/attentionSignals.js' + +export function InitiativePortfolioSignalChips({ signals = [] }) { + const top = topAttentionSignals(signals, 2) + if (top.length === 0) { + return null + } + + return ( + + ) +} diff --git a/frontend/src/constants/operating.js b/frontend/src/constants/operating.js index a1c4b39..67bed8d 100644 --- a/frontend/src/constants/operating.js +++ b/frontend/src/constants/operating.js @@ -77,6 +77,8 @@ export const ATTENTION_KIND_LABELS = { stale_initiative: 'Inaktives Vorhaben', milestone_at_risk: 'Meilenstein gefährdet', gate_graph_blocked: 'Gate blockiert (Graph)', + planning_debt: 'Durchführungsplan fehlt', + execution_waiting: 'AP wartet (Reihenfolge)', overdue_action: 'Überfällig', review_due: 'Review fällig', recurring_due: 'Wiederkehrend fällig', diff --git a/frontend/src/styles/components.css b/frontend/src/styles/components.css index aac3df3..cda6bc8 100644 --- a/frontend/src/styles/components.css +++ b/frontend/src/styles/components.css @@ -1242,6 +1242,43 @@ gap: 0.35rem; } +.initiative-portfolio-card-signals { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 10px 0 0; + padding: 0; + list-style: none; +} + +.initiative-portfolio-card-signal { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 999px; + letter-spacing: 0.02em; +} + +.initiative-portfolio-card-signal--critical { + background: color-mix(in srgb, #dc2626 15%, transparent); + color: #dc2626; +} + +.initiative-portfolio-card-signal--warning { + background: color-mix(in srgb, #d97706 15%, transparent); + color: #d97706; +} + +.initiative-portfolio-card-signal--info { + background: color-mix(in srgb, #2563eb 12%, transparent); + color: #2563eb; +} + +.initiative-portfolio-card-signal--more { + background: transparent; + font-weight: 500; +} + .initiative-journey-lead { margin-bottom: 1rem; } diff --git a/frontend/src/utils/attentionSignals.js b/frontend/src/utils/attentionSignals.js new file mode 100644 index 0000000..41b67f6 --- /dev/null +++ b/frontend/src/utils/attentionSignals.js @@ -0,0 +1,55 @@ +import { ATTENTION_KIND_LABELS } from '../constants/operating.js' + +const SEVERITY_ORDER = { critical: 0, warning: 1, info: 2 } + +/** + * @param {Array<{ initiative_id?: string, severity?: string, kind?: string }>} items + */ +export function groupAttentionByInitiative(items) { + /** @type {Record} */ + const grouped = {} + for (const item of items || []) { + const initiativeId = item.initiative_id + if (!initiativeId) continue + if (!grouped[initiativeId]) grouped[initiativeId] = [] + grouped[initiativeId].push(item) + } + for (const list of Object.values(grouped)) { + list.sort( + (a, b) => + (SEVERITY_ORDER[a.severity] ?? 99) - (SEVERITY_ORDER[b.severity] ?? 99), + ) + } + return grouped +} + +export function attentionKindLabel(kind) { + return ATTENTION_KIND_LABELS[kind] || kind +} + +/** + * @param {Array} items + * @param {number} limit + */ +export function topAttentionSignals(items, limit = 2) { + if (!items?.length) return [] + return [...items] + .sort( + (a, b) => + (SEVERITY_ORDER[a.severity] ?? 99) - (SEVERITY_ORDER[b.severity] ?? 99), + ) + .slice(0, limit) +} + +/** + * @param {Array} items + */ +export function initiativeSignalSummary(items) { + const top = topAttentionSignals(items, 1)[0] + if (!top) return null + return { + count: items.length, + topSeverity: top.severity, + topLabel: attentionKindLabel(top.kind), + } +} diff --git a/frontend/src/utils/attentionSignals.test.js b/frontend/src/utils/attentionSignals.test.js new file mode 100644 index 0000000..85b155b --- /dev/null +++ b/frontend/src/utils/attentionSignals.test.js @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { + groupAttentionByInitiative, + initiativeSignalSummary, + topAttentionSignals, +} from './attentionSignals.js' + +describe('attentionSignals', () => { + const sample = [ + { initiative_id: 'a', kind: 'planning_debt', severity: 'warning' }, + { initiative_id: 'a', kind: 'execution_waiting', severity: 'info' }, + { initiative_id: 'b', kind: 'open_blocker', severity: 'critical' }, + ] + + it('groups by initiative', () => { + const grouped = groupAttentionByInitiative(sample) + expect(grouped.a).toHaveLength(2) + expect(grouped.b).toHaveLength(1) + }) + + it('picks top signals by severity', () => { + const top = topAttentionSignals(sample.filter((i) => i.initiative_id === 'a')) + expect(top[0].kind).toBe('planning_debt') + }) + + it('summarizes initiative signals', () => { + const summary = initiativeSignalSummary(sample.filter((i) => i.initiative_id === 'b')) + expect(summary?.count).toBe(1) + expect(summary?.topSeverity).toBe('critical') + }) +}) diff --git a/frontend/src/widgets/AttentionWidget.jsx b/frontend/src/widgets/AttentionWidget.jsx index 44c98a6..467b1e0 100644 --- a/frontend/src/widgets/AttentionWidget.jsx +++ b/frontend/src/widgets/AttentionWidget.jsx @@ -16,6 +16,9 @@ const SEVERITY_LABELS = { } function attentionLink(item) { + if (item.kind === 'planning_debt' && item.initiative_id) { + return scopedPath('/plan/work', { initiativeId: item.initiative_id }) + } if (item.milestone_id) { return gatePath(item.milestone_id) } diff --git a/frontend/src/widgets/InitiativePortfolioWidget.jsx b/frontend/src/widgets/InitiativePortfolioWidget.jsx index bbc9bad..5d9c2b9 100644 --- a/frontend/src/widgets/InitiativePortfolioWidget.jsx +++ b/frontend/src/widgets/InitiativePortfolioWidget.jsx @@ -1,6 +1,9 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import { Link } from 'react-router-dom' +import { getAttentionItems } from '../api/attention.js' import { listInitiatives } from '../api/initiatives.js' +import { groupAttentionByInitiative } from '../utils/attentionSignals.js' +import { InitiativePortfolioSignalChips } from '../components/InitiativePortfolioSignalChips.jsx' import { reorderPortfolio } from '../api/workspace.js' import { StatusBadge } from '../components/StatusBadge.jsx' import { PriorityBadge } from '../components/PriorityBadge.jsx' @@ -20,6 +23,7 @@ export function InitiativePortfolioWidget() { const { hasCapability } = useCapabilities() const canManage = hasCapability('kairo.initiative.manage') const [initiatives, setInitiatives] = useState([]) + const [attentionByInitiative, setAttentionByInitiative] = useState({}) const [loading, setLoading] = useState(true) const [busy, setBusy] = useState(false) const [error, setError] = useState(null) @@ -28,8 +32,12 @@ export function InitiativePortfolioWidget() { setLoading(true) setError(null) try { - const data = await listInitiatives() + const [data, attention] = await Promise.all([ + listInitiatives(), + getAttentionItems().catch(() => []), + ]) setInitiatives(Array.isArray(data) ? data : []) + setAttentionByInitiative(groupAttentionByInitiative(attention)) } catch (err) { setError(err.message) } finally { @@ -126,6 +134,9 @@ export function InitiativePortfolioWidget() { + )