feat: Parent-Child TaskFlow — Delegated durch sichtbare Child-Tasks ersetzt

- Delegated State aus Board, Entities, DTOs, Frontend-Spalten und Tests entfernt
- Parent-Tasks bleiben InProgress waehrend delegierter Agentenarbeit
- Child-Tasks laufen sichtbar mit normalen States und parentTaskId
- Doku: README, Phase 3, Changelog, Controller-Kommentare angepasst
- openclaw-task-board-flow.md als Referenzdoku hinzugefuegt
- 73/73 Backend-Tests gruen, Frontend-Build gruen
This commit is contained in:
2026-06-21 21:30:52 +02:00
parent b89289989a
commit ac131f7f53
15 changed files with 464 additions and 133 deletions
+10 -1
View File
@@ -163,7 +163,7 @@ Legacy ModuleView routes (not standalone, rendered through `ModuleView.vue`):
| Route | Name | Description | | Route | Name | Description |
|---|---|---| |---|---|---|
| `/projects` | Projects | Project portfolio | | `/projects` | Projects | Project portfolio |
| `/tasks` | Task Board | Task board | | `/tasks` | Task Board | Task board with visible parent/child agent flow |
| `/models` | Models | Provider routing status | | `/models` | Models | Provider routing status |
| `/activity` | Activity | Audit timeline | | `/activity` | Activity | Audit timeline |
| `/chat` | Mobile Chat | Owner-chat preview | | `/chat` | Mobile Chat | Owner-chat preview |
@@ -189,6 +189,15 @@ Legacy ModuleView routes (not standalone, rendered through `ModuleView.vue`):
|---|---|---| |---|---|---|
| `GET` | `/api/v1/operations/snapshot` | Full operations snapshot (runtime, agents, projects, tasks, activity, metrics) | | `GET` | `/api/v1/operations/snapshot` | Full operations snapshot (runtime, agents, projects, tasks, activity, metrics) |
### Parent/Child task flow
The Task Board now models OpenClaw delegation as a visible parent/child flow:
- Iris keeps the parent task `In progress` while delegated work is running.
- Delegated agent work is represented as visible child tasks linked via `parentTaskId`.
- Child tasks use the normal visible states (`Backlog`, `In progress`, `Review`, `Blocked`, `Done`) instead of a separate hidden delegation lane.
- Agent progress hints on parent tasks derive from recent activity and child-task status summaries.
- Full workflow documentation: [`docs/openclaw-task-board-flow.md`](docs/openclaw-task-board-flow.md)
### Projects ### Projects
| Method | Path | Description | | Method | Path | Description |
+4 -12
View File
@@ -10,13 +10,11 @@ public class TaskBoardTests
[Theory] [Theory]
[InlineData("Backlog", "offen")] [InlineData("Backlog", "offen")]
[InlineData("In progress", "inProgress")] [InlineData("In progress", "inProgress")]
[InlineData("Delegated", "delegated")]
[InlineData("Review", "review")] [InlineData("Review", "review")]
[InlineData("Blocked", "blocked")] [InlineData("Blocked", "blocked")]
[InlineData("Done", "done")] [InlineData("Done", "done")]
[InlineData("backlog", "offen")] [InlineData("backlog", "offen")]
[InlineData("in progress", "inProgress")] [InlineData("in progress", "inProgress")]
[InlineData("delegated", "delegated")]
[InlineData("review", "review")] [InlineData("review", "review")]
[InlineData("blocked", "blocked")] [InlineData("blocked", "blocked")]
[InlineData("done", "done")] [InlineData("done", "done")]
@@ -35,7 +33,6 @@ public class TaskBoardTests
[InlineData("offen", "Backlog")] [InlineData("offen", "Backlog")]
[InlineData("inProgress", "In progress")] [InlineData("inProgress", "In progress")]
[InlineData("inprogress", "In progress")] [InlineData("inprogress", "In progress")]
[InlineData("delegated", "Delegated")]
[InlineData("review", "Review")] [InlineData("review", "Review")]
[InlineData("blocked", "Blocked")] [InlineData("blocked", "Blocked")]
[InlineData("done", "Done")] [InlineData("done", "Done")]
@@ -49,16 +46,15 @@ public class TaskBoardTests
Assert.Equal(expected, result); Assert.Equal(expected, result);
} }
// ── TaskStateHelper: AllStates has 6 entries ── // ── TaskStateHelper: AllStates has 5 entries ──
[Fact] [Fact]
public void AllStates_ContainsAllSixStates() public void AllStates_ContainsAllFiveStates()
{ {
var states = TaskStateHelper.AllStates; var states = TaskStateHelper.AllStates;
Assert.Equal(6, states.Length); Assert.Equal(5, states.Length);
Assert.Contains("Backlog", states); Assert.Contains("Backlog", states);
Assert.Contains("In progress", states); Assert.Contains("In progress", states);
Assert.Contains("Delegated", states);
Assert.Contains("Review", states); Assert.Contains("Review", states);
Assert.Contains("Blocked", states); Assert.Contains("Blocked", states);
Assert.Contains("Done", states); Assert.Contains("Done", states);
@@ -69,7 +65,6 @@ public class TaskBoardTests
[Theory] [Theory]
[InlineData("Backlog", true)] [InlineData("Backlog", true)]
[InlineData("In progress", true)] [InlineData("In progress", true)]
[InlineData("Delegated", true)]
[InlineData("Review", true)] [InlineData("Review", true)]
[InlineData("Blocked", true)] [InlineData("Blocked", true)]
[InlineData("Done", true)] [InlineData("Done", true)]
@@ -89,7 +84,6 @@ public class TaskBoardTests
[InlineData("In progress", true)] [InlineData("In progress", true)]
[InlineData("Blocked", true)] [InlineData("Blocked", true)]
[InlineData("Backlog", false)] [InlineData("Backlog", false)]
[InlineData("Delegated", false)]
[InlineData("Review", false)] [InlineData("Review", false)]
[InlineData("Done", false)] [InlineData("Done", false)]
[InlineData(null, false)] [InlineData(null, false)]
@@ -104,7 +98,6 @@ public class TaskBoardTests
[InlineData("Done", true)] [InlineData("Done", true)]
[InlineData("Backlog", true)] [InlineData("Backlog", true)]
[InlineData("In progress", false)] [InlineData("In progress", false)]
[InlineData("Delegated", false)]
[InlineData("Review", false)] [InlineData("Review", false)]
[InlineData("Blocked", false)] [InlineData("Blocked", false)]
[InlineData(null, false)] [InlineData(null, false)]
@@ -118,7 +111,6 @@ public class TaskBoardTests
[Theory] [Theory]
[InlineData("Backlog", "Offen")] [InlineData("Backlog", "Offen")]
[InlineData("In progress", "In Bearbeitung")] [InlineData("In progress", "In Bearbeitung")]
[InlineData("Delegated", "Delegiert")]
[InlineData("Review", "Review")] [InlineData("Review", "Review")]
[InlineData("Blocked", "Blockiert")] [InlineData("Blocked", "Blockiert")]
[InlineData("Done", "Erledigt")] [InlineData("Done", "Erledigt")]
@@ -136,7 +128,7 @@ public class TaskBoardTests
[Fact] [Fact]
public void ToStateString_And_ToTaskState_RoundTrip() public void ToStateString_And_ToTaskState_RoundTrip()
{ {
var states = new[] { TaskState.Backlog, TaskState.InProgress, TaskState.Delegated, TaskState.Review, TaskState.Blocked, TaskState.Done }; var states = new[] { TaskState.Backlog, TaskState.InProgress, TaskState.Review, TaskState.Blocked, TaskState.Done };
foreach (var state in states) foreach (var state in states)
{ {
var str = state.ToStateString(); var str = state.ToStateString();
+1 -1
View File
@@ -118,7 +118,7 @@ public class TasksController(ITaskService taskService) : ControllerBase
=> Results.Ok(await taskService.GetBoardAsync(ct)); => Results.Ok(await taskService.GetBoardAsync(ct));
/// <summary> /// <summary>
/// Setzt stale Tasks (InProgress/Delegated, älter als N Stunden) zurück auf Backlog. /// Setzt stale Tasks (InProgress, älter als N Stunden) zurück auf Backlog.
/// Wird vom Iris Autonomous Worker genutzt. /// Wird vom Iris Autonomous Worker genutzt.
/// </summary> /// </summary>
[AllowAnonymous] [AllowAnonymous]
+3 -9
View File
@@ -18,7 +18,6 @@ public enum TaskState
{ {
Backlog, Backlog,
InProgress, InProgress,
Delegated,
Blocked, Blocked,
Done, Done,
Review Review
@@ -30,7 +29,6 @@ public static class TaskStateHelper
{ {
[TaskState.Backlog] = "Backlog", [TaskState.Backlog] = "Backlog",
[TaskState.InProgress] = "In progress", [TaskState.InProgress] = "In progress",
[TaskState.Delegated] = "Delegated",
[TaskState.Blocked] = "Blocked", [TaskState.Blocked] = "Blocked",
[TaskState.Done] = "Done", [TaskState.Done] = "Done",
[TaskState.Review] = "Review" [TaskState.Review] = "Review"
@@ -40,7 +38,6 @@ public static class TaskStateHelper
{ {
["Backlog"] = TaskState.Backlog, ["Backlog"] = TaskState.Backlog,
["In progress"] = TaskState.InProgress, ["In progress"] = TaskState.InProgress,
["Delegated"] = TaskState.Delegated,
["Blocked"] = TaskState.Blocked, ["Blocked"] = TaskState.Blocked,
["Done"] = TaskState.Done, ["Done"] = TaskState.Done,
["Review"] = TaskState.Review ["Review"] = TaskState.Review
@@ -51,14 +48,13 @@ public static class TaskStateHelper
{ {
["Backlog"] = "Offen", ["Backlog"] = "Offen",
["In progress"] = "In Bearbeitung", ["In progress"] = "In Bearbeitung",
["Delegated"] = "Delegiert",
["Review"] = "Review", ["Review"] = "Review",
["Blocked"] = "Blockiert", ["Blocked"] = "Blockiert",
["Done"] = "Erledigt" ["Done"] = "Erledigt"
}; };
/// <summary>Valid task-state string values for API validation.</summary> /// <summary>Valid task-state string values for API validation.</summary>
public static readonly string[] AllStates = ["Backlog", "In progress", "Delegated", "Blocked", "Done", "Review"]; public static readonly string[] AllStates = ["Backlog", "In progress", "Blocked", "Done", "Review"];
/// <summary>Convert a TaskState enum to its API string representation.</summary> /// <summary>Convert a TaskState enum to its API string representation.</summary>
public static string ToStateString(this TaskState state) => StateToString[state]; public static string ToStateString(this TaskState state) => StateToString[state];
@@ -87,7 +83,7 @@ public static class TaskStateHelper
/// Returns true if the caller is allowed to change this task's state. /// Returns true if the caller is allowed to change this task's state.
/// POLICY: /// POLICY:
/// - **Iris und Bao** dürfen Status ändern / verschieben. /// - **Iris und Bao** dürfen Status ändern / verschieben.
/// - Sub-agents (programmer, reviewer, architekt) dürfen NIEMALS Status ändern. /// - Sub-agents (programmer, reviewer, architekt, researcher, executor) dürfen NIEMALS Status ändern.
/// - 'nexus-system' ist ein technischer Fallback für automatische Cron/Reset-Workflows. /// - 'nexus-system' ist ein technischer Fallback für automatische Cron/Reset-Workflows.
/// - Jeder andere (unbekannt, leer) wird abgewiesen. /// - Jeder andere (unbekannt, leer) wird abgewiesen.
/// </summary> /// </summary>
@@ -96,7 +92,7 @@ public static class TaskStateHelper
var caller = callerAgent?.Trim().ToLowerInvariant() ?? ""; var caller = callerAgent?.Trim().ToLowerInvariant() ?? "";
// Sub-agents must never move state // Sub-agents must never move state
var subAgents = new HashSet<string> { "programmer", "reviewer", "architekt" }; var subAgents = new HashSet<string> { "programmer", "reviewer", "architekt", "researcher", "executor" };
if (subAgents.Contains(caller)) return false; if (subAgents.Contains(caller)) return false;
// Technischer Fallback: nur für interne System-Operationen (Cron, ResetStale) // Technischer Fallback: nur für interne System-Operationen (Cron, ResetStale)
@@ -129,7 +125,6 @@ public static class TaskStateHelper
{ {
"backlog" => "offen", "backlog" => "offen",
"in progress" => "inProgress", "in progress" => "inProgress",
"delegated" => "delegated",
"review" => "review", "review" => "review",
"blocked" => "blocked", "blocked" => "blocked",
"done" => "done", "done" => "done",
@@ -146,7 +141,6 @@ public static class TaskStateHelper
{ {
"offen" => "Backlog", "offen" => "Backlog",
"inprogress" => "In progress", "inprogress" => "In progress",
"delegated" => "Delegated",
"review" => "Review", "review" => "Review",
"blocked" => "Blocked", "blocked" => "Blocked",
"done" => "Done", "done" => "Done",
+1
View File
@@ -34,6 +34,7 @@ public class NexusUser
/// if the underlying data is deleted. This is the single guard that /// if the underlying data is deleted. This is the single guard that
/// prevents owner-password drift after DB resets or volume recreations. /// prevents owner-password drift after DB resets or volume recreations.
/// </summary> /// </summary>
[Table("SeedAudit")]
public class SeedAudit public class SeedAudit
{ {
[Key] [Key]
-1
View File
@@ -138,7 +138,6 @@ public sealed record AgentActivityEntry(
public sealed record BoardResponse( public sealed record BoardResponse(
List<DashboardTaskDto> Offen, List<DashboardTaskDto> Offen,
List<DashboardTaskDto> InProgress, List<DashboardTaskDto> InProgress,
List<DashboardTaskDto> Delegated,
List<DashboardTaskDto> Review, List<DashboardTaskDto> Review,
List<DashboardTaskDto> Blocked, List<DashboardTaskDto> Blocked,
List<DashboardTaskDto> Done List<DashboardTaskDto> Done
+14 -15
View File
@@ -161,8 +161,8 @@ public sealed class TaskService(
/// <summary> /// <summary>
/// Returns agent-tasks grouped by which agent is expected to respond, /// Returns agent-tasks grouped by which agent is expected to respond,
/// with stale-detection: tasks in InProgress/Delegated that haven't been /// with stale-detection: parent tasks that remain in progress while child work
/// updated within the stale threshold. /// is active, and any in-progress task that has not been updated within the stale threshold.
/// </summary> /// </summary>
public async Task<AgentWorkflowOverview> GetAgentWorkflowOverviewAsync(TimeSpan staleThreshold, CancellationToken ct = default) public async Task<AgentWorkflowOverview> GetAgentWorkflowOverviewAsync(TimeSpan staleThreshold, CancellationToken ct = default)
{ {
@@ -194,8 +194,7 @@ public sealed class TaskService(
var staleTasks = map(agentTasks var staleTasks = map(agentTasks
.Where(t => .Where(t =>
(string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) || string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) &&
string.Equals(t.State, "Delegated", StringComparison.OrdinalIgnoreCase)) &&
t.UpdatedAt < threshold)); t.UpdatedAt < threshold));
return new AgentWorkflowOverview(waitingForBao, waitingForIris, waitingForOthers, return new AgentWorkflowOverview(waitingForBao, waitingForIris, waitingForOthers,
@@ -214,14 +213,18 @@ public sealed class TaskService(
throw new ArgumentException($"Parent task {parentTaskId} not found.", nameof(parentTaskId)); throw new ArgumentException($"Parent task {parentTaskId} not found.", nameof(parentTaskId));
} }
var normalizedSource = string.IsNullOrWhiteSpace(source) ? "bao" : source.Trim().ToLowerInvariant();
var normalizedAssignee = ValidateAssignedTo(assignedTo);
var task = new WorkTask var task = new WorkTask
{ {
Title = title.Trim(), Title = title.Trim(),
Detail = detail?.Trim(), Detail = detail?.Trim(),
Source = string.IsNullOrWhiteSpace(source) ? "bao" : source.Trim(), Source = normalizedSource,
Priority = string.IsNullOrWhiteSpace(priority) ? "Normal" : priority.Trim(), Priority = string.IsNullOrWhiteSpace(priority) ? "Normal" : priority.Trim(),
AssignedTo = ValidateAssignedTo(assignedTo), AssignedTo = normalizedAssignee,
ParentTaskId = parentTaskId ParentTaskId = parentTaskId,
IsAgentTask = parentTaskId.HasValue
}; };
await taskRepo.AddAsync(task, ct); await taskRepo.AddAsync(task, ct);
@@ -231,7 +234,7 @@ public sealed class TaskService(
await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = message, TaskId = task.Id }, ct); await activityRepo.AddAsync(new ActivityEvent { Type = "task", Message = message, TaskId = task.Id }, ct);
// Auto-notify: if assigned to bao, create a task_assigned notification // Auto-notify: if assigned to bao, create a task_assigned notification
if (string.Equals(assignedTo, "bao", StringComparison.OrdinalIgnoreCase)) if (string.Equals(normalizedAssignee, "bao", StringComparison.OrdinalIgnoreCase))
{ {
await notificationService.CreateAsync( await notificationService.CreateAsync(
"task_assigned", "task_assigned",
@@ -253,6 +256,7 @@ public sealed class TaskService(
task.IsAgentTask = true; task.IsAgentTask = true;
task.ExpectedFrom = string.IsNullOrWhiteSpace(expectedFrom) ? null : expectedFrom.Trim().ToLowerInvariant(); task.ExpectedFrom = string.IsNullOrWhiteSpace(expectedFrom) ? null : expectedFrom.Trim().ToLowerInvariant();
task.State = TaskStateHelper.ToStateString(TaskState.InProgress);
// Persist the agent-task-specific fields // Persist the agent-task-specific fields
await taskRepo.UpdateAsync(task, ct); await taskRepo.UpdateAsync(task, ct);
@@ -407,7 +411,6 @@ public sealed class TaskService(
var all = await taskRepo.GetAllAsync(ct); var all = await taskRepo.GetAllAsync(ct);
var offen = new List<DashboardTaskDto>(); var offen = new List<DashboardTaskDto>();
var inProgress = new List<DashboardTaskDto>(); var inProgress = new List<DashboardTaskDto>();
var delegated = new List<DashboardTaskDto>();
var review = new List<DashboardTaskDto>(); var review = new List<DashboardTaskDto>();
var blocked = new List<DashboardTaskDto>(); var blocked = new List<DashboardTaskDto>();
var done = new List<DashboardTaskDto>(); var done = new List<DashboardTaskDto>();
@@ -421,8 +424,6 @@ public sealed class TaskService(
offen.Add(dto); break; offen.Add(dto); break;
case "in progress": case "in progress":
inProgress.Add(dto); break; inProgress.Add(dto); break;
case "delegated":
delegated.Add(dto); break;
case "review": case "review":
review.Add(dto); break; review.Add(dto); break;
case "blocked": case "blocked":
@@ -436,12 +437,11 @@ public sealed class TaskService(
offen.Sort(SortByPriorityThenCreatedAt); offen.Sort(SortByPriorityThenCreatedAt);
inProgress.Sort(SortByPriorityThenCreatedAt); inProgress.Sort(SortByPriorityThenCreatedAt);
delegated.Sort(SortByPriorityThenCreatedAt);
review.Sort(SortByPriorityThenCreatedAt); review.Sort(SortByPriorityThenCreatedAt);
blocked.Sort(SortByPriorityThenCreatedAt); blocked.Sort(SortByPriorityThenCreatedAt);
done.Sort(SortByPriorityThenCreatedAt); done.Sort(SortByPriorityThenCreatedAt);
return new BoardResponse(offen, inProgress, delegated, review, blocked, done); return new BoardResponse(offen, inProgress, review, blocked, done);
} }
private static int SortByPriorityThenCreatedAt(DashboardTaskDto a, DashboardTaskDto b) private static int SortByPriorityThenCreatedAt(DashboardTaskDto a, DashboardTaskDto b)
@@ -500,8 +500,7 @@ public sealed class TaskService(
var all = await taskRepo.GetAllAsync(ct); var all = await taskRepo.GetAllAsync(ct);
var threshold = DateTimeOffset.UtcNow - staleThreshold; var threshold = DateTimeOffset.UtcNow - staleThreshold;
var staleTasks = all.Where(t => var staleTasks = all.Where(t =>
(string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) || string.Equals(t.State, "In progress", StringComparison.OrdinalIgnoreCase) &&
string.Equals(t.State, "Delegated", StringComparison.OrdinalIgnoreCase)) &&
t.UpdatedAt < threshold).ToList(); t.UpdatedAt < threshold).ToList();
foreach (var task in staleTasks) foreach (var task in staleTasks)
+320
View File
@@ -0,0 +1,320 @@
# OpenClaw ↔ Nexus Task Board Flow
> Letzte Aktualisierung: 2026-06-21
> Status: kanonische Arbeitsbeschreibung für Iris, Sub-Agenten und das Nexus Task Board
Diese Datei beschreibt den gewünschten und umgesetzten Arbeitsfluss zwischen:
- **Bao** als Auftraggeber
- **Iris** als Chief of Staff / Koordinatorin
- **Sub-Agenten** als ausführende Spezialisten
- **OpenClaw** als Agent-Runtime
- **Nexus Task Board** als sichtbare Aufgabenquelle
---
## 1. Kurzfassung
**Eine Hauptaufgabe gehört Iris.**
Wenn Iris Arbeit delegiert, wird diese Delegation **nicht unsichtbar im Chat** geführt, sondern als **sichtbare Child-Task** im Nexus Task Board angelegt.
Das bedeutet:
- **Parent-Task** = Verantwortung von Iris
- **Child-Task** = konkrete Arbeitsaufgabe für einen Spezial-Agenten
- **Board** = sichtbare Wahrheit für Aufgabenstatus und Ownership
- **OpenClaw** = Ausführungspfad für Agentenarbeit
---
## 2. Die Hauptidee
Früher war Delegation leicht unsichtbar oder lief über einen separaten `Delegated`-Status.
Der neue Flow ersetzt das durch:
1. **Iris übernimmt eine Parent-Task**
2. **Iris zerlegt die Arbeit bei Bedarf in Subtasks**
3. **Jede echte Delegation wird als Child-Task auf dem Board angelegt**
4. **Der zuständige Agent arbeitet gegen diese Child-Task**
5. **Iris integriert die Ergebnisse zurück in die Parent-Task**
6. **Erst wenn alles fertig ist, geht die Parent-Task in Review**
---
## 3. Systembild
```mermaid
flowchart LR
Bao[Bao\nAuftraggeber]
Iris[Iris\nChief of Staff]
Board[Nexus Task Board\nParent + Child Tasks]
OC[OpenClaw Runtime]
Agents[Sub-Agenten\nDeveloper / Reviewer / Architekt / ...]
Bao -->|Auftrag / Priorisierung| Iris
Iris -->|legt Parent-Task an / übernimmt Task| Board
Iris -->|delegiert konkrete Arbeit| OC
OC -->|führt Agenten-Task aus| Agents
Iris -->|legt Child-Tasks an| Board
Agents -->|arbeiten gegen Child-Tasks| Board
Agents -->|liefern Ergebnis / melden Blocker| Iris
Iris -->|integriert Ergebnis| Board
Board -->|Review für Bao| Bao
```
---
## 4. Rollen und Verantwortlichkeiten
### Bao
- gibt Aufgaben inhaltlich vor
- priorisiert und nimmt fertige Arbeit ab
- verschiebt fertige Hauptaufgaben aus **Review** nach **Done** oder zurück
### Iris
- übernimmt die Parent-Task
- analysiert, zerlegt, delegiert und reviewed
- hält die Hauptaufgabe auf dem Board aktuell
- erstellt sichtbare Child-Tasks für delegierte Arbeit
- entscheidet, ob etwas **In Progress**, **Blocked** oder **Review** ist
### Sub-Agenten
- arbeiten **nicht** direkt gegen eine diffuse Hauptaufgabe
- arbeiten gegen eine **konkret zugewiesene Child-Task**
- melden Fortschritt, Ergebnisse und Blocker an Iris
### OpenClaw
- führt die Agentenarbeit technisch aus
- liefert Nachrichten, Status und Arbeitsergebnisse zurück
- ersetzt nicht das Board als Aufgabenwahrheit
### Nexus Task Board
- ist die **sichtbare operative Quelle** für Aufgaben
- zeigt Parent-Task, Child-Tasks, Ownership und Status
- dokumentiert den tatsächlichen Arbeitsfluss
---
## 5. Parent-Task vs. Child-Task
| Ebene | Zweck | Owner | Sichtbarkeit |
|---|---|---|---|
| Parent-Task | Hauptauftrag / Koordination | Iris | Board |
| Child-Task | Delegierter Arbeitsblock | zuständiger Agent | Board |
### Parent-Task-Regeln
- bleibt bei Iris
- bleibt in der Regel **In Progress**, solange Koordination läuft
- geht erst auf **Review**, wenn alle nötigen Child-Tasks erledigt und integriert sind
- geht nur auf **Blocked**, wenn Iris insgesamt nicht weiterkommt
### Child-Task-Regeln
- repräsentiert eine echte delegierte Teilaufgabe
- hat klare Ownership (`AssignedTo`)
- zeigt sichtbar, welcher Agent woran arbeitet
- wird nicht für triviale Mini-Schritte missbraucht
---
## 6. Zustandsmodell
### Parent-Task-Lifecycle
```mermaid
stateDiagram-v2
[*] --> Backlog
Backlog --> InProgress: Iris übernimmt
InProgress --> InProgress: Child-Tasks anlegen / koordinieren
InProgress --> Blocked: Gesamtblocker
InProgress --> Review: alles integriert
Review --> Done: Bao nimmt ab
Review --> Backlog: Bao gibt zurück
Blocked --> Backlog: Blocker gelöst
```
### Child-Task-Lifecycle
```mermaid
stateDiagram-v2
[*] --> Backlog
Backlog --> InProgress: Agent startet
InProgress --> Done: Ergebnis geliefert
InProgress --> Blocked: Agent kommt nicht weiter
Blocked --> Backlog: neu geplant / entsperrt
Blocked --> InProgress: Iris stößt Weiterarbeit an
```
---
## 7. Der konkrete Arbeitsablauf
### Fall A: Bao gibt Iris einen neuen Auftrag
1. Bao formuliert einen Auftrag
2. Iris prüft Ziel, Scope, Risiko und Umgebung
3. Iris übernimmt oder erstellt die **Parent-Task**
4. Parent-Task geht auf **In Progress**
5. Wenn nötig zerlegt Iris die Arbeit in **Child-Tasks**
6. Child-Tasks werden passenden Agenten zugewiesen
7. Agenten arbeiten die Child-Tasks ab
8. Iris sammelt Ergebnisse ein und integriert sie
9. Parent-Task geht auf **Review**
10. Bao entscheidet: **Done** oder zurück nach **Backlog**
### Fall B: Agent meldet einen Blocker
1. Agent meldet Blocker an Iris
2. Iris prüft, ob der Blocker lokal lösbar ist
3. Wenn nein: die betroffene **Child-Task** geht auf **Blocked**
4. Falls nötig entsteht eine neue Ursachen-Task / neue Child-Task
5. Parent-Task bleibt **In Progress**, solange der Gesamtauftrag noch koordiniert wird
6. Nur wenn die Hauptaufgabe insgesamt feststeckt, geht die **Parent-Task** auf **Blocked**
---
## 8. OpenClaw- und Board-Interaktion
```mermaid
sequenceDiagram
participant Bao
participant Iris
participant Board as Nexus Task Board
participant OpenClaw
participant Agent as Sub-Agent
Bao->>Iris: Auftrag
Iris->>Board: Parent-Task übernehmen / anlegen
Iris->>Board: Child-Task anlegen
Iris->>OpenClaw: Agentenauftrag starten
OpenClaw->>Agent: Task ausführen
Agent-->>Iris: Ergebnis / Rückfrage / Blocker
Iris->>Board: Child-Task aktualisieren
Iris->>Board: Parent-Task integrieren
Iris->>Board: Parent auf Review setzen
Board-->>Bao: Review sichtbar
```
---
## 9. Regeln für gutes Schneiden von Child-Tasks
Eine Child-Task ist sinnvoll, wenn sie:
- einen **klaren Arbeitsblock** darstellt
- einen **eigenen Verantwortlichen** hat
- ein **eigenes Ergebnis** liefern soll
- unabhängig als **Done** oder **Blocked** sichtbar sein kann
Keine gute Child-Task ist:
- „Datei öffnen"
- „kurz nachschauen"
- „eine Kleinigkeit prüfen"
Faustregel:
> **Eine Child-Task soll ein echter delegierbarer Arbeitsauftrag sein, kein Mikro-Schritt.**
---
## 10. Board-Sicht: was sichtbar sein soll
Im Board soll erkennbar sein:
- welche Parent-Task Iris gerade steuert
- welche Child-Tasks darunter existieren
- welcher Agent welche Child-Task besitzt
- welche Child-Task blockiert ist
- welche Parent-Task in Review auf Bao wartet
Im Task-Detail sollen sichtbar sein:
- Parent/Child-Beziehung
- `AssignedTo`
- Status
- erwarteter nächster Beitrag / letzter Aktivitätshinweis
- Child-Task-Liste direkt unter der Parent-Task
---
## 11. Kanonische Regeln
### Regel 1 — Das Board ist die sichtbare Aufgabenwahrheit
Chat und Agentenläufe ergänzen das Board, ersetzen es aber nicht.
### Regel 2 — Iris bleibt Ownerin der Hauptaufgabe
Delegation verschiebt Verantwortung nicht automatisch auf den Agenten.
### Regel 3 — Delegation ist sichtbar
Jede echte delegierte Arbeit wird als Child-Task abgebildet.
### Regel 4 — Kein künstlicher Wartezustand auf Parent-Ebene
Die Parent-Task bleibt **In Progress**, solange Iris aktiv koordiniert.
### Regel 5 — Blocker präzise markieren
Wenn nur ein Arbeitspaket hängt, blockiert zuerst die **Child-Task**, nicht automatisch die ganze Parent-Task.
### Regel 6 — Review ist Bao-Gate
Fertige Hauptaufgaben gehen erst in **Review**, dann nach Bao-Entscheid auf **Done** oder zurück.
---
## 12. Beispiel
### Parent-Task
**„Nexus Taskflow auf Parent-/Child-Modell umstellen“** — Owner: `iris`
### Mögliche Child-Tasks
- **Backend-State-Handling anpassen** — Owner: `developer`
- **Frontend-Board-Spalten und Labels anpassen** — Owner: `developer`
- **Workflow verifizieren / Regression prüfen** — Owner: `reviewer`
- **Deploy-/Runtime-Auswirkung prüfen** — Owner: `architekt`
So sieht Bao später nicht nur „Iris arbeitet daran“, sondern konkret:
- welcher Teil erledigt ist
- welcher Teil noch läuft
- welcher Teil blockiert ist
- worauf Iris gerade wartet
---
## 13. Anti-Patterns
Diese Muster sollen vermieden werden:
- Parent-Task auf einen bloßen **Delegated**-Wartestatus schieben
- Delegation nur im Chat sichtbar machen
- Child-Tasks ohne klare Ownership anlegen
- Blocker nur mündlich erwähnen, aber nicht im Board markieren
- zehn Mikro-Subtasks für einen Mini-Arbeitsschritt erzeugen
---
## 14. Entscheidungsregel für Iris
Wenn Iris unsicher ist, ob sie eine Child-Task anlegen soll, gilt:
**Child-Task anlegen**, wenn mindestens einer der Punkte zutrifft:
- anderer Agent übernimmt echte Arbeit
- eigener Status muss sichtbar verfolgt werden
- eigener Blocker ist möglich
- Bao soll Transparenz über diesen Teil sehen
---
## 15. Technische Leitplanken
- `parentTaskId` verknüpft Child-Tasks mit der Parent-Task
- `AssignedTo` zeigt den operativen Owner
- Agentenstatus und Boardstatus dürfen sich ergänzen, aber nicht widersprechen
- Board-Spalten und API-State-Mapping müssen das Parent-/Child-Modell sauber abbilden
- UI und Doku müssen dieselbe Sprache sprechen
---
## 16. Merksatz
> **Iris koordiniert die Hauptaufgabe. Agenten erledigen sichtbare Child-Tasks. Das Board zeigt die Wahrheit. OpenClaw führt aus.**
+2
View File
@@ -7,6 +7,8 @@ export async function apiFetch(input: RequestInfo | URL, init: RequestInit = {})
const send = () => { const send = () => {
const headers = new Headers(init.headers) const headers = new Headers(init.headers)
if (auth.accessToken) headers.set('Authorization', `Bearer ${auth.accessToken}`) if (auth.accessToken) headers.set('Authorization', `Bearer ${auth.accessToken}`)
if (auth.isIris) headers.set('X-Agent-Id', 'iris')
else if (auth.isBao) headers.set('X-Agent-Id', 'bao')
return fetch(input, { ...init, headers, credentials: 'include' }) return fetch(input, { ...init, headers, credentials: 'include' })
} }
+6 -8
View File
@@ -4,7 +4,7 @@
* Fetches tasks from /api/dashboard/tasks and /api/dashboard/tasks/board * Fetches tasks from /api/dashboard/tasks and /api/dashboard/tasks/board
* and maps them into TaskItem[] format for the TaskStrip component. * and maps them into TaskItem[] format for the TaskStrip component.
* *
* Board state: grouped by column (offen, inProgress, delegated, review, done, blocked) * Board state: grouped by column (offen, inProgress, review, blocked, done)
* Auto-refresh: every 30 seconds. * Auto-refresh: every 30 seconds.
*/ */
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
@@ -34,10 +34,9 @@ export interface DashboardTaskDto {
export interface BoardGroup { export interface BoardGroup {
offen: DashboardTaskDto[] offen: DashboardTaskDto[]
inProgress: DashboardTaskDto[] inProgress: DashboardTaskDto[]
delegated: DashboardTaskDto[]
review: DashboardTaskDto[] review: DashboardTaskDto[]
done: DashboardTaskDto[]
blocked: DashboardTaskDto[] blocked: DashboardTaskDto[]
done: DashboardTaskDto[]
} }
export interface AgentWorkflowOverview { export interface AgentWorkflowOverview {
@@ -97,10 +96,9 @@ export const useTaskStore = defineStore('tasks', {
board: { board: {
offen: [] as DashboardTaskDto[], offen: [] as DashboardTaskDto[],
inProgress: [] as DashboardTaskDto[], inProgress: [] as DashboardTaskDto[],
delegated: [] as DashboardTaskDto[],
review: [] as DashboardTaskDto[], review: [] as DashboardTaskDto[],
done: [] as DashboardTaskDto[],
blocked: [] as DashboardTaskDto[], blocked: [] as DashboardTaskDto[],
done: [] as DashboardTaskDto[],
} as BoardGroup, } as BoardGroup,
boardLoading: false, boardLoading: false,
boardError: null as string | null, boardError: null as string | null,
@@ -169,10 +167,9 @@ export const useTaskStore = defineStore('tasks', {
const canonicalMap: Record<string, string> = { const canonicalMap: Record<string, string> = {
offen: 'Backlog', offen: 'Backlog',
inProgress: 'In progress', inProgress: 'In progress',
delegated: 'Delegated',
review: 'Review', review: 'Review',
done: 'Done',
blocked: 'Blocked', blocked: 'Blocked',
done: 'Done',
} }
// Save previous state for rollback // Save previous state for rollback
@@ -188,7 +185,6 @@ export const useTaskStore = defineStore('tasks', {
const task = const task =
findAndRemove(this.board.offen) ?? findAndRemove(this.board.offen) ??
findAndRemove(this.board.inProgress) ?? findAndRemove(this.board.inProgress) ??
findAndRemove(this.board.delegated) ??
findAndRemove(this.board.review) ?? findAndRemove(this.board.review) ??
findAndRemove(this.board.blocked) ?? findAndRemove(this.board.blocked) ??
findAndRemove(this.board.done) findAndRemove(this.board.done)
@@ -322,6 +318,7 @@ export const useTaskStore = defineStore('tasks', {
priority?: string priority?: string
assignedTo?: string assignedTo?: string
expectedFrom?: string expectedFrom?: string
parentTaskId?: string | null
}) { }) {
try { try {
const res = await apiFetch('/api/dashboard/tasks/agent', { const res = await apiFetch('/api/dashboard/tasks/agent', {
@@ -333,6 +330,7 @@ export const useTaskStore = defineStore('tasks', {
priority: data.priority ?? 'Medium', priority: data.priority ?? 'Medium',
assignedTo: data.assignedTo ?? null, assignedTo: data.assignedTo ?? null,
expectedFrom: data.expectedFrom ?? null, expectedFrom: data.expectedFrom ?? null,
parentTaskId: data.parentTaskId ?? null,
}), }),
}) })
if (!res.ok) throw new Error(`HTTP ${res.status}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)
+2 -2
View File
@@ -37,8 +37,8 @@ export interface RoutingTarget {
detail: string detail: string
} }
export type TaskState = 'Backlog' | 'In progress' | 'Blocked' | 'Done' export type TaskState = 'Backlog' | 'In progress' | 'Review' | 'Blocked' | 'Done'
export const TASK_STATES: TaskState[] = ['Backlog', 'In progress', 'Blocked', 'Done'] export const TASK_STATES: TaskState[] = ['Backlog', 'In progress', 'Review', 'Blocked', 'Done']
export interface OperationsSnapshot { export interface OperationsSnapshot {
generatedAt: string generatedAt: string
+63 -72
View File
@@ -3,13 +3,13 @@
* TaskBoardView Linear-style Kanban Board * TaskBoardView Linear-style Kanban Board
* Galaxy/Dashboard V2 styled edition. * Galaxy/Dashboard V2 styled edition.
* *
* 6 columns: Offen, In Bearbeitung, Delegiert, Review, Blockiert, Erledigt * 5 columns: Offen, In Bearbeitung, Review, Blockiert, Erledigt
* HTML5 Drag & Drop (no external lib) * HTML5 Drag & Drop (no external lib)
* *
* Agent-Workflow Features: * Agent-Workflow Features:
* - Agent-Tasks have a 🤖 badge * - Agent-Tasks have a 🤖 badge
* - ExpectedFrom field shows who is expected to act next * - ExpectedFrom field shows who is expected to act next
* - Stale-task warning banner at top (InProgress/Delegated > 2h) * - Stale-task warning banner at top (InProgress > 2h)
* - Waiting section for Iris overview * - Waiting section for Iris overview
*/ */
import { computed, onBeforeUnmount, onMounted, onUnmounted, reactive, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
@@ -157,7 +157,6 @@ function statusTone(state: string): string {
case 'done': return 'is-done' case 'done': return 'is-done'
case 'blocked': return 'is-blocked' case 'blocked': return 'is-blocked'
case 'review': return 'is-review' case 'review': return 'is-review'
case 'delegated': return 'is-delegated'
case 'in progress': return 'is-progress' case 'in progress': return 'is-progress'
default: return 'is-backlog' default: return 'is-backlog'
} }
@@ -183,7 +182,6 @@ function flattenBoard() {
return [ return [
...taskStore.board.offen, ...taskStore.board.offen,
...taskStore.board.inProgress, ...taskStore.board.inProgress,
...taskStore.board.delegated,
...taskStore.board.review, ...taskStore.board.review,
...taskStore.board.blocked, ...taskStore.board.blocked,
...taskStore.board.done, ...taskStore.board.done,
@@ -250,8 +248,45 @@ function relativeTime(date?: string | null): string {
return `vor ${days} d` return `vor ${days} d`
} }
function childStatusSummary(taskId: string): string {
const children = allBoardTasks.value.filter(task => task.parentTaskId === taskId)
if (!children.length) return ''
const counts = {
inProgress: children.filter(task => task.state === 'In progress').length,
review: children.filter(task => task.state === 'Review').length,
blocked: children.filter(task => task.state === 'Blocked').length,
done: children.filter(task => task.state === 'Done').length,
}
const parts = [] as string[]
if (counts.inProgress) parts.push(`${counts.inProgress} in Arbeit`)
if (counts.review) parts.push(`${counts.review} im Review`)
if (counts.blocked) parts.push(`${counts.blocked} blockiert`)
if (counts.done) parts.push(`${counts.done} erledigt`)
return parts.length ? `Child-Tasks: ${parts.join(' · ')}` : `Child-Tasks: ${children.length}`
}
function activityHint(task: BoardTask): string { function activityHint(task: BoardTask): string {
return task.lastActivityMessage?.trim() || (task.expectedFrom ? `Wartet auf ${expectedFromLabel(task.expectedFrom)}` : 'Noch kein relevanter Progress-Status') return task.lastActivityMessage?.trim()
|| childStatusSummary(task.id)
|| (task.expectedFrom ? `Wartet auf ${expectedFromLabel(task.expectedFrom)}` : 'Noch kein relevanter Progress-Status')
}
function hasChildTasks(taskId: string): boolean {
return allBoardTasks.value.some(task => task.parentTaskId === taskId)
}
function assigneeLabel(assignedTo: string | null | undefined): string {
return expectedFromLabel(assignedTo)
}
function assigneeClass(assignedTo: string | null | undefined): string {
if (!assignedTo) return ''
const lower = assignedTo.toLowerCase()
if (lower === 'iris') return 'assignee-iris'
if (lower === 'bao') return 'assignee-bao'
return 'assignee-agent'
} }
/* ── Task Navigation ───────────────────────────── */ /* ── Task Navigation ───────────────────────────── */
@@ -399,7 +434,7 @@ onUnmounted(() => {
<!-- Stale Warning Banner --> <!-- Stale Warning Banner -->
<div v-if="staleCount > 0" class="stale-banner"> <div v-if="staleCount > 0" class="stale-banner">
<AlertTriangle :size="14" /> <AlertTriangle :size="14" />
<span><strong>{{ staleCount }} Task(s)</strong> sind stale (InBearbeitung/Delegiert &gt; 2h ohne Update).</span> <span><strong>{{ staleCount }} Task(s)</strong> sind stale (In Bearbeitung &gt; 2h ohne Update).</span>
<button class="stale-dismiss" @click="showIrisPanel = true">Ansehen</button> <button class="stale-dismiss" @click="showIrisPanel = true">Ansehen</button>
</div> </div>
@@ -514,14 +549,14 @@ onUnmounted(() => {
<span <span
v-if="task.assignedTo" v-if="task.assignedTo"
class="assignee" class="assignee"
:class="task.assignedTo === 'iris' ? 'assignee-iris' : 'assignee-bao'" :class="assigneeClass(task.assignedTo)"
> >
{{ task.assignedTo === 'iris' ? '🤖 Iris' : '👤 Bao' }} {{ assigneeLabel(task.assignedTo) }}
</span> </span>
</div> </div>
<div class="card-title">{{ task.title }}</div> <div class="card-title">{{ task.title }}</div>
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div> <div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
<div v-if="task.isAgentTask" class="card-progress-hint">{{ activityHint(task) }}</div> <div v-if="task.isAgentTask || hasChildTasks(task.id)" class="card-progress-hint">{{ activityHint(task) }}</div>
<div class="card-meta">Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</div> <div class="card-meta">Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</div>
</button> </button>
<div v-if="!taskStore.board.offen.length" class="empty-col">Keine Aufgaben</div> <div v-if="!taskStore.board.offen.length" class="empty-col">Keine Aufgaben</div>
@@ -563,69 +598,20 @@ onUnmounted(() => {
<span <span
v-if="task.assignedTo" v-if="task.assignedTo"
class="assignee" class="assignee"
:class="task.assignedTo === 'iris' ? 'assignee-iris' : 'assignee-bao'" :class="assigneeClass(task.assignedTo)"
> >
{{ task.assignedTo === 'iris' ? '🤖 Iris' : '👤 Bao' }} {{ assigneeLabel(task.assignedTo) }}
</span> </span>
</div> </div>
<div class="card-title">{{ task.title }}</div> <div class="card-title">{{ task.title }}</div>
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div> <div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
<div v-if="task.isAgentTask" class="card-progress-hint">{{ activityHint(task) }}</div> <div v-if="task.isAgentTask || hasChildTasks(task.id)" class="card-progress-hint">{{ activityHint(task) }}</div>
<div class="card-meta">Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</div> <div class="card-meta">Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</div>
</button> </button>
<div v-if="!taskStore.board.inProgress.length" class="empty-col">Keine Aufgaben</div> <div v-if="!taskStore.board.inProgress.length" class="empty-col">Keine Aufgaben</div>
</div> </div>
</div> </div>
<div
class="col"
:class="{ 'drag-over': dragOverColumn === 'delegated' }"
@dragover="onDragOver($event, 'delegated')"
@dragleave="onDragLeave"
@drop="onDrop($event, 'delegated')"
>
<div class="col-header">
<span class="col-icon" style="background: var(--a-purple); box-shadow: 0 0 0 2px rgba(181,87,246,.25);"></span>
<span class="col-name">Delegiert</span>
<span class="col-count">{{ taskStore.board.delegated.length }}</span>
</div>
<div class="col-cards">
<button
v-for="task in taskStore.board.delegated"
:key="task.id"
type="button"
class="card"
:class="{ 'card-agent': task.isAgentTask }"
draggable="true"
@click="handleCardClick($event, task.id)"
@dragstart="onDragStart($event, task.id)"
@dragend="onDragEnd"
>
<div class="card-top">
<span class="prio-badge" :style="{ color: priorityColor(task.priority), borderColor: priorityColor(task.priority) }">
{{ priorityLabel(task.priority) }}
</span>
<span v-if="task.isAgentTask" class="agent-badge" title="Agent-Task">🤖</span>
<span v-if="task.expectedFrom" class="expected-badge" :title="'Erwartet: ' + task.expectedFrom">
{{ task.expectedFrom }}
</span>
<span
v-if="task.assignedTo"
class="assignee"
:class="task.assignedTo === 'iris' ? 'assignee-iris' : 'assignee-bao'"
>
{{ task.assignedTo === 'iris' ? '🤖 Iris' : '👤 Bao' }}
</span>
</div>
<div class="card-title">{{ task.title }}</div>
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
<div v-if="task.isAgentTask" class="card-progress-hint">{{ activityHint(task) }}</div>
<div class="card-meta">Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</div>
</button>
<div v-if="!taskStore.board.delegated.length" class="empty-col">Keine delegierten Aufgaben</div>
</div>
</div>
<div <div
class="col" class="col"
:class="{ 'drag-over': dragOverColumn === 'review' }" :class="{ 'drag-over': dragOverColumn === 'review' }"
@@ -661,14 +647,14 @@ onUnmounted(() => {
<span <span
v-if="task.assignedTo" v-if="task.assignedTo"
class="assignee" class="assignee"
:class="task.assignedTo === 'iris' ? 'assignee-iris' : 'assignee-bao'" :class="assigneeClass(task.assignedTo)"
> >
{{ task.assignedTo === 'iris' ? '🤖 Iris' : '👤 Bao' }} {{ assigneeLabel(task.assignedTo) }}
</span> </span>
</div> </div>
<div class="card-title">{{ task.title }}</div> <div class="card-title">{{ task.title }}</div>
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div> <div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
<div v-if="task.isAgentTask" class="card-progress-hint">{{ activityHint(task) }}</div> <div v-if="task.isAgentTask || hasChildTasks(task.id)" class="card-progress-hint">{{ activityHint(task) }}</div>
<div class="card-meta">Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</div> <div class="card-meta">Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</div>
</button> </button>
<div v-if="!taskStore.board.review.length" class="empty-col">Keine Aufgaben</div> <div v-if="!taskStore.board.review.length" class="empty-col">Keine Aufgaben</div>
@@ -705,14 +691,14 @@ onUnmounted(() => {
<span <span
v-if="task.assignedTo" v-if="task.assignedTo"
class="assignee" class="assignee"
:class="task.assignedTo === 'iris' ? 'assignee-iris' : 'assignee-bao'" :class="assigneeClass(task.assignedTo)"
> >
{{ task.assignedTo === 'iris' ? '🤖 Iris' : '👤 Bao' }} {{ assigneeLabel(task.assignedTo) }}
</span> </span>
</div> </div>
<div class="card-title">{{ task.title }}</div> <div class="card-title">{{ task.title }}</div>
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div> <div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
<div v-if="task.isAgentTask" class="card-progress-hint">{{ activityHint(task) }}</div> <div v-if="task.isAgentTask || hasChildTasks(task.id)" class="card-progress-hint">{{ activityHint(task) }}</div>
<div class="card-meta">Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</div> <div class="card-meta">Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</div>
</button> </button>
<div v-if="!taskStore.board.done.length" class="empty-col">Keine Aufgaben</div> <div v-if="!taskStore.board.done.length" class="empty-col">Keine Aufgaben</div>
@@ -754,13 +740,14 @@ onUnmounted(() => {
<span <span
v-if="task.assignedTo" v-if="task.assignedTo"
class="assignee" class="assignee"
:class="task.assignedTo === 'iris' ? 'assignee-iris' : 'assignee-bao'" :class="assigneeClass(task.assignedTo)"
> >
{{ task.assignedTo === 'iris' ? '🤖 Iris' : '👤 Bao' }} {{ assigneeLabel(task.assignedTo) }}
</span> </span>
</div> </div>
<div class="card-title">{{ task.title }}</div> <div class="card-title">{{ task.title }}</div>
<div v-if="task.detail" class="card-preview">{{ task.detail }}</div> <div v-if="task.detail" class="card-preview">{{ task.detail }}</div>
<div v-if="task.isAgentTask || hasChildTasks(task.id)" class="card-progress-hint">{{ activityHint(task) }}</div>
<div class="card-meta">Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</div> <div class="card-meta">Update {{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</div>
</button> </button>
<div v-if="!taskStore.board.blocked.length" class="empty-col">Keine Blockierer</div> <div v-if="!taskStore.board.blocked.length" class="empty-col">Keine Blockierer</div>
@@ -849,11 +836,12 @@ onUnmounted(() => {
<span>#{{ selectedTask.id.slice(0, 8) }}</span> <span>#{{ selectedTask.id.slice(0, 8) }}</span>
<span v-if="selectedTask.isAgentTask" class="meta-agent-tag">🤖 Agent-Task</span> <span v-if="selectedTask.isAgentTask" class="meta-agent-tag">🤖 Agent-Task</span>
<span v-if="selectedTask.expectedFrom" class="meta-expected"> Erwartet: {{ selectedTask.expectedFrom }}</span> <span v-if="selectedTask.expectedFrom" class="meta-expected"> Erwartet: {{ selectedTask.expectedFrom }}</span>
<span v-if="selectedTask.parentTaskId" class="meta-expected"> Child-Task</span>
<span><Clock3 :size="13" /> Aktualisiert {{ formatDate(selectedTask.updatedAt, true) }}</span> <span><Clock3 :size="13" /> Aktualisiert {{ formatDate(selectedTask.updatedAt, true) }}</span>
<span><CalendarDays :size="13" /> Erstellt {{ formatDate(selectedTask.createdAt) }}</span> <span><CalendarDays :size="13" /> Erstellt {{ formatDate(selectedTask.createdAt) }}</span>
<span v-if="selectedTask.isAgentTask"><MessageSquareText :size="13" /> Letzter Status {{ relativeTime(selectedTask.lastActivityAt ?? selectedTask.updatedAt) }}</span> <span v-if="selectedTask.isAgentTask"><MessageSquareText :size="13" /> Letzter Status {{ relativeTime(selectedTask.lastActivityAt ?? selectedTask.updatedAt) }}</span>
</div> </div>
<div v-if="selectedTask.isAgentTask" class="detail-progress-banner"> <div v-if="selectedTask.isAgentTask || hasChildTasks(selectedTask.id)" class="detail-progress-banner">
<strong>Letzter Fortschritt:</strong> {{ activityHint(selectedTask) }} <strong>Letzter Fortschritt:</strong> {{ activityHint(selectedTask) }}
</div> </div>
</div> </div>
@@ -914,7 +902,6 @@ onUnmounted(() => {
> >
<option value="Backlog">Offen</option> <option value="Backlog">Offen</option>
<option value="In progress">In Bearbeitung</option> <option value="In progress">In Bearbeitung</option>
<option value="Delegated">Delegiert</option>
<option value="Review">Review</option> <option value="Review">Review</option>
<option value="Blocked">Blockiert</option> <option value="Blocked">Blockiert</option>
<option value="Done">Erledigt</option> <option value="Done">Erledigt</option>
@@ -970,6 +957,10 @@ onUnmounted(() => {
<dt>Erwartet von</dt> <dt>Erwartet von</dt>
<dd>{{ selectedTask.expectedFrom }}</dd> <dd>{{ selectedTask.expectedFrom }}</dd>
</div> </div>
<div v-if="selectedTask.parentTaskId">
<dt>Task-Typ</dt>
<dd>Sichtbare Child-Task</dd>
</div>
</dl> </dl>
</section> </section>
@@ -1078,6 +1069,7 @@ select:disabled { opacity: .45; cursor: not-allowed; }
.assignee { font-family: 'Manrope', sans-serif; font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 4px; } .assignee { font-family: 'Manrope', sans-serif; font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 4px; }
.assignee-iris { background: rgba(147, 51, 234, .12); color: #c084fc; } .assignee-iris { background: rgba(147, 51, 234, .12); color: #c084fc; }
.assignee-bao { background: rgba(59, 130, 246, .12); color: #60a5fa; } .assignee-bao { background: rgba(59, 130, 246, .12); color: #60a5fa; }
.assignee-agent { background: rgba(16, 185, 129, .12); color: #6ee7b7; }
.card-title { font-size: 12.5px; font-weight: 600; color: var(--tx); line-height: 1.4; word-break: break-word; font-family: 'Manrope', sans-serif; } .card-title { font-size: 12.5px; font-weight: 600; color: var(--tx); line-height: 1.4; word-break: break-word; font-family: 'Manrope', sans-serif; }
.card-preview { margin-top: 6px; font-size: 11px; line-height: 1.45; color: var(--tx-2); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } .card-preview { margin-top: 6px; font-size: 11px; line-height: 1.45; color: var(--tx-2); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.card-progress-hint { margin-top: 7px; font-size: 10.5px; color: var(--tx-2); line-height: 1.4; padding: 6px 8px; border-radius: 8px; background: rgba(124,108,255,.07); border: 1px solid rgba(124,108,255,.12); } .card-progress-hint { margin-top: 7px; font-size: 10.5px; color: var(--tx-2); line-height: 1.4; padding: 6px 8px; border-radius: 8px; background: rgba(124,108,255,.07); border: 1px solid rgba(124,108,255,.12); }
@@ -1123,7 +1115,6 @@ select:disabled { opacity: .45; cursor: not-allowed; }
.detail-state-pill { width: fit-content; border-radius: 999px; padding: 5px 10px; font-size: 11px; font-weight: 700; letter-spacing: .03em; border: 1px solid transparent; } .detail-state-pill { width: fit-content; border-radius: 999px; padding: 5px 10px; font-size: 11px; font-weight: 700; letter-spacing: .03em; border: 1px solid transparent; }
.detail-state-pill.is-backlog { color: #fde68a; background: rgba(251,191,36,.12); border-color: rgba(251,191,36,.25); } .detail-state-pill.is-backlog { color: #fde68a; background: rgba(251,191,36,.12); border-color: rgba(251,191,36,.25); }
.detail-state-pill.is-progress { color: #86efac; background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.25); } .detail-state-pill.is-progress { color: #86efac; background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.25); }
.detail-state-pill.is-delegated { color: #d8b4fe; background: rgba(168,85,247,.12); border-color: rgba(168,85,247,.25); }
.detail-state-pill.is-review { color: #fdba74; background: rgba(249,115,22,.12); border-color: rgba(249,115,22,.25); } .detail-state-pill.is-review { color: #fdba74; background: rgba(249,115,22,.12); border-color: rgba(249,115,22,.25); }
.detail-state-pill.is-blocked { color: #fda4af; background: rgba(244,63,94,.12); border-color: rgba(244,63,94,.25); } .detail-state-pill.is-blocked { color: #fda4af; background: rgba(244,63,94,.12); border-color: rgba(244,63,94,.25); }
.detail-state-pill.is-done { color: #86efac; background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.25); } .detail-state-pill.is-done { color: #86efac; background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.25); }
+29 -8
View File
@@ -92,7 +92,6 @@ function statusLabel(state: string): string {
const map: Record<string, string> = { const map: Record<string, string> = {
'Backlog': 'Offen', 'Backlog': 'Offen',
'In progress': 'In Bearbeitung', 'In progress': 'In Bearbeitung',
'Delegated': 'Delegiert',
'Review': 'Review', 'Review': 'Review',
'Blocked': 'Blockiert', 'Blocked': 'Blockiert',
'Done': 'Erledigt', 'Done': 'Erledigt',
@@ -105,7 +104,6 @@ function statusClass(state: string): string {
if (s === 'done') return 'is-done' if (s === 'done') return 'is-done'
if (s === 'blocked') return 'is-blocked' if (s === 'blocked') return 'is-blocked'
if (s === 'review') return 'is-review' if (s === 'review') return 'is-review'
if (s === 'delegated') return 'is-delegated'
if (s === 'in progress') return 'is-progress' if (s === 'in progress') return 'is-progress'
return 'is-backlog' return 'is-backlog'
} }
@@ -148,8 +146,27 @@ function relativeTime(date?: string | null): string {
return `vor ${days} d` return `vor ${days} d`
} }
function progressHint(taskLike: Pick<TaskDto, 'lastActivityMessage' | 'expectedFrom'>): string { function childStatusSummary(taskId: string): string {
return taskLike.lastActivityMessage?.trim() || (taskLike.expectedFrom ? `Wartet auf ${taskLike.expectedFrom}` : 'Noch kein relevanter Progress-Status') const childItems = children.value.filter(child => child.parentTaskId === taskId)
if (!childItems.length) return ''
const counts = {
inProgress: childItems.filter(child => child.state === 'In progress').length,
review: childItems.filter(child => child.state === 'Review').length,
blocked: childItems.filter(child => child.state === 'Blocked').length,
done: childItems.filter(child => child.state === 'Done').length,
}
const parts = [] as string[]
if (counts.inProgress) parts.push(`${counts.inProgress} in Arbeit`)
if (counts.review) parts.push(`${counts.review} im Review`)
if (counts.blocked) parts.push(`${counts.blocked} blockiert`)
if (counts.done) parts.push(`${counts.done} erledigt`)
return parts.length ? `Child-Tasks: ${parts.join(' · ')}` : `Child-Tasks: ${childItems.length}`
}
function progressHint(taskLike: Pick<TaskDto, 'id' | 'lastActivityMessage' | 'expectedFrom'>): string {
return taskLike.lastActivityMessage?.trim() || childStatusSummary(taskLike.id) || (taskLike.expectedFrom ? `Wartet auf ${taskLike.expectedFrom}` : 'Noch kein relevanter Progress-Status')
} }
/* ── API calls ───────────────────────────────── */ /* ── API calls ───────────────────────────────── */
@@ -362,6 +379,7 @@ function handleKeydown(e: KeyboardEvent) {
<span class="meta-chip">{{ task.priority }} Priorität</span> <span class="meta-chip">{{ task.priority }} Priorität</span>
<span v-if="task.isAgentTask" class="meta-chip">🤖 Agent-Task</span> <span v-if="task.isAgentTask" class="meta-chip">🤖 Agent-Task</span>
<span v-if="task.expectedFrom" class="meta-chip"> Erwartet: {{ task.expectedFrom }}</span> <span v-if="task.expectedFrom" class="meta-chip"> Erwartet: {{ task.expectedFrom }}</span>
<span v-if="task.parentTaskId" class="meta-chip"> Sichtbare Child-Task</span>
</div> </div>
</div> </div>
@@ -390,7 +408,7 @@ function handleKeydown(e: KeyboardEvent) {
</span> </span>
</div> </div>
<div v-if="task.isAgentTask" class="progress-banner"> <div v-if="task.isAgentTask || childStatusSummary(task.id)" class="progress-banner">
<strong>Letzter Fortschritt:</strong> {{ progressHint(task) }} <strong>Letzter Fortschritt:</strong> {{ progressHint(task) }}
</div> </div>
@@ -424,7 +442,7 @@ function handleKeydown(e: KeyboardEvent) {
<option value="Medium">Medium</option> <option value="Medium">Medium</option>
<option value="Low">Low</option> <option value="Low">Low</option>
</select> </select>
<input v-model="subtaskAssign" class="galaxy-input narrow" placeholder="Zuständig (bao, iris…)" /> <input v-model="subtaskAssign" class="galaxy-input narrow" placeholder="Zuständig (bao, iris, researcher…)" />
<button class="btn-primary btn-sm" @click="createSubtask" :disabled="creatingSubtask"> <button class="btn-primary btn-sm" @click="createSubtask" :disabled="creatingSubtask">
{{ creatingSubtask ? 'Erstelle…' : 'Anlegen' }} {{ creatingSubtask ? 'Erstelle…' : 'Anlegen' }}
</button> </button>
@@ -455,6 +473,8 @@ function handleKeydown(e: KeyboardEvent) {
> >
<option value="Backlog">Offen</option> <option value="Backlog">Offen</option>
<option value="In progress">In Arbeit</option> <option value="In progress">In Arbeit</option>
<option value="Review">Review</option>
<option value="Blocked">Blockiert</option>
<option value="Done">Erledigt</option> <option value="Done">Erledigt</option>
</select> </select>
<button <button
@@ -520,7 +540,6 @@ function handleKeydown(e: KeyboardEvent) {
<select v-model="form.state" class="galaxy-input galaxy-select" :disabled="!canChangeState"> <select v-model="form.state" class="galaxy-input galaxy-select" :disabled="!canChangeState">
<option value="Backlog">Offen</option> <option value="Backlog">Offen</option>
<option value="In progress">In Bearbeitung</option> <option value="In progress">In Bearbeitung</option>
<option value="Delegated">Delegiert</option>
<option value="Review">Review</option> <option value="Review">Review</option>
<option value="Blocked">Blockiert</option> <option value="Blocked">Blockiert</option>
<option value="Done">Erledigt</option> <option value="Done">Erledigt</option>
@@ -543,6 +562,8 @@ function handleKeydown(e: KeyboardEvent) {
<option value="programmer">🛠 Programmer</option> <option value="programmer">🛠 Programmer</option>
<option value="reviewer">🔎 Reviewer</option> <option value="reviewer">🔎 Reviewer</option>
<option value="architekt">🏛 Architekt</option> <option value="architekt">🏛 Architekt</option>
<option value="researcher">🔬 Researcher</option>
<option value="executor"> Executor</option>
</select> </select>
</label> </label>
<label class="sidebar-field"> <label class="sidebar-field">
@@ -559,6 +580,7 @@ function handleKeydown(e: KeyboardEvent) {
<div><dt>Erstellt</dt><dd>{{ formatDate(task.createdAt) }}</dd></div> <div><dt>Erstellt</dt><dd>{{ formatDate(task.createdAt) }}</dd></div>
<div><dt>Geändert</dt><dd>{{ formatDate(task.updatedAt, true) }}</dd></div> <div><dt>Geändert</dt><dd>{{ formatDate(task.updatedAt, true) }}</dd></div>
<div v-if="task.isAgentTask"><dt>Letzter Status</dt><dd>{{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</dd></div> <div v-if="task.isAgentTask"><dt>Letzter Status</dt><dd>{{ relativeTime(task.lastActivityAt ?? task.updatedAt) }}</dd></div>
<div v-if="task.parentTaskId"><dt>Task-Typ</dt><dd>Sichtbare Child-Task</dd></div>
</dl> </dl>
</section> </section>
@@ -678,7 +700,6 @@ function handleKeydown(e: KeyboardEvent) {
.state-badge.is-backlog { color: #fde68a; background: rgba(251,191,36,.12); border-color: rgba(251,191,36,.25); } .state-badge.is-backlog { color: #fde68a; background: rgba(251,191,36,.12); border-color: rgba(251,191,36,.25); }
.state-badge.is-progress { color: #86efac; background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.25); } .state-badge.is-progress { color: #86efac; background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.25); }
.state-badge.is-delegated { color: #d8b4fe; background: rgba(168,85,247,.12); border-color: rgba(168,85,247,.25); }
.state-badge.is-review { color: #fdba74; background: rgba(249,115,22,.12); border-color: rgba(249,115,22,.25); } .state-badge.is-review { color: #fdba74; background: rgba(249,115,22,.12); border-color: rgba(249,115,22,.25); }
.state-badge.is-blocked { color: #fda4af; background: rgba(244,63,94,.12); border-color: rgba(244,63,94,.25); } .state-badge.is-blocked { color: #fda4af; background: rgba(244,63,94,.12); border-color: rgba(244,63,94,.25); }
.state-badge.is-done { color: #86efac; background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.25); } .state-badge.is-done { color: #86efac; background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.25); }
+2 -1
View File
@@ -86,7 +86,8 @@
- 2026-06-20: Nexus-Auth-Persistenz live verifiziert. Owner-Passwort in der produktiven Postgres-DB geprüft, Stack vollständig neu gestartet und anschließend `postgres`, `api` und `web` per `docker:cli compose up -d --force-recreate` neu erstellt, ohne das DB-Volume zu löschen. Ergebnis: `/health/live` blieb healthy, der Passwort-Hash für `vmbao62@hotmail.de` blieb vor und nach Restart/Recreate identisch. Wichtiges Learning: temporäre Passwörter oder Auth-Fixes niemals an Bao weitergeben, bevor der echte Live-Login oder mindestens der persistierte DB-Hash auf dem Zielstack verifiziert ist. - 2026-06-20: Nexus-Auth-Persistenz live verifiziert. Owner-Passwort in der produktiven Postgres-DB geprüft, Stack vollständig neu gestartet und anschließend `postgres`, `api` und `web` per `docker:cli compose up -d --force-recreate` neu erstellt, ohne das DB-Volume zu löschen. Ergebnis: `/health/live` blieb healthy, der Passwort-Hash für `vmbao62@hotmail.de` blieb vor und nach Restart/Recreate identisch. Wichtiges Learning: temporäre Passwörter oder Auth-Fixes niemals an Bao weitergeben, bevor der echte Live-Login oder mindestens der persistierte DB-Hash auf dem Zielstack verifiziert ist.
- 2026-06-20: Task Board um klickbare Linear-inspirierte Detailansicht erweitert: Board-Karten öffnen jetzt ein strukturiertes Side/Overlay-Detailpanel mit editierbarem Titel, Beschreibung, Status, Priorität, Zuständigkeit und Fälligkeitsdatum sowie geladener Aktivität und Unteraufgaben. `frontend/src/views/TaskBoardView.vue` und `frontend/src/stores/tasks.ts` angepasst. Verifiziert mit `COREPACK_HOME=$PWD/.corepack-home PNPM_HOME=$PWD/.pnpm-home pnpm build`. - 2026-06-20: Task Board um klickbare Linear-inspirierte Detailansicht erweitert: Board-Karten öffnen jetzt ein strukturiertes Side/Overlay-Detailpanel mit editierbarem Titel, Beschreibung, Status, Priorität, Zuständigkeit und Fälligkeitsdatum sowie geladener Aktivität und Unteraufgaben. `frontend/src/views/TaskBoardView.vue` und `frontend/src/stores/tasks.ts` angepasst. Verifiziert mit `COREPACK_HOME=$PWD/.corepack-home PNPM_HOME=$PWD/.pnpm-home pnpm build`.
- 2026-06-19: Task-Board-Doku-Drift behoben: Header-Kommentar in TaskBoardView.vue von "4 columns" auf "6 columns" (Offen, InBearbeitung, Delegiert, Review, Blockiert, Erledigt) korrigiert. tasks.ts-Store-Kopfkommentar um delegated ergänzt. - 2026-06-21: Delegated-/unsichtbares Delegationsmodell auf sichtbaren Parent-Child-Flow umgestellt. `Delegated` aus Board-State, DTOs, Frontend-Spalten und Tests entfernt; Parent-Tasks bleiben bei delegierter Agentenarbeit `In progress`, Child-Tasks laufen sichtbar mit normalen States und `parentTaskId`. Doku in README, Phase 3, Changelog und Controller-Kommentaren angepasst.
- 2026-06-19: Task-Board-Doku-Drift behoben; später im Parent-Child-Flow wieder auf 5 sichtbare States ohne separate Delegiert-Spalte umgestellt.
- 2026-06-19: Veralteter TODO.md-Import entfernt: `ImportFromIrisTodoAsync` in TaskService.cs, ITaskService.cs und der import-from-iris-todo-API-Endpoint in DashboardController.cs gelöscht. ImportResultDto aus Models/Dashboard.cs entfernt. TODO.md ist abgeschafft, Task Board alleinige Quelle. - 2026-06-19: Veralteter TODO.md-Import entfernt: `ImportFromIrisTodoAsync` in TaskService.cs, ITaskService.cs und der import-from-iris-todo-API-Endpoint in DashboardController.cs gelöscht. ImportResultDto aus Models/Dashboard.cs entfernt. TODO.md ist abgeschafft, Task Board alleinige Quelle.
- 2026-06-19: Backend-Tests erweitert: TaskBoardTests.cs (69 Tests total, +13 neue) decken TaskStateHelper-BoardGroupKey/ToState/BoardGroupToState/DisplayString/AllStates/IsValidState/IsInProgressOrBlocked/IsDoneOrBacklog ab. Backend-Build 0 Errors, Frontend vue-tsc 0 Errors. - 2026-06-19: Backend-Tests erweitert: TaskBoardTests.cs (69 Tests total, +13 neue) decken TaskStateHelper-BoardGroupKey/ToState/BoardGroupToState/DisplayString/AllStates/IsValidState/IsInProgressOrBlocked/IsDoneOrBacklog ab. Backend-Build 0 Errors, Frontend vue-tsc 0 Errors.
- 2026-06-16: Program.cs refactored: DI extrahiert in `Extensions/ServiceCollectionExtensions.cs`, Middleware in `Extensions/ApplicationBuilderExtensions.cs`, Helpers in `Helpers/PasswordHelper.cs`. Program.cs von ~200 auf 26 Zeilen reduziert. - 2026-06-16: Program.cs refactored: DI extrahiert in `Extensions/ServiceCollectionExtensions.cs`, Middleware in `Extensions/ApplicationBuilderExtensions.cs`, Helpers in `Helpers/PasswordHelper.cs`. Program.cs von ~200 auf 26 Zeilen reduziert.
+7 -3
View File
@@ -1,12 +1,16 @@
# Phase 3 # Phase 3
> Letzte Aktualisierung: 2026-06-08 > Letzte Aktualisierung: 2026-06-21
- [ ] Office View - [ ] Office View
- [ ] Kalender - [ ] Kalender
- [ ] Agent Collaboration Visualisierung - [x] Agent Collaboration Visualisierung
- sichtbarer Parent-Child-Flow im Task Board statt unsichtbarer Delegation
- Parent bleibt bei Iris in `In progress`, Child-Tasks sind sichtbar ueber `parentTaskId`
- Board/UI zeigen Child-Fortschritt auf Karten und im Detailpanel
- Ablauf dokumentiert in [`docs/openclaw-task-board-flow.md`](../docs/openclaw-task-board-flow.md)
- [ ] Advanced Reporting - [ ] Advanced Reporting
## Fokus ## Fokus
Phase 3 bleibt bewusst knapp und wird erst beim Start der Arbeit ausgebaut. Parent-Child-Delegation ist im Board umgesetzt. Offene Phase-3-Schwerpunkte sind jetzt Kalender, Office View und spaetere Reporting-/Visualisierungs-Ausbauten.