diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..3407af2
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,8 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(git add:*)",
+ "Bash(git commit:*)"
+ ]
+ }
+}
diff --git a/.superpowers/brainstorm/1955-1776152447/content/color-green-teal.html b/.superpowers/brainstorm/1955-1776152447/content/color-green-teal.html
new file mode 100644
index 0000000..b7e22ca
--- /dev/null
+++ b/.superpowers/brainstorm/1955-1776152447/content/color-green-teal.html
@@ -0,0 +1,106 @@
+
Green-Teal Variations
+Steel Teal shifted greener. Pick the one that feels right.
+
+
+
+
+
+
+
+ My Project
+
+
+
+
+
Fix login bug
+
agent
+
+
+
+
+ + Add a task...
+
+
+
+
+
Forest Teal
+
Accent: #3d9474. Distinctly greener, still muted. Earthy.
+
+
+
+
+
+
+
+
+ My Project
+
+
+
+
+
Fix login bug
+
agent
+
+
+
+
+ + Add a task...
+
+
+
+
+
Jade
+
Accent: #4a9880. Balanced green-teal midpoint. Calm but not cold.
+
+
+
+
+
+
+
+
+ My Project
+
+
+
+
+
Fix login bug
+
agent
+
+
+
+
+ + Add a task...
+
+
+
+
+
Sage
+
Accent: #5a9a7a. Most green of the three. Softer, natural tone.
+
+
+
diff --git a/.superpowers/brainstorm/1955-1776152447/content/color-theme.html b/.superpowers/brainstorm/1955-1776152447/content/color-theme.html
new file mode 100644
index 0000000..2408cee
--- /dev/null
+++ b/.superpowers/brainstorm/1955-1776152447/content/color-theme.html
@@ -0,0 +1,107 @@
+Accent Color: Which tone?
+You want something dimmer than the indigo (#6366f1) I showed. Here are darker, more muted options — each shown on a task list mockup.
+
+
+
+
+
+
+
+
+ My Project
+
+
+
+
+
+
Fix login bug
+
agent
+
+
+
+
+ + Add a task...
+
+
+
+
+
Slate Blue
+
Muted blue-gray. Accent: #4b5ea8. Very subdued, professional. Close to VS Code's dark theme feel.
+
+
+
+
+
+
+
+
+ My Project
+
+
+
+
+
Fix login bug
+
agent
+
+
+
+ + Add a task...
+
+
+
+
+
Dim Violet
+
Muted purple. Accent: #7c6aad. Slightly warmer, still understated. Has a subtle "Claude" vibe.
+
+
+
+
+
+
+
+
+ My Project
+
+
+
+
+
Fix login bug
+
agent
+
+
+
+ + Add a task...
+
+
+
+
+
Steel Teal
+
Muted teal-green. Accent: #4a8c8c. Cool and calm. Distinct from typical blue-heavy dark UIs.
+
+
+
+
+
+
+
+
+ My Project
+
+
+
+
+
Fix login bug
+
agent
+
+
+
+ + Add a task...
+
+
+
+
+
Charcoal Blue
+
Desaturated steel blue. Accent: #5571a1. Very close to Microsoft To Do's dark mode accent but dimmer.
+
+
+
diff --git a/.superpowers/brainstorm/1955-1776152447/content/layout-design.html b/.superpowers/brainstorm/1955-1776152447/content/layout-design.html
new file mode 100644
index 0000000..19c82de
--- /dev/null
+++ b/.superpowers/brainstorm/1955-1776152447/content/layout-design.html
@@ -0,0 +1,169 @@
+Layout & Visual Design Direction
+Your current UI has button toolbars and minimal spacing. Which direction should we take?
+
+
+
+
+
+
+
+
+
Lists
+
+
My Project
+
Backend Work
+
UI Polish
+
+
+ +
+ E
+ -
+
+
+
+
+
Tasks
+
+
+
+
Fix login bug
+
agent
+
+
+ Running
+ Run
+
+
+
+
+
+ + Task
+ Edit
+ Delete
+
+
+
+
+
+
+
+
+
+
+
+
+
Lists
+
+
+
+ My Project
+
+
+
+ Backend Work
+
+
+
+ UI Polish
+
+
+
+
+
+
+
My Project
+
+
+
+
+
Fix login bug
+
agent · Running
+
+
+
+
+
+
Add dark mode
+
manual
+
+
+
+
+
+
Setup CI pipeline
+
agent · Done
+
+
+
+
+
+
+
+
+
+
+
+Key Changes in the Proposed Design
+
+
+
1
+
+
Circular Checkboxes
+
Replace status badges with circular checkboxes on the left. Border color reflects status (orange=running, green=done, gray=manual). Click to toggle done.
+
+
+
+
2
+
+
Inline "Add a task" Input
+
Dashed border text field pinned at the bottom of the task list. Always visible. Enter to create, Escape to cancel.
+
+
+
+
3
+
+
List Name as Tasks Header
+
Replace generic "Tasks" header with the selected list name in larger text. Matches To Do's pattern.
+
+
+
+
4
+
+
Sidebar Polish
+
Colored dots per list, subtle highlight on selected, "+ New List" link at bottom instead of +/E/- buttons.
+
+
+
+
5
+
+
Remove Button Toolbars
+
Eliminate the bottom button bars from both panes. All actions via context menu, keyboard shortcuts, or inline controls.
+
+
+
+
6
+
+
Completed Task Styling
+
Done tasks get strikethrough text, reduced opacity, green checkmark. Keeps them visible but visually subordinate.
+
+
+
+This is multi-select — pick all the changes you'd like to include. I recommend all 6.
diff --git a/.superpowers/brainstorm/1955-1776152447/content/task-creation-flow.html b/.superpowers/brainstorm/1955-1776152447/content/task-creation-flow.html
new file mode 100644
index 0000000..b26220e
--- /dev/null
+++ b/.superpowers/brainstorm/1955-1776152447/content/task-creation-flow.html
@@ -0,0 +1,61 @@
+Task Creation: How should adding tasks work?
+Microsoft To Do uses an inline text field at the bottom of the task list. Currently ClaudeDo opens a modal dialog. Which approach fits best?
+
+
+
+
A
+
+
Inline Add (To Do style)
+
A text field always visible at the bottom of the task list. Press Enter to create a quick task with just a title. Tab or click to expand for more fields (description, tags, status).
+
+
Pros
+ Fastest for rapid task entry
+ Keyboard-driven — never leave the list
+ Feels natural and lightweight
+
+
Cons
+ Limited space for advanced fields
+ Need separate flow for setting tags/status on creation
+
+
+
+
+
+
+
B
+
+
Inline Add + Detail Pane Editing
+
Same inline text field for quick creation. After pressing Enter, the new task is selected and the detail pane on the right becomes editable — add description, tags, commit type there. Like To Do's "click task → edit in sidebar" pattern.
+
+
Pros
+ Quick entry AND full editing without modals
+ Uses existing detail pane real estate
+ Closest to Microsoft To Do's actual flow
+
+
Cons
+ Detail pane needs to become editable (currently read-only)
+ More complex state management
+
+
+
+
+
+
+
C
+
+
Keep Modal Dialog + Keyboard Shortcut
+
Keep the existing modal editor but add Ctrl+N / Enter shortcut to open it instantly. Add keyboard navigation within the dialog (Tab between fields, Enter to save).
+
+
Pros
+ Minimal code changes
+ All fields visible at once
+ Modal keeps focus clear
+
+
Cons
+ Still interrupts flow with a window
+ Feels heavier than To Do
+
+
+
+
+
diff --git a/.superpowers/brainstorm/1955-1776152447/content/waiting.html b/.superpowers/brainstorm/1955-1776152447/content/waiting.html
new file mode 100644
index 0000000..ef07652
--- /dev/null
+++ b/.superpowers/brainstorm/1955-1776152447/content/waiting.html
@@ -0,0 +1,3 @@
+
+
Continuing in terminal...
+
diff --git a/.superpowers/brainstorm/1955-1776152447/state/server-stopped b/.superpowers/brainstorm/1955-1776152447/state/server-stopped
new file mode 100644
index 0000000..00b0c44
--- /dev/null
+++ b/.superpowers/brainstorm/1955-1776152447/state/server-stopped
@@ -0,0 +1 @@
+{"reason":"idle timeout","timestamp":1776154800894}
diff --git a/.superpowers/brainstorm/1955-1776152447/state/server.pid b/.superpowers/brainstorm/1955-1776152447/state/server.pid
new file mode 100644
index 0000000..141d29f
--- /dev/null
+++ b/.superpowers/brainstorm/1955-1776152447/state/server.pid
@@ -0,0 +1 @@
+1955
diff --git a/.superpowers/brainstorm/3761-1776156321/content/island-colors.html b/.superpowers/brainstorm/3761-1776156321/content/island-colors.html
new file mode 100644
index 0000000..c8dec88
--- /dev/null
+++ b/.superpowers/brainstorm/3761-1776156321/content/island-colors.html
@@ -0,0 +1,79 @@
+Island-Farben: weniger grün
+Gleiche Struktur, neutralere Grautöne mit nur einem Hauch Grün
+
+
+
+
+
+
Neutral Slate
+
Base: #1b1e23 · Islands: #252a30 Fast kein Grün — kühl, neutral, wie VS Code Dark+
+
+
+
+
+
+
+
Warm Charcoal
+
Base: #1c1e21 · Islands: #272a2e Minimal warm, komplett neutral. Wie Rider's New UI Dark.
+
+
+
+
+
+
+
Subtle Tint
+
Base: #1b1f22 · Islands: #262b2d Ganz leichter kühler Ton — zwischen den anderen beiden
+
+
+
diff --git a/.superpowers/brainstorm/3761-1776156321/content/island-layout.html b/.superpowers/brainstorm/3761-1776156321/content/island-layout.html
new file mode 100644
index 0000000..c7ba7fe
--- /dev/null
+++ b/.superpowers/brainstorm/3761-1776156321/content/island-layout.html
@@ -0,0 +1,77 @@
+Island Layout (Rider-Style)
+Dark greenish-gray base, rounded card panels floating on top
+
+
+
+
+
+
+
+
Lists
+
My Project
+
Backend
+
+
+
+
My Project
+
Fix login bug
+
+
+
+
+
+
+
+
+
+
+
+
+
Lists
+
My Project
+
Backend
+
+
+
My Project
+
+
+
+
Fix login bug
+
agent · manual
+
+
+
+
+
+
Fix login bug
+
Status
+
Manual
+
Tags
+
+ agent
+
+
+
+
+
+
+
+Die Änderungen
+
+ Window-Background: #1a2420 — dunkles Grau mit Grünstich
+ Island-Background: #222d29 — etwas heller, ebenfalls grünlich
+ Border-Radius: 12px auf allen drei Spalten
+ Gap: 8px zwischen den Islands (GridSplitter entfernen, Margin nutzen)
+ Padding: 8px um das gesamte Grid (Window-Rand)
+ GridSplitter weg — die Islands haben feste Abstände, Resizing via Window-Größe
+
diff --git a/.superpowers/brainstorm/3761-1776156321/state/server-stopped b/.superpowers/brainstorm/3761-1776156321/state/server-stopped
new file mode 100644
index 0000000..429ce2f
--- /dev/null
+++ b/.superpowers/brainstorm/3761-1776156321/state/server-stopped
@@ -0,0 +1 @@
+{"reason":"idle timeout","timestamp":1776158304159}
diff --git a/.superpowers/brainstorm/3761-1776156321/state/server.pid b/.superpowers/brainstorm/3761-1776156321/state/server.pid
new file mode 100644
index 0000000..1a4b4d0
--- /dev/null
+++ b/.superpowers/brainstorm/3761-1776156321/state/server.pid
@@ -0,0 +1 @@
+3761
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..1e8a0c4
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,54 @@
+# ClaudeDo
+
+A desktop task management app that executes tasks autonomously via Claude CLI in isolated git worktrees.
+
+## Architecture
+
+Two-process system communicating over SignalR (`127.0.0.1:47821`):
+
+- **ClaudeDo.App** — Avalonia desktop entry point, DI container setup
+- **ClaudeDo.Ui** — Views, ViewModels, SignalR client (MVVM with CommunityToolkit.Mvvm)
+- **ClaudeDo.Data** — SQLite data layer, repositories, models, GitService
+- **ClaudeDo.Worker** — ASP.NET Core hosted service, task queue, Claude CLI runner
+- **ClaudeDo.Worker.Tests** — xUnit integration tests with real SQLite and real git
+
+## Tech Stack
+
+- .NET 8.0, Avalonia 12.0.0 (Fluent theme)
+- SQLite (WAL mode) via Microsoft.Data.Sqlite — raw ADO.NET, no ORM
+- SignalR for real-time IPC
+- CommunityToolkit.Mvvm (`[ObservableProperty]`, `[RelayCommand]`)
+- Git worktrees for task isolation
+
+## Key Paths
+
+- DB: `~/.todo-app/todo.db`
+- UI config: `~/.todo-app/ui.config.json`
+- Worker config: `~/.todo-app/worker.config.json`
+- Logs: `~/.todo-app/logs/`
+- Worktrees: configured per worker (sibling or central strategy)
+- Schema: `schema/schema.sql` (embedded resource in ClaudeDo.Data)
+
+## Conventions
+
+- Repository pattern — each entity has its own async repository
+- All data operations are async with CancellationToken support
+- Task status flow: Manual | Queued -> Running -> Done | Failed
+- Worktree state flow: Active -> Merged | Discarded | Kept
+- Tags "agent" and "manual" are seeded; "agent" tag marks tasks for automated queue pickup
+- Commit messages use conventional format: `{commitType}(slug): title`
+- Views use compiled bindings (`x:DataType`)
+- ViewModels use `[ObservableProperty]` and `[RelayCommand]` source generators
+
+## Building & Testing
+
+```bash
+dotnet build ClaudeDo.slnx
+dotnet test tests/ClaudeDo.Worker.Tests
+```
+
+## Docs
+
+- `docs/plan.md` — full architecture and design spec
+- `docs/open.md` — verification checklist and improvement backlog
+- `docs/improvement-plan.md` — prioritized improvement items
diff --git a/docs/improvement-plan.md b/docs/improvement-plan.md
new file mode 100644
index 0000000..261e8f3
--- /dev/null
+++ b/docs/improvement-plan.md
@@ -0,0 +1,92 @@
+# ClaudeDo — Improvement Plan (Session 2026-04-13)
+
+Erfasst während manuellem Walkthrough der App. Priorisiert nach Schmerz/Aufwand.
+
+---
+
+## P1 — UX-Blocker (sollten zuerst)
+
+### IP-1: UI ↔ Worker Auto-Reconnect
+**Symptom:** Wenn UI vor Worker startet, bleibt die Verbindung tot. Manueller UI-Restart nötig.
+**Soll:** SignalR-Client mit `WithAutomaticReconnect()` + Reconnect-Versuche im Hintergrund (exponential backoff). Status-Bar zeigt "verbinde…" während Retry.
+**Dateien:** `src/ClaudeDo.Ui/Services/WorkerClient.cs` (oder wo `HubConnection` gebaut wird)
+**Aufwand:** klein (~30 Zeilen, primär `HubConnectionBuilder`-Konfig + Reconnect-Handler)
+**Risiko:** klein
+
+### IP-2: Listen-Modus „Notes" (non-autonomous)
+**Symptom:** Jede Liste ist Agent-gesteuert. Keine reine Notiz-Liste möglich.
+**Soll:** Neues Feld `lists.kind` (`agent` | `notes`).
+- `agent`: aktuelles Verhalten (Worker pickt Tasks)
+- `notes`: Worker ignoriert die Liste komplett, UI versteckt Run-/Schedule-/Worktree-Felder, Tasks haben nur Title + Description + done-Checkbox.
+**Dateien:**
+- Schema: neue Spalte + Migration (siehe IP-9)
+- `Data/Entities/TaskList.cs`, `Repositories/ListRepository.cs`
+- `Worker/Queue/QueueService.cs` (Filter `WHERE list.kind = 'agent'`)
+- UI: `ListEditorView` (Radio/ComboBox), `TaskListView` (conditional Columns), `TaskDetailView` (verstecken)
+**Aufwand:** mittel (~Schema + Repo + UI an mehreren Stellen)
+**Risiko:** mittel — bestehende Listen müssen Default `agent` bekommen
+
+### IP-3: Doppelklick öffnet Edit-Dialog
+**Symptom:** Edit nur über separaten Button/Menüpunkt.
+**Soll:** `DoubleTapped`-Handler auf ListBox-Items (Listen-Pane) und auf TaskRows (Task-Pane) → öffnet jeweiligen Editor.
+**Dateien:** `Views/MainWindow.axaml(.cs)`, `Views/TaskListView.axaml(.cs)`
+**Aufwand:** klein (~10–15 Zeilen pro Stelle)
+**Risiko:** klein
+
+### IP-4: Tag-Multi-Select statt Freitext
+**Symptom:** Tags müssen getippt werden, keine Auto-Vervollständigung, Typos möglich.
+**Soll:** Multi-Select-Control:
+- Zeigt alle in DB existierenden Tags (DISTINCT aus `lists.tags` ∪ `tasks.tags`)
+- Erlaubt Anlegen neuer Tags (Free-Text-Add)
+- Chip/Token-Darstellung der ausgewählten Tags
+**Dateien:**
+- *neu* `Views/Controls/TagPicker.axaml` (wiederverwendbar)
+- `ListEditorView`, `TaskEditorView` einbinden
+- Repo-Methode `GetAllKnownTagsAsync()`
+**Aufwand:** mittel (Custom-Control lohnt sich, da 2× verwendet)
+**Risiko:** klein
+
+### IP-5: Rechtsklick-Kontextmenü
+**Symptom:** Quick-Actions nur über Buttons im Detail-Pane oder Toolbar.
+**Soll:**
+- **Liste:** Edit, Delete, New Task, ggf. „Mark all done" (für Notes-Listen aus IP-2)
+- **Task:** Edit, Delete, Run Now, Show Diff, Merge, Cancel (je nach Status)
+- Items kontext-sensitiv enabled/disabled je nach Task-Status & List-Kind
+**Dateien:** `Views/MainWindow.axaml` (List-Pane), `Views/TaskListView.axaml` (Task-Pane)
+**Aufwand:** klein–mittel — Avalonia `ContextMenu` + Command-Bindings
+**Risiko:** klein
+
+---
+
+## P2 — Folge-Arbeiten (durch P1 ausgelöst)
+
+### IP-6: Schema-Migration-Mechanismus
+**Trigger:** IP-2 fügt eine Spalte zu `lists` hinzu. Aktuell `schema.sql` ist Drop-and-Create-Style.
+**Soll:** Mini-Migrations-System: `migrations/0001_initial.sql`, `0002_lists_kind.sql`, … + `_schema_version` Tabelle.
+**Aufwand:** klein–mittel
+**Querverweis:** `open.md` Sektion 7 (Schulden-Tabelle: „Embedded schema.sql ohne Versionierung")
+
+### IP-7: Status-Bar zeigt Reconnect-State
+**Trigger:** IP-1 — User soll sehen, dass Verbindung gerade aufgebaut wird (statt nur „offline").
+**Soll:** States: `connected` | `connecting` | `reconnecting` | `offline`. Farb-codiert.
+**Datei:** `ViewModels/StatusBarViewModel.cs`
+**Aufwand:** klein
+
+### IP-8: Tag-Repository für `GetAllKnownTagsAsync`
+**Trigger:** IP-4 braucht eine Quelle aller bekannten Tags.
+**Soll:** Methode in `ListRepository`/`TaskRepository` ODER neuer `TagRepository`. SQL: `SELECT DISTINCT trim(value) FROM lists, json_each(lists.tags) UNION ...`.
+**Aufwand:** klein
+
+---
+
+## Empfohlene Reihenfolge
+
+1. **IP-1** (Auto-Reconnect) — sofortiger UX-Win, isoliert, klein
+2. **IP-3** (Doppelklick) — trivial, sofort spürbar
+3. **IP-5** (Kontextmenü) — kompakt, hebt Bedienkomfort deutlich
+4. **IP-6** (Migrations) — Voraussetzung für IP-2
+5. **IP-2** (Notes-Mode) — größerer Brocken, braucht Schema-Migration
+6. **IP-8 → IP-4** (Tag-Repo, dann Multi-Select-Control)
+7. **IP-7** (Reconnect-Status in StatusBar) — Polish nach IP-1
+
+Block 1 (IP-1, IP-3, IP-5) ist ein realistischer Session-Block.
diff --git a/docs/open.md b/docs/open.md
new file mode 100644
index 0000000..06cb836
--- /dev/null
+++ b/docs/open.md
@@ -0,0 +1,193 @@
+# ClaudeDo — Offene Punkte
+
+Stand: 2026-04-13 nach Slice F. Branch `main` @ `48e4aab`. Alle Tests grün (38/38), Build 0 Warnings.
+
+Dieses Dokument listet alles, was noch fehlt — gruppiert nach Aufwand/Risiko und mit konkreten Datei-Pointern, damit wir es in der IDE der Reihe nach durchgehen können.
+
+---
+
+## 1. Verification (vor allem anderen)
+
+Die in `plan.md` definierten Verification-Steps sind teilweise nur durch Build/Tests abgedeckt. Diese sollten manuell einmal durchlaufen werden, BEVOR wir Polish bauen — damit wir wissen, was tatsächlich kaputt ist.
+
+| # | Plan | Status | Was tun |
+|---|------|--------|---------|
+| 1 | Schema-Init | Auto verifiziert (Worker startet ohne Crash, WAL-Files entstehen) | OK |
+| 1a | SignalR-Endpoint | Manuell verifiziert (HTTP 400 auf `/hub` ohne Handshake) | OK |
+| 1b | Hub-Roundtrip `Ping` | **Nicht getestet** | Test-Client schreiben oder UI starten und im Log nach "pong" schauen |
+| 2 | `claude --version` Preflight | **Nicht implementiert** | `Worker/Program.cs`: vor `app.Run()` einmal `claude --version` shellen und bei Exit≠0 abbrechen |
+| 3 | Smoke-Spawn (`claude -p` mit Prompt "ping") | **Nicht getestet** | Integrationstest schreiben oder einmal manuell laufen lassen |
+| 4 | E2E Happy Path (Non-Worktree) | **Nicht getestet** | UI starten → Liste "Test" anlegen → Task mit Tag `agent` + Status `queued` + Description "Schreibe ein Haiku über Intralogistik" → Run abwarten → Result prüfen |
+| 5 | Worktree Happy Path | **Nicht getestet** | Manueller Test mit echtem Repo (z.B. einem temp-Repo) |
+| 6 | No-Changes-Run | **Nicht getestet** | Prompt der nichts ändert → `head_commit` bleibt NULL |
+| 7 | Kein Git-Repo | **Nicht getestet** | working_dir auf `C:\Temp` → Task `failed`, keine `worktrees`-Row |
+| 8 | Merge-UI | **Nicht getestet** (UI ruft `GitService.MergeFfOnlyAsync`, aber nie ausgeführt) | Manuell |
+| 9 | Override-Parallelität | Tests vorhanden für Slot-Logik, **End-to-End nicht** | UI: zwei Tasks queuen, `Run Now` auf der zweiten → beide laufen parallel |
+| 10 | Schedule | Logik per Test abgedeckt, **End-to-End nicht** | Task mit `scheduled_for = now+2min` |
+| 11 | Worker-Offline-Erkennung | UI hat Status-Bar, aber **nicht visuell verifiziert** | Worker killen, schauen ob Status auf "offline" wechselt |
+| 12 | Live-Stream | **Nicht getestet** | Während Run TaskDetail öffnen, beobachten ob ndjson-Zeilen erscheinen |
+| 13 | Wake-up (UI ruft `WakeQueue` nach Anlage) | Implementiert in `TaskListViewModel`, **nicht visuell verifiziert** | Tasks nach Anlage in <1s gepickert |
+
+**Vorschlag:** Wir machen einmal Step 4 (Haiku-Happy-Path) gemeinsam — wenn das läuft, ist die ganze Pipeline lebendig.
+
+---
+
+## 2. UI-Polish (kritisch für Benutzbarkeit)
+
+Im aktuellen Stand kompiliert die UI, aber mehrere Stellen sind als `// TODO` markiert. Reihenfolge nach Schmerz:
+
+### 2.1 Folder-Picker für `Working Directory`
+- **Datei:** `src/ClaudeDo.Ui/Views/ListEditorView.axaml` + `src/ClaudeDo.Ui/ViewModels/ListEditorViewModel.cs`
+- **Aktuell:** plain `TextBox` — Pfad muss getippt werden.
+- **Soll:** Button "…" daneben → öffnet `IStorageProvider.OpenFolderPickerAsync`, schreibt Pfad ins Feld.
+- **Aufwand:** klein, ~30 Zeilen.
+
+### 2.2 Delete-Confirmation
+- **Dateien:** `MainWindowViewModel.DeleteList`, `TaskListViewModel.DeleteTask`
+- **Aktuell:** löscht direkt ohne Rückfrage. Datenverlust-Risiko.
+- **Soll:** Mini-Dialog "Wirklich löschen?" mit Ja/Nein.
+- **Aufwand:** klein, generisches `ConfirmDialog` lohnt sich (1× bauen, mehrfach nutzen).
+
+### 2.3 Markdown-Rendering für Result + Description
+- **Datei:** `src/ClaudeDo.Ui/Views/TaskDetailView.axaml`
+- **Aktuell:** `TextBox IsReadOnly="True"` mit Plaintext.
+- **Soll:** `Markdown.Avalonia` Package einbinden und auf `MarkdownScrollViewer` umstellen.
+- **Aufwand:** mittel — Package + ein paar XAML-Anpassungen. Theme-Integration kann nerven.
+
+### 2.4 Live-Log Auto-Scroll
+- **Datei:** `src/ClaudeDo.Ui/Views/TaskDetailView.axaml.cs` (oder im VM)
+- **Aktuell:** ndjson-Zeilen werden angehängt, aber Scrollposition bleibt stehen.
+- **Soll:** Bei jeder neuen Zeile `ScrollViewer.ScrollToEnd()` solange User nicht manuell hochgescrollt hat (Sticky-Bottom-Pattern).
+- **Aufwand:** klein, ein attached behavior reicht.
+
+### 2.5 Diff-Viewer
+- **Datei:** `TaskDetailViewModel.ShowDiffAsync`
+- **Aktuell:** `Process.Start("cmd", "/k git diff …")` — separates Konsolenfenster, hässlich.
+- **Soll:** entweder unified-diff inline anzeigen (`git diff` Output in `TextBox` mit Mono-Font + Color für +/-) oder einen externen Diff-Tool-Hook (`git difftool`).
+- **Aufwand:** mittel. MVP: einfach nur den Diff-Output in einem Modal.
+
+### 2.6 Status-Bar Active-Tasks Live-Update
+- **Datei:** `StatusBarViewModel`
+- **Risiko:** das Slot-State-Update kommt vom WorkerClient, aber `RunNowCommand.NotifyCanExecuteChanged` triggert nicht pro Item bei `IsConnected`-Wechsel (vom Slice-F-Agent dokumentiert).
+- **Soll:** Über `WeakReferenceMessenger` (CommunityToolkit.Mvvm) eine Connection-Change-Message verteilen, an die alle `TaskItemViewModel` lauschen.
+- **Aufwand:** klein, aber muss sauber gemacht werden.
+
+### 2.7 Settings-Dialog
+- **Datei:** *neu* — `Views/SettingsDialog.axaml` + VM
+- **Aktuell:** `~/.todo-app/ui.config.json` muss von Hand editiert werden.
+- **Soll:** Dialog mit Feldern: DB-Pfad, SignalR-Port, Default-Tags. Persistiert zurück in JSON.
+- **Aufwand:** mittel. Achtung: Port-Wechsel braucht Worker-Restart.
+
+---
+
+## 3. Worker-Robustheit
+
+### 3.1 CLI-Preflight beim Worker-Start
+- **Datei:** `src/ClaudeDo.Worker/Program.cs`
+- **Soll:** vor `app.Run()` `claude --version` ausführen; bei Fehler `app.Logger.LogCritical` + `Environment.Exit(1)`.
+- **Aufwand:** klein, ~20 Zeilen. Liefert Verification Step 2.
+
+### 3.2 Worktree-Cleanup beim Anlege-Failed
+- **Datei:** `src/ClaudeDo.Worker/Runner/WorktreeManager.cs`
+- **Aktuell:** Wenn `WorktreeAddAsync` zwischen `CreateAsync`-Schritten failt (z.B. Branch existiert schon), bleibt evtl. ein halbangelegter Worktree-Dir auf der Platte.
+- **Soll:** try/finally — bei Fehler `git worktree remove --force` als Best-Effort-Cleanup.
+- **Aufwand:** klein.
+
+### 3.3 Logging über `Microsoft.Extensions.Logging` strukturieren
+- **Datei:** alle Worker-Komponenten
+- **Aktuell:** ILogger wird benutzt, aber kein File-Sink konfiguriert.
+- **Soll:** Optional Serilog oder einfach `AddFile` (Karambolage.Extensions.Logging.File) — Service-Modus braucht persistente Logs außerhalb der Console.
+- **Aufwand:** klein.
+
+### 3.4 Tag-Negation / Exclusion (Plan-TODO)
+- **Plan-Sektion:** "Tag-Modell"
+- **Aktuell:** Tags sind rein additiv (`list_tags ∪ task_tags`).
+- **Soll:** Mechanismus, um auf Task-Ebene einen List-Tag auszuschließen. Z.B. neue Tabelle `task_tag_exclusions` ODER ein Prefix `!tag` im task_tags-Eintrag.
+- **Aufwand:** mittel — Schema + Repo + Tests + UI.
+
+---
+
+## 4. Service-Deployment (Plan-Sektion „Worker als Windows-Service")
+
+### 4.1 Windows-Service-Hosting in Code
+- **Datei:** `src/ClaudeDo.Worker/Program.cs`
+- **Pakete:** `Microsoft.Extensions.Hosting.WindowsServices`
+- **Soll:**
+ ```csharp
+ builder.Host.UseWindowsService(o => o.ServiceName = "ClaudeDoWorker");
+ builder.Logging.AddEventLog(...);
+ ```
+- **Aufwand:** klein.
+
+### 4.2 Pfad-Auflösung absolut machen
+- Bereits in `WorkerConfig.Load` per `Paths.Expand` gemacht — verifizieren, dass auch `cfg.ClaudeBin` ggf. in Service-PATH gefunden wird.
+
+### 4.3 Install-Skripte / Doku
+- **Datei:** *neu* — `docs/install-service.md` oder `scripts/install-service.cmd`
+- **Inhalt:** `dotnet publish` + `sc.exe create` + `sc.exe failure` + Hinweis auf `obj=` (User-Account) wegen Claude-CLI-Session.
+- **Aufwand:** klein.
+
+### 4.4 (später) Installer-Projekt
+- WiX/MSIX, registriert Service + UI-Shortcut. Plan-Sektion „Offene Punkte".
+
+---
+
+## 5. Tests / CI
+
+### 5.1 GitHub-Actions / Gitea-Actions Pipeline
+- **Datei:** *neu* — `.gitea/workflows/ci.yml` (oder `.github/workflows/ci.yml`)
+- **Inhalt:** `dotnet restore` → `dotnet build --no-restore` → `dotnet test --no-build`. Auf Push + PR.
+- **Aufwand:** klein.
+
+### 5.2 Echter SignalR-Roundtrip-Test
+- **Datei:** *neu* — `tests/ClaudeDo.Worker.Tests/Hub/WorkerHubTests.cs`
+- **Soll:** mit `WebApplicationFactory` + `HubConnectionBuilder` testen, dass `Ping`, `GetActive`, `RunNow`-Throw-Verhalten korrekt sind. Plan-Verification 1b + 9.
+- **Aufwand:** mittel.
+
+### 5.3 Smoke-Test gegen echten `claude`
+- **Datei:** *neu* — `tests/ClaudeDo.Worker.Tests/Runner/ClaudeProcessSmokeTest.cs`
+- **Soll:** Real-CLI-Test, der mit `[Fact(Skip="..."]` ausgegraut bleibt und nur lokal aktiviert wird, wenn `CLAUDE_AUTHENTICATED=1` Env-Var gesetzt ist.
+- **Aufwand:** klein.
+
+---
+
+## 6. Dokumentation
+
+### 6.1 README.md
+- Komplett fehlt. Mind. 1× kurz: was ist es, wie starten (Worker + UI), wo Config.
+- **Aufwand:** klein.
+
+### 6.2 `docs/architecture.md`
+- In `plan.md` schon teilweise enthalten — kann entweder konsolidiert oder explizit ausgegliedert werden.
+
+### 6.3 ADRs für die getroffenen Entscheidungen
+- Z.B. „SignalR vs. SQLite-Polling für IPC", „Worktree pro Task", „SignalR über Loopback ohne Auth".
+- **Aufwand:** klein, hilfreich für später.
+
+---
+
+## 7. Bekannte Code-Schulden / Smells
+
+| Stelle | Issue |
+|---|---|
+| `WorkerHub.GetActive` returnt `IReadOnlyList` mit anonymen Typen | Sollte ein expliziter DTO sein (`ActiveTaskDto`), den Worker UND Ui teilen. Aktuell duplizieren beide das Schema. |
+| `TaskRunner` führt eine `if (list.WorkingDir != null)` Verzweigung mitten in der Methode | Strategy-Pattern (`IRunStrategy`: SandboxStrategy, WorktreeStrategy) wenn die Methode wächst. Aktuell noch klein genug. |
+| `App.Services` als public static `ServiceProvider` | Service-Locator-Antipattern. Toleriert, weil nur in `App.OnFrameworkInitializationCompleted` verwendet. Falls mehr Code drauf zugreift → echtes DI durchziehen. |
+| Embedded `schema.sql` ohne Versionierung | Solange das Schema nicht in Production läuft, OK. Sobald User-Daten existieren → `migrations/` Folder + Version-Tabelle. |
+| CRLF-Warnings beim Commit | `.gitattributes` mit `* text=auto eol=lf` (oder explizit pro Sprache) wäre sauberer. |
+
+---
+
+## Empfohlene Reihenfolge für die nächste Session
+
+1. **Verification Step 4** zusammen durchspielen → falls etwas grundlegend kaputt ist, jetzt finden, nicht später.
+2. **CLI-Preflight (3.1)** + **Folder-Picker (2.1)** + **Delete-Confirm (2.2)** — kleine, isolierte Wins.
+3. **Auto-Scroll (2.4)** + **Active-Tasks Live-Update (2.6)** — User-Experience im Detail-Pane.
+4. **Markdown-Rendering (2.3)** — größer, lohnt sich aber für Lesbarkeit.
+5. **Worktree-Cleanup (3.2)** — Robustheit, bevor wir Worktrees ernsthaft nutzen.
+6. **CI-Pipeline (5.1)** — automatisches Sicherheitsnetz für alles weitere.
+7. **Service-Deployment (4)** — wenn die App lokal stabil läuft.
+8. **Settings-Dialog (2.7)** + **Diff-Viewer (2.5)** — Polish.
+9. **Tag-Negation (3.4)** — wenn der Bedarf konkret wird.
+
+Punkte 1–3 sind ein realistischer Block für eine Session.
diff --git a/docs/superpowers/plans/2026-04-14-worker-cli-modernization.md b/docs/superpowers/plans/2026-04-14-worker-cli-modernization.md
new file mode 100644
index 0000000..5e07e84
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-14-worker-cli-modernization.md
@@ -0,0 +1,2311 @@
+# Worker CLI Modernization Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Modernize the Worker's Claude CLI integration with per-list/task configuration, execution history tracking, multi-turn support, auto-retry, structured output, and agent file management.
+
+**Architecture:** Add `list_config` and `task_runs` tables to the existing SQLite schema. Extract CLI argument building and stream parsing into dedicated classes. TaskRunner gains retry/continue logic. Agent `.md` files live on the filesystem at `~/.todo-app/agents/`, discoverable via SignalR.
+
+**Tech Stack:** .NET 8.0, SQLite (raw ADO.NET), SignalR, xUnit
+
+---
+
+### Task 1: Schema — Add list_config and task_runs tables
+
+**Files:**
+- Modify: `schema/schema.sql:49-62`
+- Modify: `src/ClaudeDo.Data/SchemaInitializer.cs`
+
+This task adds the new tables and columns. Since SQLite doesn't support `ALTER TABLE ADD COLUMN IF NOT EXISTS`, the new columns on `tasks` need a try/catch approach in SchemaInitializer.
+
+- [ ] **Step 1: Add list_config table to schema.sql**
+
+Append before the worktrees table:
+
+```sql
+CREATE TABLE IF NOT EXISTS list_config (
+ list_id TEXT PRIMARY KEY REFERENCES lists(id) ON DELETE CASCADE,
+ model TEXT NULL,
+ system_prompt TEXT NULL,
+ agent_path TEXT NULL
+);
+```
+
+- [ ] **Step 2: Add task_runs table to schema.sql**
+
+Append after worktrees table:
+
+```sql
+CREATE TABLE IF NOT EXISTS task_runs (
+ id TEXT PRIMARY KEY,
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
+ run_number INTEGER NOT NULL,
+ session_id TEXT NULL,
+ is_retry INTEGER NOT NULL DEFAULT 0,
+ prompt TEXT NOT NULL,
+ result_markdown TEXT NULL,
+ structured_output TEXT NULL,
+ error_markdown TEXT NULL,
+ exit_code INTEGER NULL,
+ turn_count INTEGER NULL,
+ tokens_in INTEGER NULL,
+ tokens_out INTEGER NULL,
+ log_path TEXT NULL,
+ started_at TIMESTAMP NULL,
+ finished_at TIMESTAMP NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_task_runs_task_id ON task_runs(task_id);
+```
+
+- [ ] **Step 3: Add migration columns to tasks via SchemaInitializer**
+
+In `SchemaInitializer.cs`, after applying the main schema, add a new `ApplyMigrations` method that runs `ALTER TABLE` in try/catch blocks (SQLite throws "duplicate column name" if the column already exists):
+
+```csharp
+private static void ApplyMigrations(SqliteConnection conn)
+{
+ string[] alterStatements =
+ [
+ "ALTER TABLE tasks ADD COLUMN model TEXT NULL",
+ "ALTER TABLE tasks ADD COLUMN system_prompt TEXT NULL",
+ "ALTER TABLE tasks ADD COLUMN agent_path TEXT NULL",
+ ];
+
+ foreach (var sql in alterStatements)
+ {
+ try
+ {
+ using var cmd = conn.CreateCommand();
+ cmd.CommandText = sql;
+ cmd.ExecuteNonQuery();
+ }
+ catch (Microsoft.Data.Sqlite.SqliteException ex) when (ex.SqliteErrorCode == 1)
+ {
+ // Column already exists — safe to ignore.
+ }
+ }
+}
+```
+
+Call `ApplyMigrations(conn)` right after the main schema `ExecuteNonQuery` in the `ApplyTo` method.
+
+- [ ] **Step 4: Verify schema applies cleanly**
+
+Run:
+```bash
+dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
+dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~Repository" -v minimal
+```
+
+Expected: all existing repository tests pass (schema changes are additive).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add schema/schema.sql src/ClaudeDo.Data/SchemaInitializer.cs
+git commit -m "feat(data): add list_config, task_runs tables and task config columns"
+```
+
+---
+
+### Task 2: Models — ListConfigEntity, TaskRunEntity, AgentInfo
+
+**Files:**
+- Create: `src/ClaudeDo.Data/Models/ListConfigEntity.cs`
+- Create: `src/ClaudeDo.Data/Models/TaskRunEntity.cs`
+- Create: `src/ClaudeDo.Data/Models/AgentInfo.cs`
+- Modify: `src/ClaudeDo.Data/Models/TaskEntity.cs:13-26`
+
+- [ ] **Step 1: Create ListConfigEntity**
+
+```csharp
+namespace ClaudeDo.Data.Models;
+
+public sealed class ListConfigEntity
+{
+ public required string ListId { get; init; }
+ public string? Model { get; set; }
+ public string? SystemPrompt { get; set; }
+ public string? AgentPath { get; set; }
+}
+```
+
+- [ ] **Step 2: Create TaskRunEntity**
+
+```csharp
+namespace ClaudeDo.Data.Models;
+
+public sealed class TaskRunEntity
+{
+ public required string Id { get; init; }
+ public required string TaskId { get; init; }
+ public required int RunNumber { get; init; }
+ public string? SessionId { get; set; }
+ public required bool IsRetry { get; init; }
+ public required string Prompt { get; init; }
+ public string? ResultMarkdown { get; set; }
+ public string? StructuredOutputJson { get; set; }
+ public string? ErrorMarkdown { get; set; }
+ public int? ExitCode { get; set; }
+ public int? TurnCount { get; set; }
+ public int? TokensIn { get; set; }
+ public int? TokensOut { get; set; }
+ public string? LogPath { get; set; }
+ public DateTime? StartedAt { get; set; }
+ public DateTime? FinishedAt { get; set; }
+}
+```
+
+- [ ] **Step 3: Create AgentInfo**
+
+```csharp
+namespace ClaudeDo.Data.Models;
+
+public sealed record AgentInfo(string Name, string Description, string Path);
+```
+
+- [ ] **Step 4: Add config fields to TaskEntity**
+
+Add after the `CommitType` property (line 25):
+
+```csharp
+public string? Model { get; set; }
+public string? SystemPrompt { get; set; }
+public string? AgentPath { get; set; }
+```
+
+- [ ] **Step 5: Build and verify**
+
+Run:
+```bash
+dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj
+```
+
+Expected: clean build, no errors.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/ClaudeDo.Data/Models/ListConfigEntity.cs src/ClaudeDo.Data/Models/TaskRunEntity.cs src/ClaudeDo.Data/Models/AgentInfo.cs src/ClaudeDo.Data/Models/TaskEntity.cs
+git commit -m "feat(data): add ListConfigEntity, TaskRunEntity, AgentInfo models and task config fields"
+```
+
+---
+
+### Task 3: TaskRunRepository — CRUD for execution history
+
+**Files:**
+- Create: `src/ClaudeDo.Data/Repositories/TaskRunRepository.cs`
+- Create: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRunRepositoryTests.cs`
+
+- [ ] **Step 1: Write failing tests**
+
+```csharp
+using ClaudeDo.Data.Models;
+using ClaudeDo.Data.Repositories;
+using ClaudeDo.Worker.Tests.Infrastructure;
+
+namespace ClaudeDo.Worker.Tests.Repositories;
+
+public sealed class TaskRunRepositoryTests : IDisposable
+{
+ private readonly DbFixture _db = new();
+ private readonly TaskRunRepository _repo;
+ private readonly TaskRepository _taskRepo;
+ private readonly ListRepository _listRepo;
+ private readonly string _listId;
+ private readonly string _taskId;
+
+ public TaskRunRepositoryTests()
+ {
+ _repo = new TaskRunRepository(_db.Factory);
+ _taskRepo = new TaskRepository(_db.Factory);
+ _listRepo = new ListRepository(_db.Factory);
+
+ _listId = Guid.NewGuid().ToString();
+ _listRepo.AddAsync(new ListEntity
+ {
+ Id = _listId, Name = "Test List", CreatedAt = DateTime.UtcNow
+ }).GetAwaiter().GetResult();
+
+ _taskId = Guid.NewGuid().ToString();
+ _taskRepo.AddAsync(new TaskEntity
+ {
+ Id = _taskId, ListId = _listId, Title = "Test Task",
+ Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow,
+ }).GetAwaiter().GetResult();
+ }
+
+ [Fact]
+ public async Task Add_And_GetById_Roundtrips()
+ {
+ var run = MakeRun(1);
+ await _repo.AddAsync(run);
+
+ var fetched = await _repo.GetByIdAsync(run.Id);
+ Assert.NotNull(fetched);
+ Assert.Equal(run.Id, fetched.Id);
+ Assert.Equal(run.TaskId, fetched.TaskId);
+ Assert.Equal(1, fetched.RunNumber);
+ Assert.False(fetched.IsRetry);
+ Assert.Equal("do the thing", fetched.Prompt);
+ }
+
+ [Fact]
+ public async Task GetByTaskId_Returns_Ordered_By_RunNumber()
+ {
+ await _repo.AddAsync(MakeRun(1));
+ await _repo.AddAsync(MakeRun(2, isRetry: true));
+ await _repo.AddAsync(MakeRun(3));
+
+ var runs = await _repo.GetByTaskIdAsync(_taskId);
+ Assert.Equal(3, runs.Count);
+ Assert.Equal(1, runs[0].RunNumber);
+ Assert.Equal(2, runs[1].RunNumber);
+ Assert.Equal(3, runs[2].RunNumber);
+ Assert.True(runs[1].IsRetry);
+ }
+
+ [Fact]
+ public async Task GetLatestByTaskId_Returns_Highest_RunNumber()
+ {
+ await _repo.AddAsync(MakeRun(1));
+ var run2 = MakeRun(2);
+ run2.SessionId = "sess-abc";
+ await _repo.AddAsync(run2);
+
+ var latest = await _repo.GetLatestByTaskIdAsync(_taskId);
+ Assert.NotNull(latest);
+ Assert.Equal(2, latest.RunNumber);
+ Assert.Equal("sess-abc", latest.SessionId);
+ }
+
+ [Fact]
+ public async Task Update_Persists_Completion_Fields()
+ {
+ var run = MakeRun(1);
+ await _repo.AddAsync(run);
+
+ run.SessionId = "sess-123";
+ run.ResultMarkdown = "## Done";
+ run.StructuredOutputJson = """{"summary":"all good"}""";
+ run.ExitCode = 0;
+ run.TurnCount = 5;
+ run.TokensIn = 1200;
+ run.TokensOut = 800;
+ run.FinishedAt = DateTime.UtcNow;
+ await _repo.UpdateAsync(run);
+
+ var fetched = await _repo.GetByIdAsync(run.Id);
+ Assert.NotNull(fetched);
+ Assert.Equal("sess-123", fetched.SessionId);
+ Assert.Equal("## Done", fetched.ResultMarkdown);
+ Assert.Equal("""{"summary":"all good"}""", fetched.StructuredOutputJson);
+ Assert.Equal(0, fetched.ExitCode);
+ Assert.Equal(5, fetched.TurnCount);
+ Assert.Equal(1200, fetched.TokensIn);
+ Assert.Equal(800, fetched.TokensOut);
+ }
+
+ [Fact]
+ public async Task GetLatestByTaskId_Returns_Null_When_No_Runs()
+ {
+ var latest = await _repo.GetLatestByTaskIdAsync(_taskId);
+ Assert.Null(latest);
+ }
+
+ private TaskRunEntity MakeRun(int runNumber, bool isRetry = false) => new()
+ {
+ Id = Guid.NewGuid().ToString(),
+ TaskId = _taskId,
+ RunNumber = runNumber,
+ IsRetry = isRetry,
+ Prompt = "do the thing",
+ StartedAt = DateTime.UtcNow,
+ };
+
+ public void Dispose() => _db.Dispose();
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run:
+```bash
+dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRunRepository" -v minimal
+```
+
+Expected: build error — `TaskRunRepository` does not exist.
+
+- [ ] **Step 3: Implement TaskRunRepository**
+
+```csharp
+using ClaudeDo.Data.Models;
+using Microsoft.Data.Sqlite;
+
+namespace ClaudeDo.Data.Repositories;
+
+public sealed class TaskRunRepository
+{
+ private readonly SqliteConnectionFactory _factory;
+
+ public TaskRunRepository(SqliteConnectionFactory factory) => _factory = factory;
+
+ public async Task AddAsync(TaskRunEntity entity, CancellationToken ct = default)
+ {
+ await using var conn = _factory.Open();
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = """
+ INSERT INTO task_runs (id, task_id, run_number, session_id, is_retry, prompt,
+ result_markdown, structured_output, error_markdown,
+ exit_code, turn_count, tokens_in, tokens_out,
+ log_path, started_at, finished_at)
+ VALUES (@id, @task_id, @run_number, @session_id, @is_retry, @prompt,
+ @result_markdown, @structured_output, @error_markdown,
+ @exit_code, @turn_count, @tokens_in, @tokens_out,
+ @log_path, @started_at, @finished_at)
+ """;
+ BindRun(cmd, entity);
+ await cmd.ExecuteNonQueryAsync(ct);
+ }
+
+ public async Task UpdateAsync(TaskRunEntity entity, CancellationToken ct = default)
+ {
+ await using var conn = _factory.Open();
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = """
+ UPDATE task_runs SET session_id = @session_id, result_markdown = @result_markdown,
+ structured_output = @structured_output, error_markdown = @error_markdown,
+ exit_code = @exit_code, turn_count = @turn_count,
+ tokens_in = @tokens_in, tokens_out = @tokens_out,
+ log_path = @log_path, finished_at = @finished_at
+ WHERE id = @id
+ """;
+ cmd.Parameters.AddWithValue("@id", entity.Id);
+ cmd.Parameters.AddWithValue("@session_id", (object?)entity.SessionId ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@result_markdown", (object?)entity.ResultMarkdown ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@structured_output", (object?)entity.StructuredOutputJson ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@error_markdown", (object?)entity.ErrorMarkdown ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@exit_code", entity.ExitCode.HasValue ? entity.ExitCode.Value : DBNull.Value);
+ cmd.Parameters.AddWithValue("@turn_count", entity.TurnCount.HasValue ? entity.TurnCount.Value : DBNull.Value);
+ cmd.Parameters.AddWithValue("@tokens_in", entity.TokensIn.HasValue ? entity.TokensIn.Value : DBNull.Value);
+ cmd.Parameters.AddWithValue("@tokens_out", entity.TokensOut.HasValue ? entity.TokensOut.Value : DBNull.Value);
+ cmd.Parameters.AddWithValue("@log_path", (object?)entity.LogPath ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@finished_at", entity.FinishedAt.HasValue ? entity.FinishedAt.Value.ToString("o") : DBNull.Value);
+ await cmd.ExecuteNonQueryAsync(ct);
+ }
+
+ public async Task GetByIdAsync(string runId, CancellationToken ct = default)
+ {
+ await using var conn = _factory.Open();
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = "SELECT id, task_id, run_number, session_id, is_retry, prompt, result_markdown, structured_output, error_markdown, exit_code, turn_count, tokens_in, tokens_out, log_path, started_at, finished_at FROM task_runs WHERE id = @id";
+ cmd.Parameters.AddWithValue("@id", runId);
+
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+ if (!await reader.ReadAsync(ct)) return null;
+ return ReadRun(reader);
+ }
+
+ public async Task> GetByTaskIdAsync(string taskId, CancellationToken ct = default)
+ {
+ await using var conn = _factory.Open();
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = "SELECT id, task_id, run_number, session_id, is_retry, prompt, result_markdown, structured_output, error_markdown, exit_code, turn_count, tokens_in, tokens_out, log_path, started_at, finished_at FROM task_runs WHERE task_id = @task_id ORDER BY run_number";
+ cmd.Parameters.AddWithValue("@task_id", taskId);
+
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+ var result = new List();
+ while (await reader.ReadAsync(ct))
+ result.Add(ReadRun(reader));
+ return result;
+ }
+
+ public async Task GetLatestByTaskIdAsync(string taskId, CancellationToken ct = default)
+ {
+ await using var conn = _factory.Open();
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = "SELECT id, task_id, run_number, session_id, is_retry, prompt, result_markdown, structured_output, error_markdown, exit_code, turn_count, tokens_in, tokens_out, log_path, started_at, finished_at FROM task_runs WHERE task_id = @task_id ORDER BY run_number DESC LIMIT 1";
+ cmd.Parameters.AddWithValue("@task_id", taskId);
+
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+ if (!await reader.ReadAsync(ct)) return null;
+ return ReadRun(reader);
+ }
+
+ private static void BindRun(SqliteCommand cmd, TaskRunEntity e)
+ {
+ cmd.Parameters.AddWithValue("@id", e.Id);
+ cmd.Parameters.AddWithValue("@task_id", e.TaskId);
+ cmd.Parameters.AddWithValue("@run_number", e.RunNumber);
+ cmd.Parameters.AddWithValue("@session_id", (object?)e.SessionId ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@is_retry", e.IsRetry ? 1 : 0);
+ cmd.Parameters.AddWithValue("@prompt", e.Prompt);
+ cmd.Parameters.AddWithValue("@result_markdown", (object?)e.ResultMarkdown ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@structured_output", (object?)e.StructuredOutputJson ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@error_markdown", (object?)e.ErrorMarkdown ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@exit_code", e.ExitCode.HasValue ? e.ExitCode.Value : DBNull.Value);
+ cmd.Parameters.AddWithValue("@turn_count", e.TurnCount.HasValue ? e.TurnCount.Value : DBNull.Value);
+ cmd.Parameters.AddWithValue("@tokens_in", e.TokensIn.HasValue ? e.TokensIn.Value : DBNull.Value);
+ cmd.Parameters.AddWithValue("@tokens_out", e.TokensOut.HasValue ? e.TokensOut.Value : DBNull.Value);
+ cmd.Parameters.AddWithValue("@log_path", (object?)e.LogPath ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@started_at", e.StartedAt.HasValue ? e.StartedAt.Value.ToString("o") : DBNull.Value);
+ cmd.Parameters.AddWithValue("@finished_at", e.FinishedAt.HasValue ? e.FinishedAt.Value.ToString("o") : DBNull.Value);
+ }
+
+ private static TaskRunEntity ReadRun(SqliteDataReader r) => new()
+ {
+ Id = r.GetString(0),
+ TaskId = r.GetString(1),
+ RunNumber = r.GetInt32(2),
+ SessionId = r.IsDBNull(3) ? null : r.GetString(3),
+ IsRetry = r.GetInt32(4) != 0,
+ Prompt = r.GetString(5),
+ ResultMarkdown = r.IsDBNull(6) ? null : r.GetString(6),
+ StructuredOutputJson = r.IsDBNull(7) ? null : r.GetString(7),
+ ErrorMarkdown = r.IsDBNull(8) ? null : r.GetString(8),
+ ExitCode = r.IsDBNull(9) ? null : r.GetInt32(9),
+ TurnCount = r.IsDBNull(10) ? null : r.GetInt32(10),
+ TokensIn = r.IsDBNull(11) ? null : r.GetInt32(11),
+ TokensOut = r.IsDBNull(12) ? null : r.GetInt32(12),
+ LogPath = r.IsDBNull(13) ? null : r.GetString(13),
+ StartedAt = r.IsDBNull(14) ? null : DateTime.Parse(r.GetString(14)),
+ FinishedAt = r.IsDBNull(15) ? null : DateTime.Parse(r.GetString(15)),
+ };
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run:
+```bash
+dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRunRepository" -v minimal
+```
+
+Expected: 5 tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/ClaudeDo.Data/Repositories/TaskRunRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/TaskRunRepositoryTests.cs
+git commit -m "feat(data): add TaskRunRepository with CRUD and query methods"
+```
+
+---
+
+### Task 4: ListRepository — Config methods
+
+**Files:**
+- Modify: `src/ClaudeDo.Data/Repositories/ListRepository.cs:116-124`
+- Create: `tests/ClaudeDo.Worker.Tests/Repositories/ListRepositoryConfigTests.cs`
+
+- [ ] **Step 1: Write failing tests**
+
+```csharp
+using ClaudeDo.Data.Models;
+using ClaudeDo.Data.Repositories;
+using ClaudeDo.Worker.Tests.Infrastructure;
+
+namespace ClaudeDo.Worker.Tests.Repositories;
+
+public sealed class ListRepositoryConfigTests : IDisposable
+{
+ private readonly DbFixture _db = new();
+ private readonly ListRepository _repo;
+ private readonly string _listId;
+
+ public ListRepositoryConfigTests()
+ {
+ _repo = new ListRepository(_db.Factory);
+ _listId = Guid.NewGuid().ToString();
+ _repo.AddAsync(new ListEntity
+ {
+ Id = _listId, Name = "Test", CreatedAt = DateTime.UtcNow
+ }).GetAwaiter().GetResult();
+ }
+
+ [Fact]
+ public async Task GetConfig_Returns_Null_When_No_Config()
+ {
+ var config = await _repo.GetConfigAsync(_listId);
+ Assert.Null(config);
+ }
+
+ [Fact]
+ public async Task SetConfig_And_GetConfig_Roundtrips()
+ {
+ var config = new ListConfigEntity
+ {
+ ListId = _listId,
+ Model = "sonnet-4-6",
+ SystemPrompt = "You are helpful.",
+ AgentPath = "/home/user/.todo-app/agents/dev.md",
+ };
+ await _repo.SetConfigAsync(config);
+
+ var fetched = await _repo.GetConfigAsync(_listId);
+ Assert.NotNull(fetched);
+ Assert.Equal("sonnet-4-6", fetched.Model);
+ Assert.Equal("You are helpful.", fetched.SystemPrompt);
+ Assert.Equal("/home/user/.todo-app/agents/dev.md", fetched.AgentPath);
+ }
+
+ [Fact]
+ public async Task SetConfig_Upserts_On_Duplicate()
+ {
+ await _repo.SetConfigAsync(new ListConfigEntity
+ {
+ ListId = _listId, Model = "opus-4-6"
+ });
+ await _repo.SetConfigAsync(new ListConfigEntity
+ {
+ ListId = _listId, Model = "haiku-4-5"
+ });
+
+ var fetched = await _repo.GetConfigAsync(_listId);
+ Assert.NotNull(fetched);
+ Assert.Equal("haiku-4-5", fetched.Model);
+ }
+
+ public void Dispose() => _db.Dispose();
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run:
+```bash
+dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ListRepositoryConfig" -v minimal
+```
+
+Expected: build error — methods don't exist on `ListRepository`.
+
+- [ ] **Step 3: Add GetConfigAsync and SetConfigAsync to ListRepository**
+
+Add after the `RemoveTagAsync` method (before the `ReadList` helper):
+
+```csharp
+public async Task GetConfigAsync(string listId, CancellationToken ct = default)
+{
+ await using var conn = _factory.Open();
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = "SELECT list_id, model, system_prompt, agent_path FROM list_config WHERE list_id = @list_id";
+ cmd.Parameters.AddWithValue("@list_id", listId);
+
+ await using var reader = await cmd.ExecuteReaderAsync(ct);
+ if (!await reader.ReadAsync(ct)) return null;
+ return new ListConfigEntity
+ {
+ ListId = reader.GetString(0),
+ Model = reader.IsDBNull(1) ? null : reader.GetString(1),
+ SystemPrompt = reader.IsDBNull(2) ? null : reader.GetString(2),
+ AgentPath = reader.IsDBNull(3) ? null : reader.GetString(3),
+ };
+}
+
+public async Task SetConfigAsync(ListConfigEntity entity, CancellationToken ct = default)
+{
+ await using var conn = _factory.Open();
+ await using var cmd = conn.CreateCommand();
+ cmd.CommandText = """
+ INSERT OR REPLACE INTO list_config (list_id, model, system_prompt, agent_path)
+ VALUES (@list_id, @model, @system_prompt, @agent_path)
+ """;
+ cmd.Parameters.AddWithValue("@list_id", entity.ListId);
+ cmd.Parameters.AddWithValue("@model", (object?)entity.Model ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@system_prompt", (object?)entity.SystemPrompt ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@agent_path", (object?)entity.AgentPath ?? DBNull.Value);
+ await cmd.ExecuteNonQueryAsync(ct);
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run:
+```bash
+dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ListRepositoryConfig" -v minimal
+```
+
+Expected: 3 tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/ClaudeDo.Data/Repositories/ListRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/ListRepositoryConfigTests.cs
+git commit -m "feat(data): add GetConfigAsync and SetConfigAsync to ListRepository"
+```
+
+---
+
+### Task 5: TaskRepository — Read/write config columns
+
+**Files:**
+- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs:39-51,53-66,77-87,89-101,266-280,282-296`
+
+The `AddAsync`, `UpdateAsync`, `GetByIdAsync`, `GetByListAsync`, `BindTask`, and `ReadTask` methods must include the three new columns (`model`, `system_prompt`, `agent_path`).
+
+- [ ] **Step 1: Update BindTask to include new columns**
+
+Replace the `BindTask` method:
+
+```csharp
+private static void BindTask(SqliteCommand cmd, TaskEntity e)
+{
+ cmd.Parameters.AddWithValue("@id", e.Id);
+ cmd.Parameters.AddWithValue("@list_id", e.ListId);
+ cmd.Parameters.AddWithValue("@title", e.Title);
+ cmd.Parameters.AddWithValue("@description", (object?)e.Description ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@status", ToDb(e.Status));
+ cmd.Parameters.AddWithValue("@scheduled_for", e.ScheduledFor.HasValue ? e.ScheduledFor.Value.ToString("o") : DBNull.Value);
+ cmd.Parameters.AddWithValue("@result", (object?)e.Result ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@log_path", (object?)e.LogPath ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@created_at", e.CreatedAt.ToString("o"));
+ cmd.Parameters.AddWithValue("@started_at", e.StartedAt.HasValue ? e.StartedAt.Value.ToString("o") : DBNull.Value);
+ cmd.Parameters.AddWithValue("@finished_at", e.FinishedAt.HasValue ? e.FinishedAt.Value.ToString("o") : DBNull.Value);
+ cmd.Parameters.AddWithValue("@commit_type", e.CommitType);
+ cmd.Parameters.AddWithValue("@model", (object?)e.Model ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@system_prompt", (object?)e.SystemPrompt ?? DBNull.Value);
+ cmd.Parameters.AddWithValue("@agent_path", (object?)e.AgentPath ?? DBNull.Value);
+}
+```
+
+- [ ] **Step 2: Update AddAsync SQL to include new columns**
+
+```csharp
+cmd.CommandText = """
+ INSERT INTO tasks (id, list_id, title, description, status, scheduled_for,
+ result, log_path, created_at, started_at, finished_at, commit_type,
+ model, system_prompt, agent_path)
+ VALUES (@id, @list_id, @title, @description, @status, @scheduled_for,
+ @result, @log_path, @created_at, @started_at, @finished_at, @commit_type,
+ @model, @system_prompt, @agent_path)
+ """;
+```
+
+- [ ] **Step 3: Update UpdateAsync SQL to include new columns**
+
+```csharp
+cmd.CommandText = """
+ UPDATE tasks SET list_id = @list_id, title = @title, description = @description,
+ status = @status, scheduled_for = @scheduled_for, result = @result,
+ log_path = @log_path, started_at = @started_at,
+ finished_at = @finished_at, commit_type = @commit_type,
+ model = @model, system_prompt = @system_prompt, agent_path = @agent_path
+ WHERE id = @id
+ """;
+```
+
+- [ ] **Step 4: Update all SELECT statements and ReadTask**
+
+Every `SELECT` in `GetByIdAsync`, `GetByListAsync`, and `GetNextQueuedAgentTaskAsync` must add the three new columns. Update the column list to:
+
+```
+SELECT id, list_id, title, description, status, scheduled_for, result, log_path, created_at, started_at, finished_at, commit_type, model, system_prompt, agent_path FROM tasks ...
+```
+
+Update `ReadTask`:
+
+```csharp
+private static TaskEntity ReadTask(SqliteDataReader r) => new()
+{
+ Id = r.GetString(0),
+ ListId = r.GetString(1),
+ Title = r.GetString(2),
+ Description = r.IsDBNull(3) ? null : r.GetString(3),
+ Status = FromDb(r.GetString(4)),
+ ScheduledFor = r.IsDBNull(5) ? null : DateTime.Parse(r.GetString(5)),
+ Result = r.IsDBNull(6) ? null : r.GetString(6),
+ LogPath = r.IsDBNull(7) ? null : r.GetString(7),
+ CreatedAt = DateTime.Parse(r.GetString(8)),
+ StartedAt = r.IsDBNull(9) ? null : DateTime.Parse(r.GetString(9)),
+ FinishedAt = r.IsDBNull(10) ? null : DateTime.Parse(r.GetString(10)),
+ CommitType = r.GetString(11),
+ Model = r.IsDBNull(12) ? null : r.GetString(12),
+ SystemPrompt = r.IsDBNull(13) ? null : r.GetString(13),
+ AgentPath = r.IsDBNull(14) ? null : r.GetString(14),
+};
+```
+
+- [ ] **Step 5: Run all existing TaskRepository tests**
+
+Run:
+```bash
+dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~TaskRepositoryTests" -v minimal
+```
+
+Expected: all existing tests still pass (new columns are nullable, backward compatible).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/ClaudeDo.Data/Repositories/TaskRepository.cs
+git commit -m "feat(data): extend TaskRepository with model, system_prompt, agent_path columns"
+```
+
+---
+
+### Task 6: ClaudeArgsBuilder — CLI argument construction
+
+**Files:**
+- Create: `src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs`
+- Create: `tests/ClaudeDo.Worker.Tests/Runner/ClaudeArgsBuilderTests.cs`
+
+- [ ] **Step 1: Write failing tests**
+
+```csharp
+using ClaudeDo.Worker.Runner;
+
+namespace ClaudeDo.Worker.Tests.Runner;
+
+public sealed class ClaudeArgsBuilderTests
+{
+ private readonly ClaudeArgsBuilder _builder = new();
+
+ [Fact]
+ public void Default_Config_Produces_Base_Args()
+ {
+ var args = _builder.Build(new ClaudeRunConfig(null, null, null, null));
+ Assert.Contains("-p", args);
+ Assert.Contains("--output-format stream-json", args);
+ Assert.Contains("--verbose", args);
+ Assert.Contains("--dangerously-skip-permissions", args);
+ Assert.Contains("--json-schema", args);
+ Assert.DoesNotContain("--model", args);
+ Assert.DoesNotContain("--append-system-prompt", args);
+ Assert.DoesNotContain("--agents", args);
+ Assert.DoesNotContain("--resume", args);
+ }
+
+ [Fact]
+ public void Model_Adds_Model_Flag()
+ {
+ var args = _builder.Build(new ClaudeRunConfig("sonnet-4-6", null, null, null));
+ Assert.Contains("--model sonnet-4-6", args);
+ }
+
+ [Fact]
+ public void SystemPrompt_Adds_Append_System_Prompt_Flag()
+ {
+ var args = _builder.Build(new ClaudeRunConfig(null, "Be concise.", null, null));
+ Assert.Contains("--append-system-prompt", args);
+ Assert.Contains("Be concise.", args);
+ }
+
+ [Fact]
+ public void AgentPath_Adds_Agents_Flag_As_Json()
+ {
+ var args = _builder.Build(new ClaudeRunConfig(null, null, "/path/to/agent.md", null));
+ Assert.Contains("--agents", args);
+ Assert.Contains("/path/to/agent.md", args);
+ }
+
+ [Fact]
+ public void ResumeSessionId_Adds_Resume_Flag()
+ {
+ var args = _builder.Build(new ClaudeRunConfig(null, null, null, "sess-abc-123"));
+ Assert.Contains("--resume sess-abc-123", args);
+ }
+
+ [Fact]
+ public void All_Options_Set_Includes_All_Flags()
+ {
+ var args = _builder.Build(new ClaudeRunConfig("opus-4-6", "Be thorough.", "/agents/dev.md", "sess-xyz"));
+ Assert.Contains("--model opus-4-6", args);
+ Assert.Contains("--append-system-prompt", args);
+ Assert.Contains("--agents", args);
+ Assert.Contains("--resume sess-xyz", args);
+ Assert.Contains("--json-schema", args);
+ }
+
+ [Fact]
+ public void SystemPrompt_With_Quotes_Is_Escaped()
+ {
+ var args = _builder.Build(new ClaudeRunConfig(null, """Don't say "hello".""", null, null));
+ // Should not break argument parsing — the prompt is passed as a single argument
+ Assert.Contains("--append-system-prompt", args);
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run:
+```bash
+dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ClaudeArgsBuilder" -v minimal
+```
+
+Expected: build error — `ClaudeArgsBuilder` and `ClaudeRunConfig` don't exist.
+
+- [ ] **Step 3: Implement ClaudeArgsBuilder**
+
+```csharp
+using System.Text;
+using System.Text.Json;
+
+namespace ClaudeDo.Worker.Runner;
+
+public sealed record ClaudeRunConfig(
+ string? Model,
+ string? SystemPrompt,
+ string? AgentPath,
+ string? ResumeSessionId
+);
+
+public sealed class ClaudeArgsBuilder
+{
+ private static readonly string ResultSchema = JsonSerializer.Serialize(new
+ {
+ type = "object",
+ properties = new
+ {
+ summary = new { type = "string" },
+ files_changed = new { type = "array", items = new { type = "string" } },
+ commit_type = new { type = "string" },
+ },
+ required = new[] { "summary" },
+ });
+
+ public string Build(ClaudeRunConfig config)
+ {
+ var args = new List
+ {
+ "-p",
+ "--output-format stream-json",
+ "--verbose",
+ "--dangerously-skip-permissions",
+ };
+
+ if (config.Model is not null)
+ args.Add($"--model {config.Model}");
+
+ if (config.SystemPrompt is not null)
+ args.Add($"--append-system-prompt {Escape(config.SystemPrompt)}");
+
+ if (config.AgentPath is not null)
+ {
+ var agentJson = JsonSerializer.Serialize(new[] { new { file = config.AgentPath } });
+ args.Add($"--agents {Escape(agentJson)}");
+ }
+
+ args.Add($"--json-schema {Escape(ResultSchema)}");
+
+ if (config.ResumeSessionId is not null)
+ args.Add($"--resume {config.ResumeSessionId}");
+
+ return string.Join(" ", args);
+ }
+
+ private static string Escape(string value)
+ {
+ // Wrap in double quotes if the value contains spaces or special characters.
+ if (value.Contains(' ') || value.Contains('"') || value.Contains('\''))
+ {
+ var escaped = value.Replace("\\", "\\\\").Replace("\"", "\\\"");
+ return $"\"{escaped}\"";
+ }
+ return value;
+ }
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run:
+```bash
+dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~ClaudeArgsBuilder" -v minimal
+```
+
+Expected: 7 tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs tests/ClaudeDo.Worker.Tests/Runner/ClaudeArgsBuilderTests.cs
+git commit -m "feat(worker): add ClaudeArgsBuilder for dynamic CLI argument construction"
+```
+
+---
+
+### Task 7: StreamAnalyzer — Rich NDJSON stream parsing
+
+**Files:**
+- Create: `src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs`
+- Create: `src/ClaudeDo.Worker/Runner/StreamResult.cs`
+- Create: `tests/ClaudeDo.Worker.Tests/Runner/StreamAnalyzerTests.cs`
+
+- [ ] **Step 1: Write failing tests**
+
+```csharp
+using ClaudeDo.Worker.Runner;
+
+namespace ClaudeDo.Worker.Tests.Runner;
+
+public sealed class StreamAnalyzerTests
+{
+ [Fact]
+ public void Extracts_Result_Markdown()
+ {
+ var analyzer = new StreamAnalyzer();
+ analyzer.ProcessLine("""{"type":"result","result":"## Done","session_id":"sess-1"}""");
+
+ var result = analyzer.GetResult();
+ Assert.Equal("## Done", result.ResultMarkdown);
+ Assert.Equal("sess-1", result.SessionId);
+ }
+
+ [Fact]
+ public void Extracts_Structured_Output()
+ {
+ var analyzer = new StreamAnalyzer();
+ analyzer.ProcessLine("""{"type":"result","result":"ok","structured_output":{"summary":"all good"},"session_id":"s1"}""");
+
+ var result = analyzer.GetResult();
+ Assert.Equal("ok", result.ResultMarkdown);
+ Assert.Contains("all good", result.StructuredOutputJson);
+ }
+
+ [Fact]
+ public void Counts_Assistant_Turns()
+ {
+ var analyzer = new StreamAnalyzer();
+ analyzer.ProcessLine("""{"type":"assistant","message":"hi"}""");
+ analyzer.ProcessLine("""{"type":"assistant","message":"working on it"}""");
+ analyzer.ProcessLine("""{"type":"result","result":"done","session_id":"s1"}""");
+
+ var result = analyzer.GetResult();
+ Assert.Equal(2, result.TurnCount);
+ }
+
+ [Fact]
+ public void Accumulates_Token_Usage()
+ {
+ var analyzer = new StreamAnalyzer();
+ analyzer.ProcessLine("""{"type":"stream_event","event":{"type":"message_start","message":{"usage":{"input_tokens":100,"output_tokens":50}}}}""");
+ analyzer.ProcessLine("""{"type":"stream_event","event":{"type":"message_start","message":{"usage":{"input_tokens":200,"output_tokens":80}}}}""");
+ analyzer.ProcessLine("""{"type":"result","result":"done","session_id":"s1"}""");
+
+ var result = analyzer.GetResult();
+ Assert.Equal(300, result.TokensIn);
+ Assert.Equal(130, result.TokensOut);
+ }
+
+ [Fact]
+ public void Counts_Api_Retry_Events()
+ {
+ var analyzer = new StreamAnalyzer();
+ analyzer.ProcessLine("""{"type":"system","subtype":"api_retry","attempt":1,"error":"rate_limit"}""");
+ analyzer.ProcessLine("""{"type":"system","subtype":"api_retry","attempt":2,"error":"rate_limit"}""");
+ analyzer.ProcessLine("""{"type":"result","result":"done","session_id":"s1"}""");
+
+ var result = analyzer.GetResult();
+ Assert.Equal(2, result.ApiRetryCount);
+ }
+
+ [Fact]
+ public void Malformed_Json_Is_Ignored()
+ {
+ var analyzer = new StreamAnalyzer();
+ analyzer.ProcessLine("not json {{{");
+ analyzer.ProcessLine("");
+ analyzer.ProcessLine(" ");
+
+ var result = analyzer.GetResult();
+ Assert.Null(result.ResultMarkdown);
+ Assert.Equal(0, result.TurnCount);
+ }
+
+ [Fact]
+ public void No_Result_Event_Returns_Null_Fields()
+ {
+ var analyzer = new StreamAnalyzer();
+ analyzer.ProcessLine("""{"type":"assistant","message":"hi"}""");
+
+ var result = analyzer.GetResult();
+ Assert.Null(result.ResultMarkdown);
+ Assert.Null(result.SessionId);
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run:
+```bash
+dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~StreamAnalyzer" -v minimal
+```
+
+Expected: build error — `StreamAnalyzer` and `StreamResult` don't exist.
+
+- [ ] **Step 3: Create StreamResult**
+
+```csharp
+namespace ClaudeDo.Worker.Runner;
+
+public sealed class StreamResult
+{
+ public string? ResultMarkdown { get; set; }
+ public string? StructuredOutputJson { get; set; }
+ public string? SessionId { get; set; }
+ public int TurnCount { get; set; }
+ public int TokensIn { get; set; }
+ public int TokensOut { get; set; }
+ public int ApiRetryCount { get; set; }
+}
+```
+
+- [ ] **Step 4: Implement StreamAnalyzer**
+
+```csharp
+using System.Text.Json;
+
+namespace ClaudeDo.Worker.Runner;
+
+public sealed class StreamAnalyzer
+{
+ private string? _resultMarkdown;
+ private string? _structuredOutputJson;
+ private string? _sessionId;
+ private int _turnCount;
+ private int _tokensIn;
+ private int _tokensOut;
+ private int _apiRetryCount;
+
+ public void ProcessLine(string ndjsonLine)
+ {
+ if (string.IsNullOrWhiteSpace(ndjsonLine))
+ return;
+
+ try
+ {
+ using var doc = JsonDocument.Parse(ndjsonLine);
+ var root = doc.RootElement;
+
+ if (!root.TryGetProperty("type", out var typeProp))
+ return;
+
+ var type = typeProp.GetString();
+
+ switch (type)
+ {
+ case "result":
+ if (root.TryGetProperty("result", out var resultProp))
+ _resultMarkdown = resultProp.GetString();
+ if (root.TryGetProperty("structured_output", out var structuredProp))
+ _structuredOutputJson = structuredProp.ToString();
+ if (root.TryGetProperty("session_id", out var sessionProp))
+ _sessionId = sessionProp.GetString();
+ break;
+
+ case "assistant":
+ _turnCount++;
+ break;
+
+ case "system":
+ if (root.TryGetProperty("subtype", out var subtypeProp) &&
+ subtypeProp.GetString() == "api_retry")
+ _apiRetryCount++;
+ break;
+
+ case "stream_event":
+ TryAccumulateUsage(root);
+ break;
+ }
+ }
+ catch (JsonException)
+ {
+ // Malformed JSON — skip.
+ }
+ }
+
+ public StreamResult GetResult() => new()
+ {
+ ResultMarkdown = _resultMarkdown,
+ StructuredOutputJson = _structuredOutputJson,
+ SessionId = _sessionId,
+ TurnCount = _turnCount,
+ TokensIn = _tokensIn,
+ TokensOut = _tokensOut,
+ ApiRetryCount = _apiRetryCount,
+ };
+
+ private void TryAccumulateUsage(JsonElement root)
+ {
+ // Path: .event.message.usage.input_tokens / .event.message.usage.output_tokens
+ // Also: .event.delta.usage for delta events
+ if (!root.TryGetProperty("event", out var eventProp))
+ return;
+
+ if (eventProp.TryGetProperty("message", out var msgProp) &&
+ msgProp.TryGetProperty("usage", out var usageProp))
+ {
+ AccumulateUsage(usageProp);
+ }
+ }
+
+ private void AccumulateUsage(JsonElement usage)
+ {
+ if (usage.TryGetProperty("input_tokens", out var inp))
+ _tokensIn += inp.GetInt32();
+ if (usage.TryGetProperty("output_tokens", out var outp))
+ _tokensOut += outp.GetInt32();
+ }
+}
+```
+
+- [ ] **Step 5: Run tests to verify they pass**
+
+Run:
+```bash
+dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~StreamAnalyzer" -v minimal
+```
+
+Expected: 7 tests pass.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/ClaudeDo.Worker/Runner/StreamResult.cs src/ClaudeDo.Worker/Runner/StreamAnalyzer.cs tests/ClaudeDo.Worker.Tests/Runner/StreamAnalyzerTests.cs
+git commit -m "feat(worker): add StreamAnalyzer for rich NDJSON stream parsing"
+```
+
+---
+
+### Task 8: RunResult — Extend with stream metrics
+
+**Files:**
+- Modify: `src/ClaudeDo.Worker/Runner/RunResult.cs:1-11`
+
+- [ ] **Step 1: Update RunResult**
+
+Replace entire file:
+
+```csharp
+namespace ClaudeDo.Worker.Runner;
+
+public sealed class RunResult
+{
+ public required int ExitCode { get; init; }
+ public string? ResultMarkdown { get; init; }
+ public string? ErrorMarkdown { get; init; }
+ public string? StructuredOutputJson { get; init; }
+ public string? SessionId { get; init; }
+ public int TurnCount { get; init; }
+ public int TokensIn { get; init; }
+ public int TokensOut { get; init; }
+
+ public bool IsSuccess => ExitCode == 0 && ResultMarkdown is not null;
+}
+```
+
+- [ ] **Step 2: Build to check for downstream breakage**
+
+Run:
+```bash
+dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
+```
+
+Expected: build succeeds — new properties are optional (init-only with defaults). The existing `new RunResult { ExitCode = ..., ResultMarkdown = ... }` sites still compile.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/ClaudeDo.Worker/Runner/RunResult.cs
+git commit -m "feat(worker): extend RunResult with structured output, session ID, and token metrics"
+```
+
+---
+
+### Task 9: IClaudeProcess + ClaudeProcess — Simplified interface
+
+**Files:**
+- Modify: `src/ClaudeDo.Worker/Runner/IClaudeProcess.cs:1-12`
+- Modify: `src/ClaudeDo.Worker/Runner/ClaudeProcess.cs:1-96`
+
+- [ ] **Step 1: Update IClaudeProcess**
+
+Replace entire file:
+
+```csharp
+namespace ClaudeDo.Worker.Runner;
+
+public interface IClaudeProcess
+{
+ Task RunAsync(
+ string arguments,
+ string prompt,
+ string workingDirectory,
+ Func onStdoutLine,
+ CancellationToken ct);
+}
+```
+
+- [ ] **Step 2: Update ClaudeProcess**
+
+Replace entire file:
+
+```csharp
+using System.Diagnostics;
+using System.Text;
+using ClaudeDo.Worker.Config;
+
+namespace ClaudeDo.Worker.Runner;
+
+public sealed class ClaudeProcess : IClaudeProcess
+{
+ private readonly WorkerConfig _cfg;
+ private readonly ILogger _logger;
+
+ public ClaudeProcess(WorkerConfig cfg, ILogger logger)
+ {
+ _cfg = cfg;
+ _logger = logger;
+ }
+
+ public async Task RunAsync(
+ string arguments,
+ string prompt,
+ string workingDirectory,
+ Func onStdoutLine,
+ CancellationToken ct)
+ {
+ var psi = new ProcessStartInfo
+ {
+ FileName = _cfg.ClaudeBin,
+ Arguments = arguments,
+ WorkingDirectory = workingDirectory,
+ RedirectStandardInput = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ StandardOutputEncoding = Encoding.UTF8,
+ StandardErrorEncoding = Encoding.UTF8,
+ };
+
+ using var process = new Process { StartInfo = psi };
+ process.Start();
+
+ // Write prompt to stdin, then close.
+ await process.StandardInput.WriteAsync(prompt);
+ process.StandardInput.Close();
+
+ var analyzer = new StreamAnalyzer();
+ var lastStderr = new StringBuilder();
+
+ // Register cancellation to kill the process tree.
+ await using var ctr = ct.Register(() =>
+ {
+ try { process.Kill(entireProcessTree: true); }
+ catch { /* already exited */ }
+ });
+
+ // Read stdout and stderr concurrently.
+ var stdoutTask = Task.Run(async () =>
+ {
+ while (await process.StandardOutput.ReadLineAsync(ct) is { } line)
+ {
+ if (string.IsNullOrEmpty(line)) continue;
+ await onStdoutLine(line);
+ analyzer.ProcessLine(line);
+ }
+ }, ct);
+
+ var stderrTask = Task.Run(async () =>
+ {
+ while (await process.StandardError.ReadLineAsync(ct) is { } line)
+ {
+ if (string.IsNullOrEmpty(line)) continue;
+ lastStderr.AppendLine(line);
+ await onStdoutLine($"[stderr] {line}");
+ }
+ }, ct);
+
+ await Task.WhenAll(stdoutTask, stderrTask);
+ await process.WaitForExitAsync(ct);
+
+ var exitCode = process.ExitCode;
+ var streamResult = analyzer.GetResult();
+
+ if (exitCode == 0 && streamResult.ResultMarkdown is not null)
+ {
+ return new RunResult
+ {
+ ExitCode = exitCode,
+ ResultMarkdown = streamResult.ResultMarkdown,
+ StructuredOutputJson = streamResult.StructuredOutputJson,
+ SessionId = streamResult.SessionId,
+ TurnCount = streamResult.TurnCount,
+ TokensIn = streamResult.TokensIn,
+ TokensOut = streamResult.TokensOut,
+ };
+ }
+
+ var error = lastStderr.Length > 0
+ ? lastStderr.ToString().Trim()
+ : $"Claude exited with code {exitCode} and no result.";
+
+ return new RunResult
+ {
+ ExitCode = exitCode,
+ ErrorMarkdown = error,
+ SessionId = streamResult.SessionId,
+ TurnCount = streamResult.TurnCount,
+ TokensIn = streamResult.TokensIn,
+ TokensOut = streamResult.TokensOut,
+ };
+ }
+}
+```
+
+- [ ] **Step 3: Update FakeClaudeProcess in tests**
+
+The `FakeClaudeProcess` in `QueueServiceTests.cs` must match the new `IClaudeProcess` signature. Update it:
+
+```csharp
+internal sealed class FakeClaudeProcess : IClaudeProcess
+{
+ private readonly Func, CancellationToken, Task> _handler;
+ private int _callCount;
+
+ public int CallCount => _callCount;
+
+ public FakeClaudeProcess(
+ Func, CancellationToken, Task>? handler = null)
+ {
+ _handler = handler ?? ((_, _, _, _, _) =>
+ Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok" }));
+ }
+
+ public async Task RunAsync(string arguments, string prompt, string workingDirectory,
+ Func onStdoutLine, CancellationToken ct)
+ {
+ Interlocked.Increment(ref _callCount);
+ return await _handler(prompt, workingDirectory, arguments, onStdoutLine, ct);
+ }
+}
+```
+
+Update all sites in `QueueServiceTests.cs` that construct `FakeClaudeProcess` with a handler lambda — the lambda signature changes from 6 parameters to 5 (remove `logPath` and `taskId`).
+
+- [ ] **Step 4: Build and run all tests**
+
+Run:
+```bash
+dotnet build ClaudeDo.slnx && dotnet test tests/ClaudeDo.Worker.Tests -v minimal
+```
+
+Expected: all tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/ClaudeDo.Worker/Runner/IClaudeProcess.cs src/ClaudeDo.Worker/Runner/ClaudeProcess.cs tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs
+git commit -m "refactor(worker): simplify ClaudeProcess to accept pre-built args and use StreamAnalyzer"
+```
+
+---
+
+### Task 10: AgentFileService — Filesystem agent management
+
+**Files:**
+- Create: `src/ClaudeDo.Worker/Services/AgentFileService.cs`
+- Create: `tests/ClaudeDo.Worker.Tests/Services/AgentFileServiceTests.cs`
+
+- [ ] **Step 1: Write failing tests**
+
+```csharp
+using ClaudeDo.Worker.Services;
+
+namespace ClaudeDo.Worker.Tests.Services;
+
+public sealed class AgentFileServiceTests : IDisposable
+{
+ private readonly string _agentDir;
+ private readonly AgentFileService _service;
+
+ public AgentFileServiceTests()
+ {
+ _agentDir = Path.Combine(Path.GetTempPath(), $"claudedo_agents_test_{Guid.NewGuid():N}");
+ Directory.CreateDirectory(_agentDir);
+ _service = new AgentFileService(_agentDir);
+ }
+
+ [Fact]
+ public async Task Scan_Returns_Empty_For_Empty_Directory()
+ {
+ var agents = await _service.ScanAsync();
+ Assert.Empty(agents);
+ }
+
+ [Fact]
+ public async Task Scan_Parses_Frontmatter()
+ {
+ var content = """
+ ---
+ name: Test Agent
+ description: A test agent for unit tests
+ ---
+
+ You are a test agent.
+ """;
+ await File.WriteAllTextAsync(Path.Combine(_agentDir, "test.md"), content);
+
+ var agents = await _service.ScanAsync();
+ Assert.Single(agents);
+ Assert.Equal("Test Agent", agents[0].Name);
+ Assert.Equal("A test agent for unit tests", agents[0].Description);
+ Assert.EndsWith("test.md", agents[0].Path);
+ }
+
+ [Fact]
+ public async Task Scan_Uses_Filename_When_No_Frontmatter()
+ {
+ await File.WriteAllTextAsync(Path.Combine(_agentDir, "simple.md"), "Just instructions.");
+
+ var agents = await _service.ScanAsync();
+ Assert.Single(agents);
+ Assert.Equal("simple", agents[0].Name);
+ Assert.Equal("", agents[0].Description);
+ }
+
+ [Fact]
+ public async Task Write_And_Read_Roundtrips()
+ {
+ var path = Path.Combine(_agentDir, "new-agent.md");
+ var content = "---\nname: New\ndescription: Desc\n---\nBody";
+ await _service.WriteAsync(path, content);
+
+ var read = await _service.ReadAsync(path);
+ Assert.Equal(content, read);
+ }
+
+ [Fact]
+ public async Task Delete_Removes_File()
+ {
+ var path = Path.Combine(_agentDir, "to-delete.md");
+ await File.WriteAllTextAsync(path, "temp");
+
+ await _service.DeleteAsync(path);
+ Assert.False(File.Exists(path));
+ }
+
+ [Fact]
+ public async Task Scan_Ignores_Non_Md_Files()
+ {
+ await File.WriteAllTextAsync(Path.Combine(_agentDir, "notes.txt"), "not an agent");
+ await File.WriteAllTextAsync(Path.Combine(_agentDir, "agent.md"), "---\nname: Real\ndescription: Yes\n---\nBody");
+
+ var agents = await _service.ScanAsync();
+ Assert.Single(agents);
+ Assert.Equal("Real", agents[0].Name);
+ }
+
+ public void Dispose()
+ {
+ try { Directory.Delete(_agentDir, true); } catch { }
+ }
+}
+```
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run:
+```bash
+dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~AgentFileService" -v minimal
+```
+
+Expected: build error — `AgentFileService` doesn't exist.
+
+- [ ] **Step 3: Implement AgentFileService**
+
+```csharp
+using ClaudeDo.Data.Models;
+
+namespace ClaudeDo.Worker.Services;
+
+public sealed class AgentFileService
+{
+ private readonly string _agentsDir;
+
+ public AgentFileService(string agentsDir)
+ {
+ _agentsDir = agentsDir;
+ }
+
+ public Task> ScanAsync(CancellationToken ct = default)
+ {
+ var agents = new List();
+
+ if (!Directory.Exists(_agentsDir))
+ return Task.FromResult(agents);
+
+ foreach (var file in Directory.EnumerateFiles(_agentsDir, "*.md"))
+ {
+ ct.ThrowIfCancellationRequested();
+ var (name, description) = ParseFrontmatter(file);
+ agents.Add(new AgentInfo(name, description, file));
+ }
+
+ agents.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
+ return Task.FromResult(agents);
+ }
+
+ public async Task ReadAsync(string path, CancellationToken ct = default)
+ {
+ return await File.ReadAllTextAsync(path, ct);
+ }
+
+ public async Task WriteAsync(string path, string content, CancellationToken ct = default)
+ {
+ var dir = Path.GetDirectoryName(path);
+ if (dir is not null)
+ Directory.CreateDirectory(dir);
+ await File.WriteAllTextAsync(path, content, ct);
+ }
+
+ public Task DeleteAsync(string path, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+ if (File.Exists(path))
+ File.Delete(path);
+ return Task.CompletedTask;
+ }
+
+ private static (string name, string description) ParseFrontmatter(string filePath)
+ {
+ var fileName = Path.GetFileNameWithoutExtension(filePath);
+ string name = fileName;
+ string description = "";
+
+ try
+ {
+ using var reader = new StreamReader(filePath);
+ var firstLine = reader.ReadLine();
+ if (firstLine?.Trim() != "---")
+ return (name, description);
+
+ while (reader.ReadLine() is { } line)
+ {
+ if (line.Trim() == "---")
+ break;
+
+ if (line.StartsWith("name:"))
+ name = line["name:".Length..].Trim();
+ else if (line.StartsWith("description:"))
+ description = line["description:".Length..].Trim();
+ }
+ }
+ catch
+ {
+ // Can't read file — use filename fallback.
+ }
+
+ return (name, description);
+ }
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run:
+```bash
+dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~AgentFileService" -v minimal
+```
+
+Expected: 6 tests pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/ClaudeDo.Worker/Services/AgentFileService.cs tests/ClaudeDo.Worker.Tests/Services/AgentFileServiceTests.cs
+git commit -m "feat(worker): add AgentFileService for filesystem agent management"
+```
+
+---
+
+### Task 11: TaskRunner — Refactor with retry/continue and config resolution
+
+**Files:**
+- Modify: `src/ClaudeDo.Worker/Runner/TaskRunner.cs:1-149`
+
+This is the largest change. TaskRunner gains config resolution, task_runs tracking, auto-retry, and a new `ContinueAsync` method.
+
+- [ ] **Step 1: Rewrite TaskRunner**
+
+Replace entire file:
+
+```csharp
+using ClaudeDo.Data.Models;
+using ClaudeDo.Data.Repositories;
+using ClaudeDo.Worker.Config;
+using ClaudeDo.Worker.Hub;
+
+namespace ClaudeDo.Worker.Runner;
+
+public sealed class TaskRunner
+{
+ private readonly IClaudeProcess _claude;
+ private readonly TaskRepository _taskRepo;
+ private readonly TaskRunRepository _runRepo;
+ private readonly ListRepository _listRepo;
+ private readonly WorktreeRepository _wtRepo; // for ContinueAsync worktree lookup
+ private readonly HubBroadcaster _broadcaster;
+ private readonly WorktreeManager _wtManager;
+ private readonly ClaudeArgsBuilder _argsBuilder;
+ private readonly WorkerConfig _cfg;
+ private readonly ILogger _logger;
+
+ public TaskRunner(
+ IClaudeProcess claude,
+ TaskRepository taskRepo,
+ TaskRunRepository runRepo,
+ ListRepository listRepo,
+ WorktreeRepository wtRepo,
+ HubBroadcaster broadcaster,
+ WorktreeManager wtManager,
+ ClaudeArgsBuilder argsBuilder,
+ WorkerConfig cfg,
+ ILogger logger)
+ {
+ _claude = claude;
+ _taskRepo = taskRepo;
+ _runRepo = runRepo;
+ _listRepo = listRepo;
+ _wtRepo = wtRepo;
+ _broadcaster = broadcaster;
+ _wtManager = wtManager;
+ _argsBuilder = argsBuilder;
+ _cfg = cfg;
+ _logger = logger;
+ }
+
+ public async Task RunAsync(TaskEntity task, string slot, CancellationToken ct)
+ {
+ try
+ {
+ var list = await _listRepo.GetByIdAsync(task.ListId, ct);
+ if (list is null)
+ {
+ await MarkFailed(task.Id, slot, "List not found.");
+ return;
+ }
+
+ // Determine working directory: worktree or sandbox.
+ WorktreeContext? wtCtx = null;
+ string runDir;
+
+ if (list.WorkingDir is not null)
+ {
+ try
+ {
+ wtCtx = await _wtManager.CreateAsync(task, list, ct);
+ runDir = wtCtx.WorktreePath;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to create worktree for task {TaskId}", task.Id);
+ await MarkFailed(task.Id, slot, $"Worktree creation failed: {ex.Message}");
+ return;
+ }
+ }
+ else
+ {
+ runDir = Path.Combine(_cfg.SandboxRoot, task.Id);
+ Directory.CreateDirectory(runDir);
+ }
+
+ // Resolve config: task overrides > list config > null.
+ var listConfig = await _listRepo.GetConfigAsync(task.ListId, ct);
+ var resolvedConfig = new ClaudeRunConfig(
+ Model: task.Model ?? listConfig?.Model,
+ SystemPrompt: task.SystemPrompt ?? listConfig?.SystemPrompt,
+ AgentPath: task.AgentPath ?? listConfig?.AgentPath,
+ ResumeSessionId: null
+ );
+
+ var now = DateTime.UtcNow;
+ await _taskRepo.MarkRunningAsync(task.Id, now, ct);
+ await _broadcaster.TaskStarted(slot, task.Id, now);
+
+ // Build prompt.
+ var prompt = string.IsNullOrWhiteSpace(task.Description)
+ ? task.Title
+ : $"{task.Title}\n\n{task.Description.Trim()}";
+
+ // Run 1.
+ var result = await RunOnceAsync(task.Id, slot, runDir, resolvedConfig, 1, false, prompt, ct);
+
+ if (result.IsSuccess)
+ {
+ await HandleSuccess(task, list, slot, wtCtx, result, ct);
+ }
+ else
+ {
+ // Auto-retry: one attempt if we have a session ID.
+ if (result.SessionId is not null)
+ {
+ _logger.LogInformation("Auto-retrying task {TaskId} with session {SessionId}", task.Id, result.SessionId);
+ var retryConfig = resolvedConfig with { ResumeSessionId = result.SessionId };
+ var retryPrompt = $"The previous attempt failed with:\n\n{result.ErrorMarkdown}\n\nTry again and fix the issues.";
+
+ await _broadcaster.RunCreated(task.Id, 2, true);
+ var retryResult = await RunOnceAsync(task.Id, slot, runDir, retryConfig, 2, true, retryPrompt, ct);
+
+ if (retryResult.IsSuccess)
+ {
+ await HandleSuccess(task, list, slot, wtCtx, retryResult, ct);
+ }
+ else
+ {
+ await HandleFailure(task.Id, slot, retryResult);
+ }
+ }
+ else
+ {
+ await HandleFailure(task.Id, slot, result);
+ }
+ }
+
+ await _broadcaster.TaskUpdated(task.Id);
+ }
+ catch (OperationCanceledException)
+ {
+ _logger.LogInformation("Task {TaskId} was cancelled", task.Id);
+ await MarkFailed(task.Id, slot, "Task cancelled.");
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Unhandled exception running task {TaskId}", task.Id);
+ await MarkFailed(task.Id, slot, $"Unhandled error: {ex.Message}");
+ }
+ }
+
+ public async Task ContinueAsync(string taskId, string followUpPrompt, string slot, CancellationToken ct)
+ {
+ var task = await _taskRepo.GetByIdAsync(taskId, ct)
+ ?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
+
+ var lastRun = await _runRepo.GetLatestByTaskIdAsync(taskId, ct)
+ ?? throw new InvalidOperationException("No previous run to continue.");
+
+ if (lastRun.SessionId is null)
+ throw new InvalidOperationException("Previous run has no session ID — cannot resume.");
+
+ var list = await _listRepo.GetByIdAsync(task.ListId, ct)
+ ?? throw new InvalidOperationException("List not found.");
+
+ var listConfig = await _listRepo.GetConfigAsync(task.ListId, ct);
+ var resolvedConfig = new ClaudeRunConfig(
+ Model: task.Model ?? listConfig?.Model,
+ SystemPrompt: task.SystemPrompt ?? listConfig?.SystemPrompt,
+ AgentPath: task.AgentPath ?? listConfig?.AgentPath,
+ ResumeSessionId: lastRun.SessionId
+ );
+
+ // Determine run directory from existing worktree or sandbox.
+ string runDir;
+ WorktreeContext? wtCtx = null;
+ var worktree = await _wtRepo.GetByTaskIdAsync(taskId, ct);
+ if (worktree is not null)
+ {
+ runDir = worktree.Path;
+ wtCtx = new WorktreeContext(worktree.Path, worktree.BranchName, worktree.BaseCommit);
+ }
+ else
+ {
+ runDir = Path.Combine(_cfg.SandboxRoot, taskId);
+ }
+
+ var now = DateTime.UtcNow;
+ await _taskRepo.MarkRunningAsync(taskId, now, ct);
+ await _broadcaster.TaskStarted(slot, taskId, now);
+
+ var nextRunNumber = lastRun.RunNumber + 1;
+ await _broadcaster.RunCreated(taskId, nextRunNumber, false);
+ var result = await RunOnceAsync(taskId, slot, runDir, resolvedConfig, nextRunNumber, false, followUpPrompt, ct);
+
+ if (result.IsSuccess)
+ {
+ await HandleSuccess(task, list, slot, wtCtx, result, ct);
+ }
+ else
+ {
+ await HandleFailure(taskId, slot, result);
+ }
+
+ await _broadcaster.TaskUpdated(taskId);
+ }
+
+ private async Task RunOnceAsync(
+ string taskId, string slot, string runDir, ClaudeRunConfig config,
+ int runNumber, bool isRetry, string prompt, CancellationToken ct)
+ {
+ var runId = Guid.NewGuid().ToString();
+ var logPath = Path.Combine(_cfg.LogRoot, $"{taskId}_run{runNumber}.ndjson");
+
+ var run = new TaskRunEntity
+ {
+ Id = runId,
+ TaskId = taskId,
+ RunNumber = runNumber,
+ IsRetry = isRetry,
+ Prompt = prompt,
+ LogPath = logPath,
+ StartedAt = DateTime.UtcNow,
+ };
+ await _runRepo.AddAsync(run, ct);
+
+ var arguments = _argsBuilder.Build(config);
+
+ await using var logWriter = new LogWriter(logPath);
+
+ var result = await _claude.RunAsync(
+ arguments,
+ prompt,
+ runDir,
+ async line =>
+ {
+ await logWriter.WriteLineAsync(line, ct);
+ await _broadcaster.TaskMessage(taskId, line);
+ },
+ ct);
+
+ // Update the run record with results.
+ run.SessionId = result.SessionId;
+ run.ResultMarkdown = result.ResultMarkdown;
+ run.StructuredOutputJson = result.StructuredOutputJson;
+ run.ErrorMarkdown = result.ErrorMarkdown;
+ run.ExitCode = result.ExitCode;
+ run.TurnCount = result.TurnCount;
+ run.TokensIn = result.TokensIn;
+ run.TokensOut = result.TokensOut;
+ run.FinishedAt = DateTime.UtcNow;
+ await _runRepo.UpdateAsync(run, ct);
+
+ // Update denormalized fields on the task.
+ await _taskRepo.SetLogPathAsync(taskId, logPath, ct);
+
+ return result;
+ }
+
+ private async Task HandleSuccess(TaskEntity task, ListEntity list, string slot, WorktreeContext? wtCtx, RunResult result, CancellationToken ct)
+ {
+ if (wtCtx is not null)
+ {
+ var committed = await _wtManager.CommitIfChangedAsync(wtCtx, task, list, ct);
+ if (committed)
+ await _broadcaster.WorktreeUpdated(task.Id);
+ }
+
+ var finishedAt = DateTime.UtcNow;
+ await _taskRepo.MarkDoneAsync(task.Id, finishedAt, result.ResultMarkdown, ct);
+ await _broadcaster.TaskFinished(slot, task.Id, "done", finishedAt);
+ _logger.LogInformation("Task {TaskId} completed (turns={Turns}, tokens_in={In}, tokens_out={Out})",
+ task.Id, result.TurnCount, result.TokensIn, result.TokensOut);
+ }
+
+ private async Task HandleFailure(string taskId, string slot, RunResult result)
+ {
+ var finishedAt = DateTime.UtcNow;
+ await _taskRepo.MarkFailedAsync(taskId, finishedAt, result.ErrorMarkdown);
+ await _broadcaster.TaskFinished(slot, taskId, "failed", finishedAt);
+ _logger.LogWarning("Task {TaskId} failed (turns={Turns}): {Error}", taskId, result.TurnCount, result.ErrorMarkdown);
+ }
+
+ private async Task MarkFailed(string taskId, string slot, string error)
+ {
+ try
+ {
+ var now = DateTime.UtcNow;
+ await _taskRepo.MarkFailedAsync(taskId, now, error);
+ await _broadcaster.TaskFinished(slot, taskId, "failed", now);
+ await _broadcaster.TaskUpdated(taskId);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to mark task {TaskId} as failed", taskId);
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Build and check for compilation errors**
+
+Run:
+```bash
+dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
+```
+
+Expected: may fail on missing DI registrations (Program.cs) — that's Task 12.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/ClaudeDo.Worker/Runner/TaskRunner.cs
+git commit -m "refactor(worker): rewrite TaskRunner with config resolution, retry, and continue support"
+```
+
+---
+
+### Task 12: HubBroadcaster + WorkerHub — New methods
+
+**Files:**
+- Modify: `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs:1-25`
+- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs:1-44`
+
+- [ ] **Step 1: Add RunCreated broadcast to HubBroadcaster**
+
+Add after the `TaskUpdated` method:
+
+```csharp
+public Task RunCreated(string taskId, int runNumber, bool isRetry) =>
+ _hub.Clients.All.SendAsync("RunCreated", taskId, runNumber, isRetry);
+```
+
+- [ ] **Step 2: Add ContinueTask, GetAgents, RefreshAgents to WorkerHub**
+
+Update WorkerHub to inject `AgentFileService` and expose new methods. Replace entire file:
+
+```csharp
+using System.Reflection;
+using ClaudeDo.Data.Models;
+using ClaudeDo.Worker.Services;
+using Microsoft.AspNetCore.SignalR;
+
+namespace ClaudeDo.Worker.Hub;
+
+public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
+{
+ private static readonly string Version =
+ Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0";
+
+ private readonly QueueService _queue;
+ private readonly AgentFileService _agentService;
+
+ public WorkerHub(QueueService queue, AgentFileService agentService)
+ {
+ _queue = queue;
+ _agentService = agentService;
+ }
+
+ public string Ping() => $"pong v{Version}";
+
+ public IReadOnlyList GetActive()
+ {
+ return _queue.GetActive()
+ .Select(a => (object)new { slot = a.slot, taskId = a.taskId, startedAt = a.startedAt })
+ .ToList();
+ }
+
+ public async Task RunNow(string taskId)
+ {
+ try
+ {
+ await _queue.RunNow(taskId);
+ }
+ catch (InvalidOperationException)
+ {
+ throw new HubException("override slot busy");
+ }
+ catch (KeyNotFoundException)
+ {
+ throw new HubException("task not found");
+ }
+ }
+
+ public async Task ContinueTask(string taskId, string followUpPrompt)
+ {
+ try
+ {
+ return await _queue.ContinueTask(taskId, followUpPrompt);
+ }
+ catch (InvalidOperationException ex)
+ {
+ throw new HubException(ex.Message);
+ }
+ catch (KeyNotFoundException)
+ {
+ throw new HubException("task not found");
+ }
+ }
+
+ public bool CancelTask(string taskId) => _queue.CancelTask(taskId);
+
+ public void WakeQueue() => _queue.WakeQueue();
+
+ public async Task> GetAgents() => await _agentService.ScanAsync();
+
+ public async Task RefreshAgents() => await _agentService.ScanAsync();
+}
+```
+
+- [ ] **Step 3: Build to check**
+
+Run:
+```bash
+dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
+```
+
+Expected: may fail — `QueueService.ContinueTask` doesn't exist yet (Task 13).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/ClaudeDo.Worker/Hub/HubBroadcaster.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs
+git commit -m "feat(worker): add ContinueTask, GetAgents, RefreshAgents hub methods and RunCreated broadcast"
+```
+
+---
+
+### Task 13: QueueService — Route ContinueTask
+
+**Files:**
+- Modify: `src/ClaudeDo.Worker/Services/QueueService.cs:57-76`
+
+- [ ] **Step 1: Add ContinueTask method to QueueService**
+
+Add after the `RunNow` method:
+
+```csharp
+public async Task ContinueTask(string taskId, string followUpPrompt)
+{
+ var task = await _taskRepo.GetByIdAsync(taskId)
+ ?? throw new KeyNotFoundException($"Task '{taskId}' not found.");
+
+ if (task.Status == Data.Models.TaskStatus.Running)
+ throw new InvalidOperationException("Task is currently running.");
+
+ lock (_lock)
+ {
+ if (_overrideSlot is not null)
+ throw new InvalidOperationException("override slot busy");
+
+ var cts = new CancellationTokenSource();
+ _overrideSlot = new QueueSlotState { TaskId = taskId, StartedAt = DateTime.UtcNow, Cts = cts };
+
+ _ = RunContinueInSlotAsync(taskId, followUpPrompt, cts.Token).ContinueWith(_ =>
+ {
+ lock (_lock) { _overrideSlot = null; }
+ }, TaskScheduler.Default);
+ }
+
+ return taskId;
+}
+
+private async Task RunContinueInSlotAsync(string taskId, string followUpPrompt, CancellationToken ct)
+{
+ try
+ {
+ _logger.LogInformation("Continuing task {TaskId} in override slot", taskId);
+ await _runner.ContinueAsync(taskId, followUpPrompt, "override", ct);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Continue runner error for task {TaskId}", taskId);
+ }
+}
+```
+
+- [ ] **Step 2: Build the full solution**
+
+Run:
+```bash
+dotnet build ClaudeDo.slnx
+```
+
+Expected: may fail due to missing DI registrations (Task 14).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/ClaudeDo.Worker/Services/QueueService.cs
+git commit -m "feat(worker): add ContinueTask routing to QueueService"
+```
+
+---
+
+### Task 14: Program.cs — DI registration for new services
+
+**Files:**
+- Modify: `src/ClaudeDo.Worker/Program.cs:1-47`
+
+- [ ] **Step 1: Register new services**
+
+Add after the existing `builder.Services.AddSingleton();` line:
+
+```csharp
+builder.Services.AddSingleton();
+```
+
+Add after the `builder.Services.AddSingleton();` line:
+
+```csharp
+builder.Services.AddSingleton();
+```
+
+Add after the `builder.Services.AddSingleton();` line (before the QueueService block):
+
+```csharp
+// Agent file management.
+var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents");
+Directory.CreateDirectory(agentsDir);
+builder.Services.AddSingleton(new AgentFileService(agentsDir));
+```
+
+Also add the using at the top:
+
+```csharp
+using ClaudeDo.Worker.Services;
+```
+
+- [ ] **Step 2: Build the full solution**
+
+Run:
+```bash
+dotnet build ClaudeDo.slnx
+```
+
+Expected: clean build, no errors.
+
+- [ ] **Step 3: Run all tests**
+
+Run:
+```bash
+dotnet test tests/ClaudeDo.Worker.Tests -v minimal
+```
+
+Expected: all tests pass (some may need FakeClaudeProcess updates from Task 9 step 3).
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/ClaudeDo.Worker/Program.cs
+git commit -m "feat(worker): register TaskRunRepository, ClaudeArgsBuilder, and AgentFileService in DI"
+```
+
+---
+
+### Task 15: Delete MessageParser + update tests
+
+**Files:**
+- Delete: `src/ClaudeDo.Worker/Runner/MessageParser.cs`
+- Delete: `tests/ClaudeDo.Worker.Tests/Runner/MessageParserTests.cs`
+
+- [ ] **Step 1: Verify no references to MessageParser remain**
+
+Run:
+```bash
+grep -r "MessageParser" src/ tests/ --include="*.cs"
+```
+
+Expected: only hits in the files being deleted (ClaudeProcess no longer calls it after Task 9).
+
+- [ ] **Step 2: Delete the files**
+
+```bash
+rm src/ClaudeDo.Worker/Runner/MessageParser.cs
+rm tests/ClaudeDo.Worker.Tests/Runner/MessageParserTests.cs
+```
+
+- [ ] **Step 3: Build and run all tests**
+
+Run:
+```bash
+dotnet build ClaudeDo.slnx && dotnet test tests/ClaudeDo.Worker.Tests -v minimal
+```
+
+Expected: clean build, all tests pass.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add -A
+git commit -m "refactor(worker): remove MessageParser (replaced by StreamAnalyzer)"
+```
+
+---
+
+### Task 16: Integration smoke test — Full run with retry
+
+**Files:**
+- Modify: `tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs`
+
+- [ ] **Step 1: Add a test for auto-retry flow**
+
+Add a new test to `QueueServiceTests`:
+
+```csharp
+[Fact]
+public async Task RunNow_AutoRetries_On_Failure_With_SessionId()
+{
+ var callCount = 0;
+ var fake = new FakeClaudeProcess((prompt, dir, args, onLine, ct) =>
+ {
+ callCount++;
+ if (callCount == 1)
+ {
+ return Task.FromResult(new RunResult
+ {
+ ExitCode = 1,
+ ErrorMarkdown = "something broke",
+ SessionId = "sess-retry-test",
+ });
+ }
+ return Task.FromResult(new RunResult
+ {
+ ExitCode = 0,
+ ResultMarkdown = "fixed it",
+ SessionId = "sess-retry-test",
+ });
+ });
+
+ // Build a QueueService with the fake and run a task through it.
+ // Verify callCount == 2 (initial + retry).
+ // Verify the task ends as "done".
+ Assert.Equal(2, callCount);
+}
+```
+
+Note: this test skeleton needs the full QueueService wiring that's already in the test file. Follow the existing test patterns for `RunNow` — set up DbFixture, create list + task, construct QueueService with fake dependencies, call `RunNow`, await completion, assert.
+
+- [ ] **Step 2: Run the test**
+
+Run:
+```bash
+dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~AutoRetries" -v minimal
+```
+
+Expected: test passes.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add tests/ClaudeDo.Worker.Tests/Services/QueueServiceTests.cs
+git commit -m "test(worker): add integration test for auto-retry flow"
+```
+
+---
+
+### Task 17: Update Worker CLAUDE.md
+
+**Files:**
+- Modify: `src/ClaudeDo.Worker/CLAUDE.md`
+
+- [ ] **Step 1: Update documentation to reflect changes**
+
+Add sections for:
+- New config resolution flow (list_config + task overrides)
+- `task_runs` table and execution tracking
+- Auto-retry and continue flow
+- `ClaudeArgsBuilder`, `StreamAnalyzer` (replacing `MessageParser`)
+- `AgentFileService` and agents directory
+- New hub methods: `ContinueTask`, `GetAgents`, `RefreshAgents`
+- New broadcast: `RunCreated`
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add src/ClaudeDo.Worker/CLAUDE.md
+git commit -m "docs(worker): update CLAUDE.md with CLI modernization changes"
+```
diff --git a/docs/superpowers/specs/2026-04-14-worker-cli-modernization-design.md b/docs/superpowers/specs/2026-04-14-worker-cli-modernization-design.md
new file mode 100644
index 0000000..f3fc282
--- /dev/null
+++ b/docs/superpowers/specs/2026-04-14-worker-cli-modernization-design.md
@@ -0,0 +1,514 @@
+# Worker CLI Modernization
+
+**Date:** 2026-04-14
+**Status:** Approved
+**Scope:** ClaudeDo.Worker — CLI invocation, execution tracking, per-task configuration, multi-turn support
+
+## Problem
+
+The Worker currently invokes Claude CLI with hardcoded flags (`-p --output-format stream-json --verbose --dangerously-skip-permissions`). There is no way to configure model, system prompt, or agent per list or task. Execution is single-shot with no retry or follow-up capability. Results are stored as a single markdown blob on the `tasks` row with no structured metadata, token usage, or turn count.
+
+## Goals
+
+1. Per-list configuration (model, system prompt, agent file) with per-task overrides
+2. Execution history — each CLI invocation tracked as its own `task_runs` row
+3. Multi-turn support — manual continue and auto-retry via `--resume`
+4. Structured output alongside markdown via `--json-schema`
+5. Agent file management — filesystem-based `.md` agents with UI to browse/create/edit
+6. Richer stream parsing — token usage, turn count, session ID, retry events
+
+## Non-Goals (Deferred)
+
+- `--bare` mode (forces API key; user relies on OAuth/keychain auth)
+- `--allowedTools` / permission modes (keep `--dangerously-skip-permissions`)
+- Schema migration framework (use `IF NOT EXISTS` / `INSERT OR IGNORE` for additive changes)
+
+---
+
+## 1. Schema Changes
+
+### 1.1 New table: `list_config`
+
+One-to-one with `lists`. Stores per-list defaults for CLI invocation.
+
+```sql
+CREATE TABLE IF NOT EXISTS list_config (
+ list_id TEXT PRIMARY KEY REFERENCES lists(id) ON DELETE CASCADE,
+ model TEXT NULL, -- 'opus-4-6' | 'sonnet-4-6' | 'haiku-4-5'
+ system_prompt TEXT NULL, -- appended via --append-system-prompt
+ agent_path TEXT NULL -- path to agent .md file, passed via --agents
+);
+```
+
+### 1.2 New columns on `tasks`
+
+Per-task overrides. All nullable — NULL means "use list default".
+
+```sql
+ALTER TABLE tasks ADD COLUMN model TEXT NULL;
+ALTER TABLE tasks ADD COLUMN system_prompt TEXT NULL;
+ALTER TABLE tasks ADD COLUMN agent_path TEXT NULL;
+```
+
+Since schema uses `IF NOT EXISTS` and is re-applied on startup, these are added via `ALTER TABLE ... ADD COLUMN` wrapped in a try/catch (SQLite raises "duplicate column" if already present — safe to ignore).
+
+### 1.3 New table: `task_runs`
+
+One row per CLI invocation. Supports multi-turn and retry tracking.
+
+```sql
+CREATE TABLE IF NOT EXISTS task_runs (
+ id TEXT PRIMARY KEY,
+ task_id TEXT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
+ run_number INTEGER NOT NULL, -- 1, 2, 3... sequential per task
+ session_id TEXT NULL, -- Claude CLI session ID (for --resume)
+ is_retry INTEGER NOT NULL DEFAULT 0, -- 0 = normal/continue, 1 = auto-retry
+ prompt TEXT NOT NULL, -- the prompt sent for this run
+ result_markdown TEXT NULL, -- free-form result from 'result' field
+ structured_output TEXT NULL, -- JSON from 'structured_output' field
+ error_markdown TEXT NULL, -- error output on failure
+ exit_code INTEGER NULL, -- CLI exit code
+ turn_count INTEGER NULL, -- number of agent loop turns
+ tokens_in INTEGER NULL, -- total input tokens
+ tokens_out INTEGER NULL, -- total output tokens
+ log_path TEXT NULL, -- NDJSON log file for this run
+ started_at TIMESTAMP NULL,
+ finished_at TIMESTAMP NULL
+);
+
+CREATE INDEX IF NOT EXISTS idx_task_runs_task_id ON task_runs(task_id);
+```
+
+### 1.4 Denormalized fields on `tasks`
+
+Keep existing `result`, `log_path`, `started_at`, `finished_at` on the `tasks` table. After each run completes, update them with the latest run's values. This preserves backward compatibility for UI queries that read `tasks` directly.
+
+### 1.5 Model validation
+
+Valid model values: `opus-4-6`, `sonnet-4-6`, `haiku-4-5`. Validated at the application layer (repository/service), not via SQL CHECK constraint, to allow easy future additions.
+
+---
+
+## 2. Agent File Management
+
+### 2.1 Directory
+
+Agents live in `~/.todo-app/agents/`. The directory is created on Worker startup if absent.
+
+### 2.2 File format
+
+Standard Claude agent markdown with YAML frontmatter:
+
+```markdown
+---
+name: .NET Developer
+description: Senior .NET developer focused on clean architecture
+---
+
+You are a senior .NET developer. Follow existing project patterns...
+```
+
+### 2.3 AgentFileService
+
+New service in `ClaudeDo.Worker` (not a repository — operates on filesystem, not DB):
+
+| Method | Description |
+|--------|-------------|
+| `ScanAsync()` | Returns `List` — parse frontmatter for name/description from all `*.md` in agents dir |
+| `ReadAsync(string path)` | Full file content |
+| `WriteAsync(string path, string content)` | Create or overwrite |
+| `DeleteAsync(string path)` | Remove file |
+
+### 2.4 AgentInfo DTO
+
+```csharp
+public sealed record AgentInfo(string Name, string Description, string Path);
+```
+
+### 2.5 Discovery
+
+- Worker scans on startup and exposes agents via a new SignalR method `GetAgents()`.
+- UI calls `GetAgents()` to populate dropdowns.
+- A `RefreshAgents()` hub method triggers a re-scan (for after UI creates/edits a file).
+
+---
+
+## 3. CLI Invocation Changes
+
+### 3.1 Current invocation
+
+```
+claude -p --output-format stream-json --verbose --dangerously-skip-permissions
+```
+
+Prompt written to stdin. Single-shot, no config, no structured output.
+
+### 3.2 New invocation
+
+Built dynamically per run by `ClaudeArgsBuilder`:
+
+```
+claude -p
+ --output-format stream-json
+ --verbose
+ --dangerously-skip-permissions
+ --model # if set
+ --append-system-prompt # if set
+ --agents '[{"file":""}]' # if set
+ --json-schema # always
+ --resume # only for multi-turn/retry
+```
+
+### 3.3 Config resolution
+
+```
+resolved_model = task.model ?? list_config.model ?? null (omit --model)
+resolved_prompt = task.system_prompt ?? list_config.system_prompt ?? null (omit --append-system-prompt)
+resolved_agent = task.agent_path ?? list_config.agent_path ?? null (omit --agents)
+```
+
+### 3.4 Structured output schema
+
+Passed via `--json-schema` on every invocation:
+
+```json
+{
+ "type": "object",
+ "properties": {
+ "summary": { "type": "string" },
+ "files_changed": {
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "commit_type": { "type": "string" }
+ },
+ "required": ["summary"]
+}
+```
+
+The CLI returns this in the `structured_output` field of the JSON result event. The markdown result remains in the `result` field.
+
+### 3.5 ClaudeArgsBuilder
+
+New class, single responsibility for argument construction:
+
+```csharp
+public sealed class ClaudeArgsBuilder
+{
+ // Returns the full argument string for ProcessStartInfo.Arguments
+ public string Build(ClaudeRunConfig config);
+}
+
+public sealed record ClaudeRunConfig(
+ string? Model,
+ string? SystemPrompt,
+ string? AgentPath,
+ string? ResumeSessionId
+);
+```
+
+Testable in isolation — no process spawning, just string building.
+
+---
+
+## 4. Stream Parsing
+
+### 4.1 StreamAnalyzer (replaces MessageParser)
+
+Processes each NDJSON line and accumulates metrics:
+
+| Responsibility | How |
+|---|---|
+| Extract result markdown | Look for `type: "result"`, read `.result` field |
+| Extract structured output | Same event, read `.structured_output` field |
+| Extract session ID | Read `.session_id` from the result event |
+| Count turns | Count events where `.type == "assistant"` |
+| Accumulate tokens | Sum `.usage.input_tokens` and `.usage.output_tokens` from each turn |
+| Track retries | Count `system/api_retry` events (informational logging) |
+
+### 4.2 StreamResult
+
+```csharp
+public sealed class StreamResult
+{
+ public string? ResultMarkdown { get; set; }
+ public string? StructuredOutputJson { get; set; }
+ public string? SessionId { get; set; }
+ public int TurnCount { get; set; }
+ public int TokensIn { get; set; }
+ public int TokensOut { get; set; }
+ public int ApiRetryCount { get; set; }
+}
+```
+
+### 4.3 Extended RunResult
+
+```csharp
+public sealed class RunResult
+{
+ public required int ExitCode { get; init; }
+ public string? ResultMarkdown { get; init; }
+ public string? ErrorMarkdown { get; init; }
+ public string? StructuredOutputJson { get; init; }
+ public string? SessionId { get; init; }
+ public int TurnCount { get; init; }
+ public int TokensIn { get; init; }
+ public int TokensOut { get; init; }
+
+ public bool IsSuccess => ExitCode == 0 && ResultMarkdown is not null;
+}
+```
+
+---
+
+## 5. Multi-Turn & Auto-Retry
+
+### 5.1 Execution flow
+
+```
+Task queued
+ -> Run 1 (run_number=1, is_retry=0)
+ -> Resolve config (list defaults + task overrides)
+ -> Build CLI args (no --resume on first run)
+ -> Spawn claude, stream output, parse via StreamAnalyzer
+ -> Create task_runs row with all metrics
+ -> Update denormalized tasks fields
+
+ If failure (exit_code != 0):
+ -> Auto-retry: Run 2 (run_number=2, is_retry=1)
+ -> Prompt: "The previous attempt failed with:\n\n{error_markdown}\n\nTry again and fix the issues."
+ -> Uses --resume from Run 1
+ -> Same worktree, same config
+ -> Create new task_runs row
+ -> If still fails: mark task Failed, stop
+
+ If success (exit_code == 0):
+ -> Auto-commit in worktree if changes
+ -> Mark task Done
+
+User triggers "Continue" on finished/failed task:
+ -> New run (run_number=N+1, is_retry=0)
+ -> User-provided follow-up prompt
+ -> Uses --resume from last run
+ -> Task status -> Running -> Done/Failed
+```
+
+### 5.2 Rules
+
+- Max 1 auto-retry per task execution (no retry loops)
+- Auto-retry reuses the session via `--resume` (full context of prior failure)
+- Manual continue works on both Done and Failed tasks
+- Each run gets its own log file: `{task_id}_run{N}.ndjson`
+- Worktree commit happens only after a successful run
+- If Run 1 has no session_id (edge case: CLI crashed before producing one), skip auto-retry
+
+### 5.3 Continue via SignalR
+
+New hub method: `ContinueTask(string taskId, string followUpPrompt)` -> returns `string runId`
+
+Validation:
+- Task must exist
+- Task must not be currently running
+- Previous run must have a session_id
+
+---
+
+## 6. TaskRunner Refactoring
+
+### 6.1 Current flow (TaskRunner.RunAsync)
+
+1. Load list, create worktree/sandbox, mark running
+2. Build prompt from title + description
+3. Call `_claude.RunAsync(prompt, dir, logPath, taskId, callback, ct)`
+4. Handle result: commit on success, mark done/failed
+
+### 6.2 New flow
+
+```csharp
+public async Task RunAsync(TaskEntity task, string slot, CancellationToken ct)
+{
+ // 1. Load list + list_config
+ // 2. Resolve config (merge list_config + task overrides)
+ // 3. Create worktree/sandbox (unchanged)
+ // 4. Execute run (see RunOnceAsync below)
+ // 5. If failed and no prior retry: auto-retry
+ // 6. Final status update
+}
+
+public async Task ContinueAsync(string taskId, string followUpPrompt, string slot, CancellationToken ct)
+{
+ // 1. Load task, last run (for session_id)
+ // 2. Mark task running
+ // 3. Execute run with --resume
+ // 4. Commit if success + worktree
+ // 5. Final status update
+}
+
+private async Task RunOnceAsync(
+ TaskEntity task, string slot, string runDir, ClaudeRunConfig config,
+ int runNumber, bool isRetry, string prompt, CancellationToken ct)
+{
+ // 1. Create task_runs row (started_at = now)
+ // 2. Build log path: {task_id}_run{runNumber}.ndjson
+ // 3. Build CLI args via ClaudeArgsBuilder
+ // 4. Spawn ClaudeProcess
+ // 5. Stream lines to LogWriter + StreamAnalyzer + HubBroadcaster
+ // 6. Build RunResult from StreamAnalyzer
+ // 7. Update task_runs row (finished_at, metrics, result)
+ // 8. Update denormalized tasks fields
+ // 9. Return RunResult
+}
+```
+
+### 6.3 ClaudeProcess changes
+
+Simplified — receives pre-built args, no longer constructs its own:
+
+```csharp
+public async Task RunAsync(
+ string arguments, // pre-built by ClaudeArgsBuilder
+ string prompt, // written to stdin
+ string workingDirectory,
+ Func onStdoutLine,
+ CancellationToken ct)
+```
+
+The `StreamAnalyzer` instance is owned by the caller (TaskRunner), not ClaudeProcess. ClaudeProcess just feeds lines via the callback.
+
+---
+
+## 7. Repository Changes
+
+### 7.1 New: TaskRunRepository
+
+| Method | Description |
+|--------|-------------|
+| `AddAsync(TaskRunEntity)` | Insert new run |
+| `UpdateAsync(TaskRunEntity)` | Update after completion |
+| `GetByTaskIdAsync(string taskId)` | All runs for a task, ordered by run_number |
+| `GetLatestByTaskIdAsync(string taskId)` | Most recent run (for session_id lookup) |
+| `GetByIdAsync(string runId)` | Single run |
+
+### 7.2 Extended: ListRepository
+
+| Method | Description |
+|--------|-------------|
+| `GetConfigAsync(string listId)` | Returns `ListConfigEntity?` |
+| `SetConfigAsync(ListConfigEntity)` | Upsert via INSERT OR REPLACE |
+
+### 7.3 New models
+
+```csharp
+public sealed class TaskRunEntity
+{
+ public required string Id { get; init; }
+ public required string TaskId { get; init; }
+ public required int RunNumber { get; init; }
+ public string? SessionId { get; set; }
+ public required bool IsRetry { get; init; }
+ public required string Prompt { get; init; }
+ public string? ResultMarkdown { get; set; }
+ public string? StructuredOutputJson { get; set; }
+ public string? ErrorMarkdown { get; set; }
+ public int? ExitCode { get; set; }
+ public int? TurnCount { get; set; }
+ public int? TokensIn { get; set; }
+ public int? TokensOut { get; set; }
+ public string? LogPath { get; set; }
+ public DateTime? StartedAt { get; set; }
+ public DateTime? FinishedAt { get; set; }
+}
+
+public sealed class ListConfigEntity
+{
+ public required string ListId { get; init; }
+ public string? Model { get; set; }
+ public string? SystemPrompt { get; set; }
+ public string? AgentPath { get; set; }
+}
+```
+
+---
+
+## 8. SignalR Hub Changes
+
+### 8.1 New server methods
+
+| Method | Description |
+|--------|-------------|
+| `ContinueTask(string taskId, string followUpPrompt)` | Trigger follow-up run. Returns `string runId`. Throws if running or no session. |
+| `GetAgents()` | Returns `List` from AgentFileService scan |
+| `RefreshAgents()` | Re-scan agents directory |
+
+### 8.2 Updated broadcasts
+
+| Event | Change |
+|-------|--------|
+| `TaskStarted(slot, taskId, runId, runNumber, startedAt)` | Added `runId`, `runNumber` |
+| `TaskFinished(slot, taskId, runId, status, finishedAt)` | Added `runId` |
+| `TaskMessage(taskId, runId, ndjsonLine)` | Added `runId` |
+| `RunCreated(taskId, runId, runNumber, isRetry)` | New — signals retry/continue started |
+
+### 8.3 Unchanged
+
+`Ping`, `GetActive`, `CancelTask`, `WakeQueue`, `WorktreeUpdated`, `TaskUpdated` — no changes.
+
+---
+
+## 9. File Structure (New/Changed)
+
+```
+src/ClaudeDo.Worker/
+ Runner/
+ ClaudeArgsBuilder.cs NEW — CLI argument construction
+ StreamAnalyzer.cs NEW — replaces MessageParser
+ StreamResult.cs NEW — accumulated stream metrics
+ RunResult.cs CHANGED — extended with tokens, turns, session_id
+ ClaudeProcess.cs CHANGED — simplified, takes pre-built args
+ TaskRunner.cs CHANGED — retry/continue logic, config resolution
+ MessageParser.cs DELETED — replaced by StreamAnalyzer
+ Services/
+ AgentFileService.cs NEW — filesystem agent management
+
+src/ClaudeDo.Data/
+ Models/
+ TaskRunEntity.cs NEW
+ ListConfigEntity.cs NEW
+ AgentInfo.cs NEW — DTO (name, description, path)
+ Repositories/
+ TaskRunRepository.cs NEW
+ ListRepository.cs CHANGED — GetConfigAsync, SetConfigAsync
+
+schema/
+ schema.sql CHANGED — list_config table, task_runs table, tasks columns
+```
+
+---
+
+## 10. Testing Strategy
+
+### 10.1 Unit tests (new)
+
+| Test class | Covers |
+|------------|--------|
+| `ClaudeArgsBuilderTests` | Arg construction with all config combos, omitted flags for null values |
+| `StreamAnalyzerTests` | Turn counting, token accumulation, result extraction, session_id, retry events, malformed input |
+| `AgentFileServiceTests` | Scan, frontmatter parsing, read/write/delete, missing directory handling |
+
+### 10.2 Unit tests (updated)
+
+| Test class | Changes |
+|------------|---------|
+| `TaskRunnerTests` | New: auto-retry flow, continue flow, config resolution |
+| `QueueServiceTests` | New: continue task routing |
+
+### 10.3 Integration tests (new)
+
+| Test class | Covers |
+|------------|--------|
+| `TaskRunRepositoryTests` | CRUD, ordering, latest-by-task queries |
+| `ListRepositoryConfigTests` | GetConfig, SetConfig upsert behavior |
+
+### 10.4 Existing tests (MessageParserTests)
+
+Removed along with `MessageParser`. Equivalent coverage moves to `StreamAnalyzerTests`.
diff --git a/src/ClaudeDo.App/CLAUDE.md b/src/ClaudeDo.App/CLAUDE.md
new file mode 100644
index 0000000..a752d3a
--- /dev/null
+++ b/src/ClaudeDo.App/CLAUDE.md
@@ -0,0 +1,29 @@
+# ClaudeDo.App
+
+Desktop entry point for the ClaudeDo application. Configures DI, initializes the database, and launches the Avalonia window.
+
+## Responsibility
+
+- `Program.cs` — STA thread, DI container registration (repositories, services, viewmodels), schema init, Avalonia builder
+- `App.axaml` / `App.axaml.cs` — Avalonia application lifecycle, main window creation, static `ServiceProvider` accessor
+- `ViewLocator.cs` — reflection-based IDataTemplate that maps ViewModels to Views by naming convention
+
+## Dependencies
+
+- Avalonia 12.0.0 (Desktop, Fluent theme, Inter fonts)
+- CommunityToolkit.Mvvm 8.4.1
+- Microsoft.Extensions.DependencyInjection 8.0.1
+- Microsoft.AspNetCore.SignalR.Client 8.0.11
+- Microsoft.Data.Sqlite 8.0.11
+- Project references: ClaudeDo.Data, ClaudeDo.Ui
+
+## DI Registration Pattern
+
+- **Singletons**: SqliteConnectionFactory, all Repositories, WorkerClient, MainWindowViewModel, TaskListViewModel, TaskDetailViewModel, StatusBarViewModel
+- **Transients**: TaskEditorViewModel, ListEditorViewModel (created per dialog)
+
+## Notes
+
+- This project owns the composition root — all wiring happens here
+- ViewLocator resolves `FooViewModel` -> `FooView` by replacing "ViewModel" with "View" in the type name
+- AvaloniaUI diagnostics are conditionally included (DEBUG only)
diff --git a/src/ClaudeDo.Data/CLAUDE.md b/src/ClaudeDo.Data/CLAUDE.md
new file mode 100644
index 0000000..6720add
--- /dev/null
+++ b/src/ClaudeDo.Data/CLAUDE.md
@@ -0,0 +1,41 @@
+# ClaudeDo.Data
+
+Shared data layer: models, repositories, SQLite infrastructure, and git operations.
+
+## Models
+
+- **TaskEntity** — Id, ListId, Title, Description, Status (Manual|Queued|Running|Done|Failed), ScheduledFor, Result, LogPath, timestamps, CommitType
+- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt
+- **TagEntity** — Id (autoincrement), Name (unique)
+- **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, State (Active|Merged|Discarded|Kept)
+
+## Repositories
+
+All repositories use raw parameterized SQL via `Microsoft.Data.Sqlite`. Each method opens its own connection — no Unit of Work.
+
+- **TaskRepository** — CRUD, status transitions (`MarkRunningAsync`, `MarkDoneAsync`, `MarkFailedAsync`), `GetNextQueuedAgentTaskAsync` (queue polling), `GetEffectiveTagsAsync` (union of task + list tags), `FlipAllRunningToFailedAsync`
+- **ListRepository** — CRUD, tag junction management
+- **TagRepository** — `GetOrCreateAsync` (idempotent)
+- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`
+
+## Infrastructure
+
+- **SqliteConnectionFactory** — creates connections, applies WAL mode once, enforces foreign keys via PRAGMA
+- **SchemaInitializer** — applies embedded `schema/schema.sql` idempotently (IF NOT EXISTS, INSERT OR IGNORE)
+- **Paths** — expands `~` and `%USERPROFILE%`, resolves relative paths. App root: `~/.todo-app`
+- **AppSettings** — loads `~/.todo-app/ui.config.json` (DbPath, SignalRUrl)
+
+## Git
+
+- **GitService** — async wrapper around git CLI (ProcessStartInfo, no shell). Operations: worktree add/remove, add all, commit (stdin for message), merge ff-only, rev-parse, diff-stat, has-changes, is-git-repo
+
+## Schema
+
+6 tables: `lists`, `tasks`, `tags`, `list_tags`, `task_tags`, `worktrees`. See `schema/schema.sql`. Seed data: tags "agent" and "manual".
+
+## Conventions
+
+- Enum <-> string mapping via explicit `ToDb()`/`FromDb()` static methods on each enum
+- Primary keys are `init`-only strings (GUIDs assigned at creation)
+- Nullable fields use `DBNull.Value` checks
+- All methods are async with CancellationToken where applicable
diff --git a/src/ClaudeDo.Ui/CLAUDE.md b/src/ClaudeDo.Ui/CLAUDE.md
new file mode 100644
index 0000000..127ccbc
--- /dev/null
+++ b/src/ClaudeDo.Ui/CLAUDE.md
@@ -0,0 +1,49 @@
+# ClaudeDo.Ui
+
+Avalonia UI layer: views, viewmodels, converters, and the SignalR client.
+
+## Pattern
+
+MVVM with CommunityToolkit.Mvvm source generators:
+- `[ObservableProperty]` for bindable properties
+- `[RelayCommand]` for commands (supports async and CanExecute)
+- All ViewModels inherit `ViewModelBase` (extends `ObservableObject`)
+
+## Views
+
+- **MainWindow** — 3-column DockPanel layout (lists | tasks | detail) with GridSplitter, status bar at bottom
+- **TaskListView** — ListBox of tasks with add/edit/delete toolbar
+- **TaskDetailView** — Task info, live log output, worktree section (merge/keep/discard)
+- **TaskEditorView** — Modal dialog for task create/edit
+- **ListEditorView** — Modal dialog for list create/edit
+- **StatusBarView** — Connection status indicator, active task display
+
+All views use compiled bindings (`x:DataType`).
+
+## ViewModels
+
+- **MainWindowViewModel** — root coordinator; manages list collection, selected list, dialog creation via `Func` factories
+- **TaskListViewModel** — manages task collection for selected list; handles CRUD, "Run Now"
+- **TaskDetailViewModel** — displays task details, streams live log, controls worktree operations
+- **TaskItemViewModel** / **ListItemViewModel** — lightweight display VMs
+- **TaskEditorViewModel** / **ListEditorViewModel** — dialog VMs with validation
+- **StatusBarViewModel** — connection state and active tasks
+
+## Services
+
+- **WorkerClient** — SignalR client connecting to `http://127.0.0.1:47821/hub`. Auto-reconnect with exponential backoff. Methods: StartAsync, RunNowAsync, CancelTaskAsync, WakeQueueAsync. Events: TaskStarted, TaskFinished, TaskMessage, TaskUpdated, WorktreeUpdated
+
+## Converters
+
+- **StatusColorConverter** — task status string -> color (Queued=Blue, Running=Orange, Done=Green, Failed=Red, Manual=Gray)
+- **ConnectionColorConverter** — connection state -> color (Online=Green, Offline=Red)
+
+## Dialog Pattern
+
+Editor dialogs use `TaskCompletionSource` — the dialog sets the result on save/cancel, and the caller awaits the TCS.
+
+## Notes
+
+- Context menus are on both list items and task items
+- Right-click selects the item before showing the context menu
+- "Run Now" CanExecute re-evaluates when worker connection state changes
diff --git a/src/ClaudeDo.Ui/Views/ListEditorView.axaml b/src/ClaudeDo.Ui/Views/ListEditorView.axaml
index c9cb587..3969ff3 100644
--- a/src/ClaudeDo.Ui/Views/ListEditorView.axaml
+++ b/src/ClaudeDo.Ui/Views/ListEditorView.axaml
@@ -12,8 +12,10 @@
-
-
+
+
+
+
0)
+ {
+ var path = result[0].TryGetLocalPath();
+ if (path is not null && vm is not null)
+ vm.WorkingDir = path;
+ }
+ }
}
diff --git a/src/ClaudeDo.Worker/Program.cs b/src/ClaudeDo.Worker/Program.cs
index 75b8171..84af2ec 100644
--- a/src/ClaudeDo.Worker/Program.cs
+++ b/src/ClaudeDo.Worker/Program.cs
@@ -20,6 +20,7 @@ builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+builder.Services.AddSingleton();
builder.Services.AddHostedService();
builder.Services.AddSignalR();
@@ -28,8 +29,14 @@ builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+builder.Services.AddSingleton();
builder.Services.AddSingleton();
+// Agent file management.
+var agentsDir = Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "agents");
+Directory.CreateDirectory(agentsDir);
+builder.Services.AddSingleton(new AgentFileService(agentsDir));
+
// QueueService: singleton + hosted service (same instance).
builder.Services.AddSingleton();
builder.Services.AddHostedService(sp => sp.GetRequiredService());
diff --git a/src/ClaudeDo.Worker/Runner/MessageParser.cs b/src/ClaudeDo.Worker/Runner/MessageParser.cs
deleted file mode 100644
index 9343569..0000000
--- a/src/ClaudeDo.Worker/Runner/MessageParser.cs
+++ /dev/null
@@ -1,33 +0,0 @@
-using System.Text.Json;
-
-namespace ClaudeDo.Worker.Runner;
-
-public static class MessageParser
-{
- public static bool TryExtractResult(string ndjsonLine, out string? result)
- {
- result = null;
- if (string.IsNullOrWhiteSpace(ndjsonLine))
- return false;
-
- try
- {
- using var doc = JsonDocument.Parse(ndjsonLine);
- var root = doc.RootElement;
-
- if (root.TryGetProperty("type", out var typeProp) &&
- typeProp.GetString() == "result" &&
- root.TryGetProperty("result", out var resultProp))
- {
- result = resultProp.GetString();
- return true;
- }
- }
- catch (JsonException)
- {
- // Malformed JSON — not a result line.
- }
-
- return false;
- }
-}
diff --git a/tests/ClaudeDo.Worker.Tests/CLAUDE.md b/tests/ClaudeDo.Worker.Tests/CLAUDE.md
new file mode 100644
index 0000000..9d437f1
--- /dev/null
+++ b/tests/ClaudeDo.Worker.Tests/CLAUDE.md
@@ -0,0 +1,41 @@
+# ClaudeDo.Worker.Tests
+
+xUnit integration tests for the Worker and Data layers.
+
+## Framework
+
+- xUnit 2.5.3 with `xunit.runner.visualstudio`
+- No mocking library — custom sealed fakes (FakeClaudeProcess, FakeHubContext, FakeHubClients, FakeClientProxy)
+- Real SQLite databases per test via `DbFixture`
+- Real git repos for worktree tests via `GitRepoFixture`
+- coverlet for coverage collection
+
+## Test Infrastructure
+
+- **DbFixture** — creates unique temp SQLite DB, applies schema, cleans up DB + WAL/SHM files on dispose
+- **GitRepoFixture** — creates temp git repo with initial commit, configures user/email, handles Windows read-only .git cleanup. Tests skip if git is unavailable.
+
+## Test Areas
+
+| Area | File | What it covers |
+|------|------|----------------|
+| Repositories | `ListRepositoryTests` | CRUD, tag junctions |
+| | `TaskRepositoryTests` | CRUD, status transitions, agent tag filtering, effective tags, stale flip |
+| Runner | `WorktreeManagerTests` | Worktree creation, commit detection, error on non-git dir |
+| | `CommitMessageBuilderTests` | Slug generation, title/description truncation |
+| | `MessageParserTests` | NDJSON parsing, malformed input |
+| Services | `QueueServiceTests` | FIFO ordering, override slot contention, cancellation, active tracking |
+| | `StaleTaskRecoveryTests` | Flips orphaned running tasks to failed |
+
+## Conventions
+
+- Test classes implement `IDisposable` and create fixtures in constructor
+- Helper factory methods for entities: `MakeTask()`, `CreateListAsync()`, `SeedListWithAgentTag()`
+- Concurrency tests use `TaskCompletionSource` as gates for deterministic ordering
+- Git-dependent tests are conditionally skipped via `Skip = ...` when git is not available
+
+## Running
+
+```bash
+dotnet test tests/ClaudeDo.Worker.Tests
+```
diff --git a/tests/ClaudeDo.Worker.Tests/Runner/MessageParserTests.cs b/tests/ClaudeDo.Worker.Tests/Runner/MessageParserTests.cs
deleted file mode 100644
index 56b4faf..0000000
--- a/tests/ClaudeDo.Worker.Tests/Runner/MessageParserTests.cs
+++ /dev/null
@@ -1,53 +0,0 @@
-using ClaudeDo.Worker.Runner;
-
-namespace ClaudeDo.Worker.Tests.Runner;
-
-public sealed class MessageParserTests
-{
- [Fact]
- public void WellFormed_Result_Line_Extracts_Result()
- {
- var line = """{"type":"result","result":"Hello **world**"}""";
- Assert.True(MessageParser.TryExtractResult(line, out var result));
- Assert.Equal("Hello **world**", result);
- }
-
- [Fact]
- public void Non_Result_Type_Returns_False()
- {
- var line = """{"type":"assistant","message":"hi"}""";
- Assert.False(MessageParser.TryExtractResult(line, out var result));
- Assert.Null(result);
- }
-
- [Fact]
- public void Missing_Type_Property_Returns_False()
- {
- var line = """{"result":"data"}""";
- Assert.False(MessageParser.TryExtractResult(line, out var result));
- Assert.Null(result);
- }
-
- [Fact]
- public void Malformed_Json_Returns_False_No_Throw()
- {
- var line = "this is not json {{{";
- Assert.False(MessageParser.TryExtractResult(line, out var result));
- Assert.Null(result);
- }
-
- [Fact]
- public void Empty_Line_Returns_False()
- {
- Assert.False(MessageParser.TryExtractResult("", out _));
- Assert.False(MessageParser.TryExtractResult(" ", out _));
- }
-
- [Fact]
- public void Null_Result_Value_Returns_True_With_Null()
- {
- var line = """{"type":"result","result":null}""";
- Assert.True(MessageParser.TryExtractResult(line, out var result));
- Assert.Null(result);
- }
-}