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>
This commit is contained in:
Lars 2026-07-12 11:48:34 +02:00
parent 8546c7021b
commit b9bfa9e5bd
13 changed files with 352 additions and 12 deletions

View File

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

View File

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

View File

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

View File

@ -128,7 +128,7 @@ Phase H2 PM Work Modes AP1.9 ✓ 9a / → 9b9e
Phase H3 Plan Outline AP1.12 + AP1.10 ◐ 12ad, 10c
Phase I Gate-Graph AP1.13 + AP1.15 ◐ 13a/b, 15ac
Phase I2 Plan/Ist Snapshots AP1.14 ✓
Phase I3 Execution-Plan AP1.16 ◐ 16ab ✓ / 16c UI / 16d offen
Phase I3 Execution-Plan AP1.16 ◐ 16ac ✓ / 16d ◐ / AP1.9c ◐
Phase J Portfolio AP1.8 ◐ 8a ✓ / 8b deferred
Phase K Archetyp-Steuerung AP2.0 ◐ 2.0ac ✓ / → 2.0df
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.16ab 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.16cd Plan-Outline-Kanten + Planning Debt | offen | nach 16ab |

View File

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

View File

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

View File

@ -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 (
<ul className="initiative-portfolio-card-signals" aria-label="Steuerungssignale">
{top.map((item) => (
<li
key={`${item.kind}-${item.scope_id}`}
className={`initiative-portfolio-card-signal initiative-portfolio-card-signal--${item.severity}`}
title={item.summary || item.title}
>
{attentionKindLabel(item.kind)}
</li>
))}
{signals.length > top.length && (
<li className="initiative-portfolio-card-signal initiative-portfolio-card-signal--more muted">
+{signals.length - top.length}
</li>
)}
</ul>
)
}

View File

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

View File

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

View File

@ -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<string, Array>} */
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),
}
}

View File

@ -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')
})
})

View File

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

View File

@ -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() {
<StatusBadge kind="initiative" status={item.status} />
<PriorityBadge priority={item.priority} />
</div>
<InitiativePortfolioSignalChips
signals={attentionByInitiative[item.id] || []}
/>
</Link>
</article>
)