From c1c4c75979458fa787e48edaedfccf76e73cc5ad Mon Sep 17 00:00:00 2001 From: Mika Kuns Date: Tue, 14 Apr 2026 14:12:21 +0200 Subject: [PATCH] refactor(worker): remove MessageParser (replaced by StreamAnalyzer) --- .claude/settings.local.json | 8 + .../content/color-green-teal.html | 106 + .../1955-1776152447/content/color-theme.html | 107 + .../content/layout-design.html | 169 ++ .../content/task-creation-flow.html | 61 + .../1955-1776152447/content/waiting.html | 3 + .../1955-1776152447/state/server-stopped | 1 + .../1955-1776152447/state/server.pid | 1 + .../content/island-colors.html | 79 + .../content/island-layout.html | 77 + .../3761-1776156321/state/server-stopped | 1 + .../3761-1776156321/state/server.pid | 1 + CLAUDE.md | 54 + docs/improvement-plan.md | 92 + docs/open.md | 193 ++ .../2026-04-14-worker-cli-modernization.md | 2311 +++++++++++++++++ ...6-04-14-worker-cli-modernization-design.md | 514 ++++ src/ClaudeDo.App/CLAUDE.md | 29 + src/ClaudeDo.Data/CLAUDE.md | 41 + src/ClaudeDo.Ui/CLAUDE.md | 49 + src/ClaudeDo.Ui/Views/ListEditorView.axaml | 6 +- src/ClaudeDo.Ui/Views/ListEditorView.axaml.cs | 29 + src/ClaudeDo.Worker/Program.cs | 7 + src/ClaudeDo.Worker/Runner/MessageParser.cs | 33 - tests/ClaudeDo.Worker.Tests/CLAUDE.md | 41 + .../Runner/MessageParserTests.cs | 53 - 26 files changed, 3978 insertions(+), 88 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 .superpowers/brainstorm/1955-1776152447/content/color-green-teal.html create mode 100644 .superpowers/brainstorm/1955-1776152447/content/color-theme.html create mode 100644 .superpowers/brainstorm/1955-1776152447/content/layout-design.html create mode 100644 .superpowers/brainstorm/1955-1776152447/content/task-creation-flow.html create mode 100644 .superpowers/brainstorm/1955-1776152447/content/waiting.html create mode 100644 .superpowers/brainstorm/1955-1776152447/state/server-stopped create mode 100644 .superpowers/brainstorm/1955-1776152447/state/server.pid create mode 100644 .superpowers/brainstorm/3761-1776156321/content/island-colors.html create mode 100644 .superpowers/brainstorm/3761-1776156321/content/island-layout.html create mode 100644 .superpowers/brainstorm/3761-1776156321/state/server-stopped create mode 100644 .superpowers/brainstorm/3761-1776156321/state/server.pid create mode 100644 CLAUDE.md create mode 100644 docs/improvement-plan.md create mode 100644 docs/open.md create mode 100644 docs/superpowers/plans/2026-04-14-worker-cli-modernization.md create mode 100644 docs/superpowers/specs/2026-04-14-worker-cli-modernization-design.md create mode 100644 src/ClaudeDo.App/CLAUDE.md create mode 100644 src/ClaudeDo.Data/CLAUDE.md create mode 100644 src/ClaudeDo.Ui/CLAUDE.md delete mode 100644 src/ClaudeDo.Worker/Runner/MessageParser.cs create mode 100644 tests/ClaudeDo.Worker.Tests/CLAUDE.md delete mode 100644 tests/ClaudeDo.Worker.Tests/Runner/MessageParserTests.cs 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
+
+
+
+
+ +
+
+
Setup CI
+
Done
+
+
+
+ + Add a task... +
+
+
+
+

Forest Teal

+

Accent: #3d9474. Distinctly greener, still muted. Earthy.

+
+
+ +
+
+
+
+ + My Project +
+
+
+
+
Fix login bug
+
agent
+
+
+
+
+ +
+
+
Setup CI
+
Done
+
+
+
+ + Add a task... +
+
+
+
+

Jade

+

Accent: #4a9880. Balanced green-teal midpoint. Calm but not cold.

+
+
+ +
+
+
+
+ + My Project +
+
+
+
+
Fix login bug
+
agent
+
+
+
+
+ +
+
+
Setup CI
+
Done
+
+
+
+ + 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?

+ +
+
+
Current: Toolbar Style
+
+
+ +
+
Lists
+
+
My Project
+
Backend Work
+
UI Polish
+
+
+ + + +
+
+ +
+
Tasks
+
+
+
+
Fix login bug
+
agent
+
+
+ Running + +
+
+
+
Add dark mode
+
+ Manual + +
+
+
+
+ + + +
+
+
+
+
+ +
+
Proposed: To Do Style
+
+
+ +
+
Lists
+
+
+ + My Project +
+
+ + Backend Work +
+
+ + UI Polish +
+
+
+
+ + New List +
+
+
+ +
+
My Project
+
+
+
+
+
+
+
Fix login bug
+
agent · Running
+
+
+
+
+
+
Add dark mode
+
manual
+
+
+
+
+ +
+
+
Setup CI pipeline
+
agent · Done
+
+
+
+ +
+
+ + Add a task... +
+
+
+
+
+
+
+ +

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

+ +
+
+
+
+
+
Lists
+
My Project
+
+
+
My Project
+
+
+ Fix login bug +
+
+
+
Detail
+
+
+
+
+

Neutral Slate

+

Base: #1b1e23 · Islands: #252a30
Fast kein Grün — kühl, neutral, wie VS Code Dark+

+
+
+ +
+
+
+
+
Lists
+
My Project
+
+
+
My Project
+
+
+ Fix login bug +
+
+
+
Detail
+
+
+
+
+

Warm Charcoal

+

Base: #1c1e21 · Islands: #272a2e
Minimal warm, komplett neutral. Wie Rider's New UI Dark.

+
+
+ +
+
+
+
+
Lists
+
My Project
+
+
+
My Project
+
+
+ Fix login bug +
+
+
+
Detail
+
+
+
+
+

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

+ +
+
+
Current: Flat columns, black gaps
+
+
+
+
Lists
+
My Project
+
Backend
+
+
+
+
My Project
+
Fix login bug
+
+
+
+
Detail
+
+
+
+
+ +
+
Proposed: Floating islands on tinted base
+
+
+
+
Lists
+
My Project
+
Backend
+
+
+
My Project
+
+
+
+
Fix login bug
+
agent · manual
+
+
+
+
+ +
+
+
Setup CI
+
done
+
+
+
+
+
Fix login bug
+
Status
+
Manual
+
Tags
+
+ agent +
+
+
+
+
+
+ +

Die Änderungen

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